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.
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/process.ts +185 -15
- package/node_modules/pi-better-background-tasks/src/runtime.ts +14 -1
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +10 -4
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-navigator.ts +10 -4
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
|
195
|
-
|
|
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 {
|
|
@@ -152,7 +152,12 @@ export function unregisterBackgroundWorkProvider(id: string): void {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boolean {
|
|
155
|
-
|
|
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
|
|
195
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
51
|
+
"pi-better-subagents": "0.1.23"
|
|
52
52
|
},
|
|
53
53
|
"bundledDependencies": [
|
|
54
54
|
"pi-better-background-tasks",
|