pi-better-background-tasks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # pi-better-background-tasks
2
+
3
+ Generic durable background tasks for [pi](https://pi.dev). The extension lets an
4
+ agent start long-running commands or command-based watchers without blocking the
5
+ foreground turn. Task metadata and logs are stored under the OS temp directory,
6
+ so status and logs remain available across `/reload` and ordinary session use.
7
+
8
+ The core is deliberately domain-neutral. GitHub, Mergify, Cloud Build, Vercel,
9
+ and similar integrations should be thin presets on top of the generic watcher,
10
+ not special cases in the runtime.
11
+
12
+ ## Tools
13
+
14
+ | Tool | Purpose |
15
+ | ---- | ------- |
16
+ | `bg_task_spawn` | Start a long-running process and return immediately. |
17
+ | `bg_task_watch` | Poll a command until success/failure/timeout. |
18
+ | `bg_task_list` | List known tasks. |
19
+ | `bg_task_status` | Inspect one task. Compact by default; pass `verbose:true` for full metadata JSON. |
20
+ | `bg_task_log` | Read a bounded task log tail by default, or a full log on request. |
21
+ | `bg_task_stop` | Cancel a watcher or terminate a process task. |
22
+ | `bg_task` | Action-based wrapper for `spawn`, `watch`, `list`, `status`, `log`, `stop`, `clear`. |
23
+ | `bg_status` | Small action wrapper for `list`, `status`, `log`, `stop`, `clear`. |
24
+
25
+ `clear` dismisses terminal tasks for the active cwd/session so they no longer
26
+ count as foreground attention. It keeps metadata and logs on disk for explicit
27
+ inspection.
28
+
29
+ Callbacks are terminal-only by default. When `callback` is not false, a task that
30
+ reaches a terminal state queues one follow-up message telling the foreground
31
+ agent which task finished and which tool to call for details.
32
+
33
+ Callbacks point to `bg_task_status` first. The default status response is a
34
+ compact model-facing summary that omits large command bodies; use
35
+ `verbose:true` only when full metadata is required. `bg_task_log` defaults to a
36
+ 20-line tail, and `tail_lines: 0` requests the full log explicitly.
37
+
38
+ Command watchers default to a 15 minute timeout when `timeout_seconds` is
39
+ omitted. Pass `timeout_seconds: 0` to disable the watcher timeout explicitly.
40
+ Spawned processes do not get a default timeout.
41
+
42
+ ## Examples
43
+
44
+ ```json
45
+ {
46
+ "name": "dev server",
47
+ "command": "npm run dev",
48
+ "cwd": "/path/to/app"
49
+ }
50
+ ```
51
+
52
+ ```json
53
+ {
54
+ "name": "wait for success",
55
+ "command": "node ./scripts/check-status.js --json",
56
+ "interval_seconds": 15,
57
+ "timeout_seconds": 600,
58
+ "success_when": { "type": "json_path_equals", "path": "$.status", "value": "done" },
59
+ "failure_when": { "type": "json_path_equals", "path": "$.status", "value": "failed" }
60
+ }
61
+ ```
62
+
63
+ Commands run through `/bin/bash -lc` by default so shell-mode watchers behave
64
+ consistently across sessions. Set `PI_BETTER_BACKGROUND_TASKS_SHELL` to override
65
+ that shell, or pass `argv` and `shell:false` to avoid shell parsing.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "pi-better-background-tasks",
3
+ "version": "0.1.0",
4
+ "description": "Generic durable background task and watcher tools for pi.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "background-tasks"
10
+ ],
11
+ "pi": {
12
+ "extensions": [
13
+ "./src/index.ts"
14
+ ]
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/1aboveio/pi-better-harness.git",
19
+ "directory": "packages/pi-better-background-tasks"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/1aboveio/pi-better-harness/issues"
23
+ },
24
+ "homepage": "https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-background-tasks#readme",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit",
30
+ "test": "vitest run",
31
+ "test:unit": "vitest run src/conditions.test.ts src/runtime.test.ts",
32
+ "test:e2e": "vitest run src/e2e.test.ts",
33
+ "test:cross-session": "vitest run src/e2e.test.ts -t \"different session\"",
34
+ "test:golden": "vitest run src/golden-path.test.ts",
35
+ "test:integration": "npm run test:e2e && npm run test:golden"
36
+ },
37
+ "files": [
38
+ "src/**/*.ts",
39
+ "!src/**/*.test.ts",
40
+ "README.md"
41
+ ],
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-coding-agent": "*",
44
+ "typebox": "*"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.10.0",
48
+ "typescript": "^5.7.0",
49
+ "vitest": "^3.0.0",
50
+ "typebox": "^1.1.38"
51
+ }
52
+ }
@@ -0,0 +1,92 @@
1
+ import type { CommandResult, Condition } from "./types.js";
2
+
3
+ export interface ConditionMatch {
4
+ matched: boolean;
5
+ value?: unknown;
6
+ error?: string;
7
+ }
8
+
9
+ export function evaluateCondition(condition: Condition, result: CommandResult): ConditionMatch {
10
+ switch (condition.type) {
11
+ case "exit_code":
12
+ return { matched: result.exitCode === condition.equals, value: result.exitCode };
13
+ case "stdout_contains":
14
+ return { matched: result.stdout.includes(condition.value) };
15
+ case "stderr_contains":
16
+ return { matched: result.stderr.includes(condition.value) };
17
+ case "json_path_equals": {
18
+ const resolved = resolveJsonPath(result.stdout, condition.path);
19
+ if (!resolved.ok) return { matched: false, error: resolved.error };
20
+ return { matched: deepEqual(resolved.value, condition.value), value: resolved.value };
21
+ }
22
+ case "json_path_exists": {
23
+ const resolved = resolveJsonPath(result.stdout, condition.path);
24
+ return resolved.ok ? { matched: true, value: resolved.value } : { matched: false, error: resolved.error };
25
+ }
26
+ }
27
+ }
28
+
29
+ export function resolveJsonPath(jsonText: string, path: string): { ok: true; value: unknown } | { ok: false; error: string } {
30
+ let current: unknown;
31
+ try {
32
+ current = JSON.parse(jsonText);
33
+ } catch (error) {
34
+ return { ok: false, error: `stdout is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
35
+ }
36
+
37
+ const tokens = parseJsonPath(path);
38
+ if (!tokens) return { ok: false, error: `unsupported JSON path: ${path}` };
39
+
40
+ for (const token of tokens) {
41
+ if (typeof token === "number") {
42
+ if (!Array.isArray(current) || token < 0 || token >= current.length) {
43
+ return { ok: false, error: `JSON path not found: ${path}` };
44
+ }
45
+ current = current[token];
46
+ continue;
47
+ }
48
+ if (current === null || typeof current !== "object" || !(token in current)) {
49
+ return { ok: false, error: `JSON path not found: ${path}` };
50
+ }
51
+ current = (current as Record<string, unknown>)[token];
52
+ }
53
+
54
+ return { ok: true, value: current };
55
+ }
56
+
57
+ function parseJsonPath(path: string): Array<string | number> | undefined {
58
+ if (!path.startsWith("$")) return undefined;
59
+ const tokens: Array<string | number> = [];
60
+ let i = 1;
61
+ while (i < path.length) {
62
+ if (path[i] === ".") {
63
+ i += 1;
64
+ const start = i;
65
+ while (i < path.length && /[A-Za-z0-9_$-]/.test(path[i]!)) i += 1;
66
+ if (i === start) return undefined;
67
+ tokens.push(path.slice(start, i));
68
+ continue;
69
+ }
70
+ if (path[i] === "[") {
71
+ const end = path.indexOf("]", i);
72
+ if (end === -1) return undefined;
73
+ const raw = path.slice(i + 1, end).trim();
74
+ const quoted = raw.match(/^['\"](.+)['\"]$/);
75
+ if (quoted) {
76
+ tokens.push(quoted[1]!);
77
+ } else if (/^\d+$/.test(raw)) {
78
+ tokens.push(Number(raw));
79
+ } else {
80
+ return undefined;
81
+ }
82
+ i = end + 1;
83
+ continue;
84
+ }
85
+ return undefined;
86
+ }
87
+ return tokens;
88
+ }
89
+
90
+ function deepEqual(a: unknown, b: unknown): boolean {
91
+ return JSON.stringify(a) === JSON.stringify(b);
92
+ }
@@ -0,0 +1,116 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { listMetas } from "./registry.js";
3
+ import type { BackgroundTaskMeta } from "./types.js";
4
+ import { isTerminalStatus } from "./types.js";
5
+
6
+ const GOAL_READY_EVENT = "pi-better-goal:ready";
7
+ const GOAL_REGISTER_PROVIDER_EVENT = "pi-better-goal:register-provider";
8
+ const readySubscriptions = new WeakSet<ExtensionAPI>();
9
+
10
+ export interface GoalBackgroundWorkItem {
11
+ id: string;
12
+ label?: string;
13
+ status: string;
14
+ active: boolean;
15
+ unhealthy?: boolean;
16
+ terminal?: boolean;
17
+ attention?: boolean;
18
+ startedAt?: number;
19
+ endedAt?: number;
20
+ details?: Record<string, unknown>;
21
+ }
22
+
23
+ export interface GoalBackgroundProviderSnapshot {
24
+ providerId: string;
25
+ label?: string;
26
+ items: GoalBackgroundWorkItem[];
27
+ }
28
+
29
+ export function collectBackgroundTaskGoalActivity(
30
+ metas: BackgroundTaskMeta[] = listMetas(),
31
+ ctx?: ExtensionContext,
32
+ now = Date.now(),
33
+ ): GoalBackgroundProviderSnapshot {
34
+ return {
35
+ providerId: "background-tasks",
36
+ label: "Background Tasks",
37
+ items: metas
38
+ .filter((meta) => isVisibleGoalActivity(meta, ctx, now))
39
+ .map(backgroundTaskToGoalItem),
40
+ };
41
+ }
42
+
43
+ export function registerBackgroundTasksGoalProvider(pi: ExtensionAPI): void {
44
+ const emitProvider = (): void => {
45
+ pi.events?.emit(GOAL_REGISTER_PROVIDER_EVENT, {
46
+ id: "background-tasks",
47
+ label: "Background Tasks",
48
+ getActivity: (ctx: ExtensionContext) => collectBackgroundTaskGoalActivity(listMetas(), ctx),
49
+ });
50
+ };
51
+
52
+ if (!readySubscriptions.has(pi)) {
53
+ pi.events?.on?.(GOAL_READY_EVENT, emitProvider);
54
+ readySubscriptions.add(pi);
55
+ }
56
+ emitProvider();
57
+ }
58
+
59
+ const TERMINAL_GOAL_ACTIVITY_RETENTION_MS = 30_000;
60
+
61
+ function isVisibleGoalActivity(meta: BackgroundTaskMeta, ctx: ExtensionContext | undefined, now: number): boolean {
62
+ if (meta.dismissedAt !== undefined) return false;
63
+ if (ctx && !belongsToGoalActivitySession(meta, ctx)) return false;
64
+ if (meta.status === "running") return true;
65
+ if (!ctx) return true;
66
+ if (typeof meta.endedAt !== "number") return true;
67
+ return now - meta.endedAt < TERMINAL_GOAL_ACTIVITY_RETENTION_MS;
68
+ }
69
+
70
+ function belongsToGoalActivitySession(meta: BackgroundTaskMeta, ctx: ExtensionContext): boolean {
71
+ const active = getGoalActivityOrigin(ctx);
72
+ const origin = meta.callbackOrigin;
73
+ if (origin) {
74
+ if (origin.cwd !== active.cwd) return false;
75
+ if (origin.sessionId || active.sessionId) return origin.sessionId === active.sessionId;
76
+ return true;
77
+ }
78
+ if (active.sessionId) return false;
79
+ return meta.cwd === active.cwd;
80
+ }
81
+
82
+ function getGoalActivityOrigin(ctx: ExtensionContext) {
83
+ let sessionId: string | undefined;
84
+ try {
85
+ sessionId = ctx.sessionManager?.getSessionId();
86
+ } catch {
87
+ sessionId = undefined;
88
+ }
89
+ return { cwd: ctx.cwd, sessionId };
90
+ }
91
+
92
+ function backgroundTaskToGoalItem(meta: BackgroundTaskMeta): GoalBackgroundWorkItem {
93
+ const active = meta.status === "running";
94
+ const terminal = isTerminalStatus(meta.status);
95
+ const attention = terminal && meta.status !== "succeeded";
96
+ const item: GoalBackgroundWorkItem = {
97
+ id: meta.id,
98
+ status: meta.status,
99
+ active,
100
+ terminal,
101
+ attention,
102
+ startedAt: meta.startedAt,
103
+ details: {
104
+ kind: meta.kind,
105
+ callback: meta.callback !== false,
106
+ cwd: meta.cwd,
107
+ command: meta.command,
108
+ argv: meta.argv,
109
+ logPath: meta.logPath,
110
+ spawnPid: meta.spawnPid,
111
+ },
112
+ };
113
+ if (meta.name !== undefined) item.label = meta.name;
114
+ if (meta.endedAt !== undefined) item.endedAt = meta.endedAt;
115
+ return item;
116
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { disposeBackgroundWorkNavigator } from "../../navigator/index.ts";
3
+ import { registerBackgroundTasksGoalProvider } from "./goal-provider.js";
4
+ import { clearBackgroundTasksNavigatorSession, ensureBackgroundTasksNavigator, ensureBackgroundTasksNavigatorProvider } from "./navigator-provider.js";
5
+ import { registerTools } from "./tools.js";
6
+
7
+ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
8
+ registerBackgroundTasksGoalProvider(pi);
9
+ ensureBackgroundTasksNavigatorProvider(pi);
10
+ pi.on("session_start", async (_event, ctx) => {
11
+ registerBackgroundTasksGoalProvider(pi);
12
+ ensureBackgroundTasksNavigator(ctx);
13
+ });
14
+ pi.on("session_before_switch", async () => {
15
+ clearBackgroundTasksNavigatorSession();
16
+ disposeBackgroundWorkNavigator();
17
+ });
18
+ pi.on("session_shutdown", async (_event, ctx) => {
19
+ clearBackgroundTasksNavigatorSession();
20
+ disposeBackgroundWorkNavigator(ctx);
21
+ });
22
+ registerTools(pi);
23
+ }
package/src/logs.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { appendFileSync, readFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { mkdirSync } from "node:fs";
4
+ import type { CommandResult } from "./types.js";
5
+
6
+ export function appendWatchResult(logPath: string, result: CommandResult): void {
7
+ mkdirSync(dirname(logPath), { recursive: true });
8
+ const header = `\n--- check ${new Date(result.startedAt).toISOString()} exit=${result.exitCode ?? "null"} signal=${result.signal ?? "null"} duration_ms=${result.endedAt - result.startedAt} ---\n`;
9
+ appendFileSync(logPath, header);
10
+ if (result.stdout) appendFileSync(logPath, result.stdout.endsWith("\n") ? result.stdout : `${result.stdout}\n`);
11
+ if (result.stderr) appendFileSync(logPath, `[stderr]\n${result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`}`);
12
+ }
13
+
14
+ export function appendLine(logPath: string, line: string): void {
15
+ mkdirSync(dirname(logPath), { recursive: true });
16
+ appendFileSync(logPath, `${line}\n`);
17
+ }
18
+
19
+ export function readLog(logPath: string, tailLines?: number): { text: string; truncated: boolean } {
20
+ let text: string;
21
+ try {
22
+ text = readFileSync(logPath, "utf8");
23
+ } catch {
24
+ return { text: "", truncated: false };
25
+ }
26
+ if (!tailLines || tailLines <= 0) return { text, truncated: false };
27
+ const lines = text.split(/\r?\n/);
28
+ if (lines.length <= tailLines) return { text, truncated: false };
29
+ return { text: lines.slice(-tailLines).join("\n"), truncated: true };
30
+ }
@@ -0,0 +1,212 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ ensureBackgroundWorkNavigator,
4
+ refreshBackgroundWorkNavigator,
5
+ registerBackgroundWorkProvider,
6
+ type BackgroundWorkDetail,
7
+ type BackgroundWorkProvider,
8
+ type BackgroundWorkRow,
9
+ } from "../../navigator/index.ts";
10
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
11
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
12
+ import { readLog } from "./logs.js";
13
+ import { listMetas, readMeta, writeMeta } from "./registry.js";
14
+ import { stopTask } from "./runtime.js";
15
+ import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, BackgroundTaskStatus } from "./types.js";
16
+
17
+ let unregister: (() => void) | undefined;
18
+ let piRef: ExtensionAPI | undefined;
19
+ let activeNavigatorOrigin: BackgroundTaskCallbackOrigin | undefined;
20
+ const TERMINAL_NAVIGATOR_RETENTION_MS = 30_000;
21
+
22
+ export function ensureBackgroundTasksNavigatorProvider(pi: ExtensionAPI): void {
23
+ piRef = pi;
24
+ if (unregister) return;
25
+ unregister = registerBackgroundWorkProvider(provider);
26
+ }
27
+
28
+ export function ensureBackgroundTasksNavigator(ctx: ExtensionContext): void {
29
+ activeNavigatorOrigin = getNavigatorOrigin(ctx);
30
+ ensureBackgroundWorkNavigator(ctx, {
31
+ createDefaultEditor: (tui, theme, keybindings) => new CustomEditor(tui as never, theme as never, keybindings as never),
32
+ isOpenTrigger: (data) => matchesKey(data, Key.left),
33
+ matchKey: (data, keyId) => matchesKey(data, keyId as Parameters<typeof matchesKey>[1]),
34
+ truncate: truncateToWidth,
35
+ });
36
+ }
37
+
38
+ export function clearBackgroundTasksNavigatorSession(): void {
39
+ activeNavigatorOrigin = undefined;
40
+ }
41
+
42
+ export function refreshBackgroundTasksNavigator(ctx?: ExtensionContext): void {
43
+ refreshBackgroundWorkNavigator(ctx);
44
+ }
45
+
46
+ const provider: BackgroundWorkProvider = {
47
+ id: "background-tasks",
48
+ label: "Background Tasks",
49
+ priority: 20,
50
+ visibleCount: () => visibleMetas(Date.now()).filter((meta) => meta.status === "running").length,
51
+ listRows: (now) => visibleMetas(now).map((meta) => rowFromMeta(meta, now)),
52
+ detail: (id, now, options) => detailFromMeta(readMeta(id), now, options),
53
+ armCloseLabel: (row) => row.status === "running" ? "x again to stop" : "x again to dismiss",
54
+ close: (id) => {
55
+ const meta = readMeta(id);
56
+ if (!meta) return { action: "missing", providerId: "background-tasks", id };
57
+ if (meta.status === "running") {
58
+ const stopped = stopTask(piRef as ExtensionAPI, id);
59
+ return { action: "stopped", providerId: "background-tasks", id, status: stopped?.status };
60
+ }
61
+ meta.dismissedAt = Date.now();
62
+ writeMeta(meta);
63
+ return { action: "dismissed", providerId: "background-tasks", id, status: meta.status };
64
+ },
65
+ };
66
+
67
+ function visibleMetas(now = Date.now()): BackgroundTaskMeta[] {
68
+ return listMetas().filter((meta) => meta.dismissedAt === undefined && belongsToActiveNavigatorSession(meta) && !isExpiredTerminalNavigatorRow(meta, now));
69
+ }
70
+
71
+ function getNavigatorOrigin(ctx: ExtensionContext): BackgroundTaskCallbackOrigin {
72
+ let sessionId: string | undefined;
73
+ try {
74
+ sessionId = ctx.sessionManager?.getSessionId();
75
+ } catch {
76
+ sessionId = undefined;
77
+ }
78
+ return { cwd: ctx.cwd, sessionId };
79
+ }
80
+
81
+ function belongsToActiveNavigatorSession(meta: BackgroundTaskMeta): boolean {
82
+ const active = activeNavigatorOrigin;
83
+ if (!active) return false;
84
+ const origin = meta.callbackOrigin;
85
+ if (origin) {
86
+ if (origin.cwd !== active.cwd) return false;
87
+ if (origin.sessionId || active.sessionId) return origin.sessionId === active.sessionId;
88
+ return true;
89
+ }
90
+ if (active.sessionId) return false;
91
+ return meta.cwd === active.cwd;
92
+ }
93
+
94
+ function isExpiredTerminalNavigatorRow(meta: BackgroundTaskMeta, now: number): boolean {
95
+ if (meta.status === "running") return false;
96
+ const endedAt = meta.endedAt;
97
+ return typeof endedAt === "number" && now - endedAt >= TERMINAL_NAVIGATOR_RETENTION_MS;
98
+ }
99
+
100
+ function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
101
+ const elapsed = formatDuration((meta.endedAt ?? now) - meta.startedAt);
102
+ return {
103
+ providerId: "background-tasks",
104
+ id: meta.id,
105
+ name: meta.name,
106
+ status: meta.status,
107
+ statusTone: toneForStatus(meta.status),
108
+ kind: meta.kind === "command_watch" ? "watch" : "process",
109
+ elapsed,
110
+ primary: compactCommandLabel(meta),
111
+ command: commandLabel(meta),
112
+ tool: compactCommandLabel(meta),
113
+ secondary: secondaryLabel(meta),
114
+ facts: factsForMeta(meta, now),
115
+ sortStartedAt: meta.startedAt,
116
+ };
117
+ }
118
+
119
+ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
120
+ if (!meta) return null;
121
+ const log = readLog(meta.logPath, options?.logTailLines ?? 10);
122
+ const command = commandLabel(meta);
123
+ const metadata = [
124
+ { label: "provider", value: "Background Tasks" },
125
+ { label: "kind", value: meta.kind === "command_watch" ? "watch" : "process" },
126
+ { label: "elapsed", value: formatDuration((meta.endedAt ?? now) - meta.startedAt) },
127
+ { label: "cwd", value: meta.cwd },
128
+ { label: "pid", value: meta.pid != null ? String(meta.pid) : "-" },
129
+ { label: "pgid", value: meta.pgid != null ? String(meta.pgid) : "-" },
130
+ { label: "log", value: meta.logPath },
131
+ ];
132
+ if (meta.deadlineAt) metadata.push({ label: "deadline", value: formatDuration(meta.deadlineAt - now) });
133
+ if (meta.lastCheckedAt) metadata.push({ label: "checked", value: `${formatDuration(now - meta.lastCheckedAt)} ago` });
134
+ if (meta.lastExitCode !== undefined) metadata.push({ label: "exit", value: String(meta.lastExitCode) });
135
+ if (meta.error) metadata.push({ label: "error", value: meta.error });
136
+ return {
137
+ providerId: "background-tasks",
138
+ id: meta.id,
139
+ title: meta.name || meta.id,
140
+ status: meta.status,
141
+ statusTone: toneForStatus(meta.status),
142
+ subtitle: compactCommandLabel(meta),
143
+ metadata,
144
+ foldedSections: [{
145
+ id: "command",
146
+ label: "command",
147
+ text: command,
148
+ collapsedText: compactCommandLabel(meta),
149
+ }],
150
+ evidence: {
151
+ label: log.truncated ? "log tail" : "log",
152
+ text: log.text || "(log is empty)",
153
+ },
154
+ footerActions: [meta.status === "running" ? "x stop" : "x dismiss"],
155
+ };
156
+ }
157
+
158
+ function toneForStatus(status: BackgroundTaskStatus): BackgroundWorkRow["statusTone"] {
159
+ switch (status) {
160
+ case "running": return "running";
161
+ case "succeeded": return "success";
162
+ case "failed":
163
+ case "timed_out": return "failed";
164
+ case "cancelled": return "warning";
165
+ default: return "muted";
166
+ }
167
+ }
168
+
169
+ function commandLabel(meta: BackgroundTaskMeta): string {
170
+ if (meta.command) return meta.command;
171
+ if (meta.argv?.length) return meta.argv.join(" ");
172
+ return "(no command recorded)";
173
+ }
174
+
175
+ function compactCommandLabel(meta: BackgroundTaskMeta): string {
176
+ const value = commandLabel(meta).trim();
177
+ const parts = value.split(/\s+/).filter(Boolean);
178
+ if (parts.length <= 3) return value;
179
+ return parts.slice(0, 3).join(" ");
180
+ }
181
+
182
+ function secondaryLabel(meta: BackgroundTaskMeta): string | undefined {
183
+ const parts = [];
184
+ if (meta.cwd) parts.push(meta.cwd);
185
+ if (meta.pid != null) parts.push(`pid ${meta.pid}`);
186
+ if (meta.lastExitCode !== undefined) parts.push(`exit ${meta.lastExitCode}`);
187
+ return parts.length ? parts.join(" · ") : undefined;
188
+ }
189
+
190
+ function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
191
+ const facts: string[] = [];
192
+ if (meta.kind === "command_watch" && meta.intervalMs) facts.push(`every ${formatDuration(meta.intervalMs)}`);
193
+ if (meta.deadlineAt && meta.status === "running") facts.push(`${formatDuration(meta.deadlineAt - now)} left`);
194
+ if (meta.result && meta.status !== "running") {
195
+ const reason = typeof meta.result === "object" && meta.result && "reason" in meta.result
196
+ ? String((meta.result as { reason?: unknown }).reason)
197
+ : "result";
198
+ facts.push(reason);
199
+ }
200
+ return facts.slice(0, 2);
201
+ }
202
+
203
+ function formatDuration(ms: number): string {
204
+ const seconds = Math.max(0, Math.round(ms / 1000));
205
+ if (seconds < 60) return `${seconds}s`;
206
+ const minutes = Math.floor(seconds / 60);
207
+ const rest = seconds % 60;
208
+ if (minutes < 60) return `${minutes}m ${rest.toString().padStart(2, "0")}s`;
209
+ const hours = Math.floor(minutes / 60);
210
+ const min = minutes % 60;
211
+ return `${hours}h ${min.toString().padStart(2, "0")}m`;
212
+ }
package/src/process.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFileSync, closeSync, mkdirSync, openSync, writeSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import type { ChildProcess } from "node:child_process";
5
+ import type { CommandResult, CommandSpec } from "./types.js";
6
+
7
+ const DEFAULT_SHELL = process.env.PI_BETTER_BACKGROUND_TASKS_SHELL || "/bin/bash";
8
+
9
+ export interface SpawnedProcess {
10
+ child: ChildProcess;
11
+ pgid?: number;
12
+ }
13
+
14
+ export function validateCommandSpec(spec: CommandSpec): void {
15
+ if (spec.shell === false) {
16
+ if (!spec.argv || spec.argv.length === 0 || !spec.argv[0]) {
17
+ throw new Error("argv with at least one element is required when shell:false");
18
+ }
19
+ return;
20
+ }
21
+ if (!spec.command || spec.command.trim().length === 0) {
22
+ throw new Error("command is required unless shell:false with argv is provided");
23
+ }
24
+ }
25
+
26
+ export function spawnCommand(spec: CommandSpec, logPath: string, detached: boolean): SpawnedProcess {
27
+ validateCommandSpec(spec);
28
+ mkdirSync(dirname(logPath), { recursive: true });
29
+ const fd = openSync(logPath, "a");
30
+ const child = spawnArgs(spec, detached, ["ignore", fd, fd]);
31
+ writeSync(fd, `\n--- spawn ${new Date().toISOString()} pid=${child.pid ?? "unknown"} ---\n`);
32
+ closeSync(fd);
33
+ child.on("close", (code, signal) => {
34
+ appendFileSync(logPath, `\n--- exit ${new Date().toISOString()} code=${code ?? "null"} signal=${signal ?? "null"} ---\n`);
35
+ });
36
+ return { child, pgid: detached && child.pid ? child.pid : undefined };
37
+ }
38
+
39
+ export function runCommandOnce(spec: CommandSpec, maxBufferBytes = 1024 * 1024): Promise<CommandResult> {
40
+ validateCommandSpec(spec);
41
+ const startedAt = Date.now();
42
+ const child = spawnArgs(spec, false, ["ignore", "pipe", "pipe"]);
43
+ let stdout = "";
44
+ let stderr = "";
45
+ child.stdout?.on("data", (chunk: Buffer) => {
46
+ if (Buffer.byteLength(stdout) < maxBufferBytes) stdout += chunk.toString("utf8");
47
+ });
48
+ child.stderr?.on("data", (chunk: Buffer) => {
49
+ if (Buffer.byteLength(stderr) < maxBufferBytes) stderr += chunk.toString("utf8");
50
+ });
51
+ return new Promise((resolve, reject) => {
52
+ child.on("error", reject);
53
+ child.on("close", (exitCode, signal) => {
54
+ resolve({ exitCode, signal, stdout, stderr, startedAt, endedAt: Date.now() });
55
+ });
56
+ });
57
+ }
58
+
59
+ export function stopProcessGroup(pid: number, pgid?: number): void {
60
+ const target = pgid ?? pid;
61
+ try {
62
+ process.kill(-target, "SIGTERM");
63
+ return;
64
+ } catch {
65
+ process.kill(pid, "SIGTERM");
66
+ }
67
+ }
68
+
69
+ export function processExists(pid: number): boolean {
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function spawnArgs(
79
+ spec: CommandSpec,
80
+ detached: boolean,
81
+ stdio: ["ignore", "pipe" | number, "pipe" | number],
82
+ ): ChildProcess {
83
+ const env = { ...process.env, ...spec.env };
84
+ if (spec.shell === false) {
85
+ const [command, ...args] = spec.argv!;
86
+ return spawn(command!, args, {
87
+ cwd: spec.cwd,
88
+ env,
89
+ detached,
90
+ stdio,
91
+ });
92
+ }
93
+ return spawn(DEFAULT_SHELL, ["-lc", spec.command!], {
94
+ cwd: spec.cwd,
95
+ env,
96
+ detached,
97
+ stdio,
98
+ });
99
+ }