pi-better-harness 0.2.0 → 0.2.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,10 +1,47 @@
1
- import { spawn } from "node:child_process";
2
- import { appendFileSync, closeSync, mkdirSync, openSync, writeSync } from "node:fs";
3
- import { dirname } from "node:path";
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, writeSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
4
  import type { ChildProcess } from "node:child_process";
5
5
  import type { CommandResult, CommandSpec } from "./types.js";
6
6
 
7
- const DEFAULT_SHELL = process.env.PI_BETTER_BACKGROUND_TASKS_SHELL || "/bin/bash";
7
+ /** Known Git for Windows locations; `bash -lc` needs a real bash, not the WSL shim. */
8
+ const WINDOWS_BASH_CANDIDATES = [
9
+ "C:\\Program Files\\Git\\bin\\bash.exe",
10
+ "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
11
+ "C:\\Program Files\\Git\\usr\\bin\\bash.exe",
12
+ ];
13
+
14
+ /**
15
+ * Resolve the shell used for `command` specs.
16
+ *
17
+ * POSIX keeps `/bin/bash`. Windows has no `/bin/bash`, and the `bash.exe` found
18
+ * on PATH is usually the WSL launcher in System32 or the WindowsApps alias,
19
+ * either of which would run the command inside WSL instead of Windows. Prefer
20
+ * an explicit override, then Git for Windows, then a non-WSL `bash.exe` on PATH.
21
+ *
22
+ * Resolved lazily at spawn time, not module load: env-injection extensions
23
+ * (e.g. pi-env) may apply settings.json `env` values after this module is
24
+ * evaluated, and those overrides must still take effect.
25
+ *
26
+ * Exposed for tests and reuse.
27
+ */
28
+ export function resolveDefaultShell(): string {
29
+ const fromEnv = process.env.PI_BETTER_BACKGROUND_TASKS_SHELL;
30
+ if (fromEnv) return fromEnv;
31
+ if (process.platform !== "win32") return "/bin/bash";
32
+ for (const candidate of WINDOWS_BASH_CANDIDATES) {
33
+ if (existsSync(candidate)) return candidate;
34
+ }
35
+ for (const dir of (process.env.PATH ?? "").split(";")) {
36
+ const trimmed = dir.trim();
37
+ if (!trimmed || /(^|[\\/])(system32|windowsapps)([\\/]|$)/i.test(trimmed)) continue;
38
+ const candidate = join(trimmed, "bash.exe");
39
+ if (existsSync(candidate)) return candidate;
40
+ }
41
+ // Nothing usable found: keep the POSIX default so the failure surfaces as a
42
+ // logged spawn error for the task instead of crashing the whole host process.
43
+ return "/bin/bash";
44
+ }
8
45
 
9
46
  /** How long a timed-out process group has to exit on SIGTERM before SIGKILL. */
10
47
  const TERMINATION_GRACE_MS = 2_000;
@@ -26,15 +63,98 @@ export function validateCommandSpec(spec: CommandSpec): void {
26
63
  }
27
64
  }
28
65
 
66
+ /** Convert a Windows path to the `/c/...` form MSYS bash resolves in redirections. Exposed for tests and reuse. */
67
+ export function toMsysPath(path: string): string {
68
+ const forward = path.replace(/\\/g, "/");
69
+ const drive = /^([A-Za-z]):(\/.+)$/.exec(forward);
70
+ return drive ? `/${drive[1].toLowerCase()}${drive[2]}` : forward;
71
+ }
72
+
73
+ /** Single-quote a value for safe literal use in a bash script line. Exposed for tests and reuse. */
74
+ export function bashSingleQuote(value: string): string {
75
+ return `'${value.replace(/'/g, "'\\''")}'`;
76
+ }
77
+
78
+ /**
79
+ * On Windows, numeric fds above 2 are unusable as child stdio: Node spawns the
80
+ * process, but its output handles end up broken, every write fails, and shell
81
+ * tasks exit 1 having produced nothing. (POSIX inherits the fd normally.)
82
+ *
83
+ * Instead of handing the child a log fd, the shell opens and redirects into the
84
+ * log itself. Output stays durable — written by the detached task directly, so
85
+ * logging continues after pi exits — and the child runs with no inherited
86
+ * stdio. Raw argv specs get a bash trampoline (`exec`) that performs the same
87
+ * redirect before replacing itself with the target program.
88
+ *
89
+ * Exposed for tests and reuse.
90
+ */
91
+ export function withWindowsLogRedirect(spec: CommandSpec, logPath: string): CommandSpec {
92
+ const redirectLine = `exec >> ${bashSingleQuote(toMsysPath(logPath))} 2>&1`;
93
+ if (spec.shell === false) {
94
+ const argvText = spec.argv!.map((arg) => bashSingleQuote(String(arg))).join(" ");
95
+ return {
96
+ ...spec,
97
+ shell: true,
98
+ // The MSYS2 runtime rewrites POSIX-looking argv (e.g. `/c`, `/opt/x.sh`)
99
+ // when exec'ing native Windows binaries. Node spawn passed argv verbatim,
100
+ // so conversion is disabled to keep raw-argv semantics unchanged. The
101
+ // redirect target is unaffected: bash resolves it itself, already in
102
+ // `/c/...` form.
103
+ command: `${redirectLine}\nexport MSYS2_ARG_CONV_EXCL='*'\nexec ${argvText}`,
104
+ };
105
+ }
106
+ return { ...spec, command: `${redirectLine}\n${spec.command}` };
107
+ }
108
+
29
109
  export function spawnCommand(spec: CommandSpec, logPath: string, detached: boolean): SpawnedProcess {
30
110
  validateCommandSpec(spec);
31
111
  mkdirSync(dirname(logPath), { recursive: true });
32
- const fd = openSync(logPath, "a");
33
- const child = spawnArgs(spec, detached, ["ignore", fd, fd]);
34
- writeSync(fd, `\n--- spawn ${new Date().toISOString()} pid=${child.pid ?? "unknown"} ---\n`);
35
- closeSync(fd);
112
+ const windows = process.platform === "win32";
113
+ const launchSpec = windows ? withWindowsLogRedirect(spec, logPath) : spec;
114
+ let fd: number | undefined;
115
+ let stdio: SpawnStdio;
116
+ if (windows) {
117
+ stdio = ["ignore", "ignore", "ignore"];
118
+ } else {
119
+ fd = openSync(logPath, "a");
120
+ stdio = ["ignore", fd, fd];
121
+ }
122
+ const child = spawnArgs(launchSpec, detached, stdio);
123
+ const marker = `\n--- spawn ${new Date().toISOString()} pid=${child.pid ?? "unknown"} ---\n`;
124
+ try {
125
+ if (fd !== undefined) {
126
+ writeSync(fd, marker);
127
+ } else {
128
+ // The detached child is already running; a throw here would orphan it
129
+ // with no task metadata, so the marker write is best effort.
130
+ appendFileSync(logPath, marker);
131
+ }
132
+ } catch {
133
+ // Log unavailable; the runtime close handler still records the failure.
134
+ } finally {
135
+ if (fd !== undefined) {
136
+ try { closeSync(fd); } catch { /* best effort */ }
137
+ }
138
+ }
139
+ // Spawn failures (ENOENT, bad cwd, permission denied) surface as an 'error'
140
+ // event. With no listener Node turns it into an uncaughtException that takes
141
+ // down the whole host process; log it here and let the runtime's 'close'
142
+ // handler finalize the task as failed.
143
+ child.on("error", (error) => {
144
+ try {
145
+ const code = (error as NodeJS.ErrnoException).code ?? "unknown";
146
+ appendFileSync(logPath, `\n--- spawn error ${new Date().toISOString()} code=${code} message=${error.message} ---\n`);
147
+ } catch {
148
+ // Log unavailable; the runtime close handler still records the failure.
149
+ }
150
+ });
36
151
  child.on("close", (code, signal) => {
37
- appendFileSync(logPath, `\n--- exit ${new Date().toISOString()} code=${code ?? "null"} signal=${signal ?? "null"} ---\n`);
152
+ try {
153
+ appendFileSync(logPath, `\n--- exit ${new Date().toISOString()} code=${code ?? "null"} signal=${signal ?? "null"} ---\n`);
154
+ } catch {
155
+ // Log unavailable (swept tmp dir, ACL change): a throw here would crash
156
+ // the host; the runtime already finalized the task from meta.
157
+ }
38
158
  });
39
159
  return { child, pgid: detached && child.pid ? child.pid : undefined };
40
160
  }
@@ -80,18 +200,32 @@ export function runCommandOnce(
80
200
  if (child.pid === undefined) return;
81
201
  try {
82
202
  stopProcessGroup(child.pid, undefined, signal);
83
- } catch {
203
+ } catch (error) {
204
+ if (process.platform === "win32") throw error;
84
205
  // Nothing left in the group: the tree is already gone.
85
206
  }
86
207
  };
87
208
  let escalation: NodeJS.Timeout | undefined;
88
209
  const timeout = timeoutMs === undefined ? undefined : setTimeout(() => {
89
210
  timedOut = true;
90
- signalGroup("SIGTERM");
211
+ try {
212
+ signalGroup("SIGTERM");
213
+ } catch (error) {
214
+ settle();
215
+ reject(error);
216
+ return;
217
+ }
91
218
  // SIGTERM is a request. Whatever still holds the output pipes after the
92
219
  // grace period is exactly what would keep this promise pending, so the
93
220
  // group is killed outright rather than waited on.
94
- escalation = setTimeout(() => signalGroup("SIGKILL"), TERMINATION_GRACE_MS);
221
+ escalation = setTimeout(() => {
222
+ try {
223
+ signalGroup("SIGKILL");
224
+ } catch (error) {
225
+ settle();
226
+ reject(error);
227
+ }
228
+ }, TERMINATION_GRACE_MS);
95
229
  escalation.unref();
96
230
  }, Math.max(1, timeoutMs));
97
231
  timeout?.unref();
@@ -118,11 +252,41 @@ export function runCommandOnce(
118
252
  });
119
253
  }
120
254
 
255
+ /**
256
+ * Stop a task's process tree.
257
+ *
258
+ * POSIX signals the process group (`-target`), falling back to the direct pid
259
+ * when the group is already gone. Windows has no process groups in libuv, and
260
+ * `process.kill(pid)` would only terminate the spawned `bash.exe` while real
261
+ * work survives in grandchildren — so the tree is terminated with
262
+ * `taskkill /T /F` instead. Windows has no graceful signal delivery, so the
263
+ * requested signal is informational there.
264
+ */
121
265
  export function stopProcessGroup(
122
266
  pid: number,
123
267
  pgid?: number,
124
268
  signal: NodeJS.Signals = "SIGTERM",
125
269
  ): void {
270
+ if (process.platform === "win32") {
271
+ const result = spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], {
272
+ encoding: "utf8",
273
+ windowsHide: true,
274
+ });
275
+ if (result.error) {
276
+ const code = (result.error as NodeJS.ErrnoException).code;
277
+ throw new Error(`taskkill could not start for PID ${pid}${code ? ` (${code})` : ""}: ${result.error.message}`, {
278
+ cause: result.error,
279
+ });
280
+ }
281
+ if (result.status === 0) return;
282
+ // A child can exit between the caller's liveness check and taskkill. That
283
+ // race is success; any still-live PID means the tree was not terminated.
284
+ if (!processExists(pid)) return;
285
+ const detail = String(result.stderr ?? result.stdout ?? "").replace(/\s+/g, " ").trim();
286
+ throw new Error(
287
+ `taskkill failed with exit ${result.status ?? "unknown"} for PID ${pid}${detail ? `: ${detail}` : ""}`,
288
+ );
289
+ }
126
290
  const target = pgid ?? pid;
127
291
  try {
128
292
  process.kill(-target, signal);
@@ -153,13 +317,16 @@ export function commandExecution(spec: CommandSpec): { execPath: string; execArg
153
317
  const [command, ...args] = spec.argv!;
154
318
  return { execPath: command!, execArgs: args };
155
319
  }
156
- return { execPath: DEFAULT_SHELL, execArgs: ["-lc", spec.command!] };
320
+ return { execPath: resolveDefaultShell(), execArgs: ["-lc", spec.command!] };
157
321
  }
158
322
 
323
+ /** Stdio for a spawned task: stdin is always ignored; stdout/stderr are piped (collected) or ignored (redirected into the log by the child itself). */
324
+ type SpawnStdio = ["ignore", "pipe" | "ignore" | number, "pipe" | "ignore" | number];
325
+
159
326
  function spawnArgs(
160
327
  spec: CommandSpec,
161
328
  detached: boolean,
162
- stdio: ["ignore", "pipe" | number, "pipe" | number],
329
+ stdio: SpawnStdio,
163
330
  ): ChildProcess {
164
331
  const env = { ...process.env, ...spec.env };
165
332
  const { execPath, execArgs } = commandExecution(spec);
@@ -168,5 +335,8 @@ function spawnArgs(
168
335
  env,
169
336
  detached,
170
337
  stdio,
338
+ // A detached Windows child gets its own console window unless hidden; these
339
+ // tasks write to log files and must not flash terminals.
340
+ windowsHide: process.platform === "win32",
171
341
  });
172
- }
342
+ }
@@ -490,7 +490,13 @@ export async function stopTask(
490
490
  try {
491
491
  stopProcessGroup(meta.pid, meta.pgid);
492
492
  } catch (error) {
493
+ meta.stopRequestedAt = undefined;
493
494
  meta.error = error instanceof Error ? error.message : String(error);
495
+ writeMeta(meta);
496
+ if (meta.deadlineAt && meta.deadlineAt > Date.now()) {
497
+ scheduleProcessTimeout(pi, id, meta.deadlineAt, getActiveSession);
498
+ }
499
+ return meta;
494
500
  }
495
501
  }
496
502
 
@@ -726,7 +732,14 @@ async function finalizeProcessTimeout(
726
732
  }
727
733
  } else {
728
734
  if (meta.pid) {
729
- try { stopProcessGroup(meta.pid, meta.pgid); } catch { /* best effort */ }
735
+ try {
736
+ stopProcessGroup(meta.pid, meta.pgid);
737
+ } catch (error) {
738
+ reason = `timeout; could not terminate local process tree: ${readableError(error)}`;
739
+ meta.error = reason;
740
+ writeMeta(meta);
741
+ return;
742
+ }
730
743
  }
731
744
  if (meta.remote?.session === "direct") {
732
745
  reason = "timeout; terminated local SSH client, but the remote process may still be running";
@@ -152,7 +152,12 @@ export function unregisterBackgroundWorkProvider(id: string): void {
152
152
  }
153
153
 
154
154
  export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boolean {
155
- return Boolean(ctx && ctx.mode === "tui" && ctx.hasUI === true && ctx.ui);
155
+ if (!ctx) return false;
156
+ try {
157
+ return Boolean(ctx.mode === "tui" && ctx.hasUI === true && ctx.ui);
158
+ } catch {
159
+ return false;
160
+ }
156
161
  }
157
162
 
158
163
  export function navigatorFooterHint(count: number): string | null {
@@ -191,8 +196,10 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
191
196
  try { s.dispose?.(); } catch { /* ignore */ }
192
197
  s.dispose = undefined;
193
198
  stopMainListWidget();
194
- const ui = (ctx ?? s.uiCtx)?.ui;
195
- if (ui && isNavigatorUiAvailable(ctx ?? s.uiCtx)) {
199
+ const activeCtx = ctx ?? s.uiCtx;
200
+ s.uiCtx = undefined;
201
+ if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
202
+ const ui = activeCtx.ui;
196
203
  try { applyNavigatorFooter(ui as any, 0); } catch { /* ignore */ }
197
204
  try { applyCloseConfirmFooter(ui as any, null); } catch { /* ignore */ }
198
205
  try { (ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
@@ -207,7 +214,6 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
207
214
  s.detailOverlayRows = undefined;
208
215
  s.mainListSelectedId = undefined;
209
216
  s.mainListFocused = false;
210
- if (ctx && s.uiCtx === ctx) s.uiCtx = undefined;
211
217
  }
212
218
 
213
219
  export function refreshBackgroundWorkNavigator(ctx?: ExtensionContext): void {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -152,7 +152,12 @@ export function unregisterBackgroundWorkProvider(id: string): void {
152
152
  }
153
153
 
154
154
  export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boolean {
155
- return Boolean(ctx && ctx.mode === "tui" && ctx.hasUI === true && ctx.ui);
155
+ if (!ctx) return false;
156
+ try {
157
+ return Boolean(ctx.mode === "tui" && ctx.hasUI === true && ctx.ui);
158
+ } catch {
159
+ return false;
160
+ }
156
161
  }
157
162
 
158
163
  export function navigatorFooterHint(count: number): string | null {
@@ -191,8 +196,10 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
191
196
  try { s.dispose?.(); } catch { /* ignore */ }
192
197
  s.dispose = undefined;
193
198
  stopMainListWidget();
194
- const ui = (ctx ?? s.uiCtx)?.ui;
195
- if (ui && isNavigatorUiAvailable(ctx ?? s.uiCtx)) {
199
+ const activeCtx = ctx ?? s.uiCtx;
200
+ s.uiCtx = undefined;
201
+ if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
202
+ const ui = activeCtx.ui;
196
203
  try { applyNavigatorFooter(ui as any, 0); } catch { /* ignore */ }
197
204
  try { applyCloseConfirmFooter(ui as any, null); } catch { /* ignore */ }
198
205
  try { (ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
@@ -207,7 +214,6 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
207
214
  s.detailOverlayRows = undefined;
208
215
  s.mainListSelectedId = undefined;
209
216
  s.mainListFocused = false;
210
- if (ctx && s.uiCtx === ctx) s.uiCtx = undefined;
211
217
  }
212
218
 
213
219
  export function refreshBackgroundWorkNavigator(ctx?: ExtensionContext): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pi extension bundle for an opt-in foreground write sandbox, subagents, durable background tasks, and goal tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,10 +45,10 @@
45
45
  "test": "node --test test/*.test.mjs"
46
46
  },
47
47
  "dependencies": {
48
- "pi-better-background-tasks": "0.2.7",
48
+ "pi-better-background-tasks": "0.2.8",
49
49
  "pi-better-goal": "0.1.22",
50
50
  "pi-better-sandbox": "0.2.0",
51
- "pi-better-subagents": "0.1.22"
51
+ "pi-better-subagents": "0.1.23"
52
52
  },
53
53
  "bundledDependencies": [
54
54
  "pi-better-background-tasks",