pi-better-background-tasks 0.2.6 → 0.2.8

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 CHANGED
@@ -27,8 +27,10 @@ the effective foreground policy at launch and runs under the platform's write
27
27
  sandbox: reads and network stay unrestricted, writes are confined to the
28
28
  canonical project directory, and denied paths stay denied.
29
29
 
30
- The policy is captured once, when the task starts. A later `/sandbox on`,
31
- `/sandbox off`, or deny-rule change reaches tasks launched after it; a task
30
+ The policy is captured once, when the task starts. The foreground sandbox is
31
+ inactive by default, so local tasks ordinarily launch unconfined. A later
32
+ `/sandbox on`, `/sandbox off`, `/sandbox default on|off`, or a deny-rule change
33
+ reaches tasks launched after it; a task
32
34
  already running — including a watcher resumed in a later Pi session — keeps the
33
35
  policy it started with.
34
36
 
@@ -45,8 +47,8 @@ process, `pi.exec` calls, and unrelated third-party extension code stay outside
45
47
  the guarantee, and confinement is per surface: a confined process on another
46
48
  first-party surface can still write this one's task registry. Installing
47
49
  [`pi-better-harness`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-harness#readme)
48
- brings the sandbox in by default, so an ordinary `pi` session confines its
49
- foreground tools and its local background tasks under one project policy.
50
+ installs the sandbox extension, but leaves foreground tools and local background
51
+ tasks inactive until a human opts in.
50
52
 
51
53
  ## Remote SSH
52
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.2.6",
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",
package/src/process.ts CHANGED
@@ -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
+ }
package/src/runtime.ts CHANGED
@@ -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";
package/src/sandbox.ts CHANGED
@@ -12,10 +12,10 @@
12
12
  * argv are resolved once, when the task starts, and are what the task keeps
13
13
  * running. A later `/sandbox off` or deny-rule change therefore reaches only
14
14
  * tasks launched after it.
15
- * 2. **Fail closed.** Once the foreground state says a sandbox should apply, a
16
- * missing or unusable backend blocks the launch. The local command is never
17
- * retried unconfined behind the operator's back. The single exception is an
18
- * explicitly `disabled` state, which is a human's deliberate decision.
15
+ * 2. **Opt-in, then fail closed.** `inactive` and explicitly `disabled` states
16
+ * launch unconfined. Once foreground policy says confinement applies, a
17
+ * missing or unusable backend blocks the launch and is never retried
18
+ * unconfined behind the operator's back.
19
19
  *
20
20
  * The contract is duplicated here rather than imported: `pi-better-sandbox` is
21
21
  * an optional peer that this package must keep working without. Two channel
@@ -53,12 +53,13 @@ export const FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL = "pi-better-sandbox:poli
53
53
  /**
54
54
  * What the foreground sandbox is doing right now.
55
55
  *
56
+ * - `inactive` - default-off foreground policy; launch tasks as before.
56
57
  * - `enabled` - confine locally launched tasks.
57
58
  * - `disabled` - a human switched protection off; launch tasks as before.
58
59
  * - `unavailable` - no backend on this platform; block protected launches.
59
60
  * - `failed` - protection cannot be applied here; block protected launches.
60
61
  */
61
- export type ForegroundSandboxState = "enabled" | "disabled" | "unavailable" | "failed";
62
+ export type ForegroundSandboxState = "inactive" | "enabled" | "disabled" | "unavailable" | "failed";
62
63
 
63
64
  /** The published snapshot, narrowed to the fields a task launch needs. */
64
65
  export interface ForegroundSandboxPolicy {
@@ -101,7 +102,7 @@ export type ForegroundSandboxPlan =
101
102
 
102
103
  const UNCONFINED: ForegroundSandboxPlan = { confined: false };
103
104
 
104
- const VALID_STATES = new Set<string>(["enabled", "disabled", "unavailable", "failed"]);
105
+ const VALID_STATES = new Set<string>(["inactive", "enabled", "disabled", "unavailable", "failed"]);
105
106
 
106
107
  /**
107
108
  * The latest snapshot per event bus.
@@ -183,8 +184,8 @@ export function currentForegroundSandboxPolicy(pi: unknown): ForegroundSandboxPo
183
184
  * Decide how a local launch must be confined, before the task has an id, a
184
185
  * directory, or a log.
185
186
  *
186
- * Throws for every state that is neither confinable nor a human's explicit
187
- * opt-out, which is what keeps a blocked launch from leaving task state behind.
187
+ * Throws for every state that is neither confinable nor intentionally
188
+ * unconfined, which keeps a blocked launch from leaving task state behind.
188
189
  */
189
190
  export function resolveForegroundSandboxPlan(pi: unknown): ForegroundSandboxPlan {
190
191
  return planFor(currentForegroundSandboxPolicy(pi));
@@ -195,7 +196,7 @@ export function planFor(policy: ForegroundSandboxPolicy | undefined): Foreground
195
196
  // No sandbox extension is publishing: this package is installed on its own and
196
197
  // keeps its historical unsandboxed behaviour.
197
198
  if (!policy) return UNCONFINED;
198
- if (policy.state === "disabled") return UNCONFINED;
199
+ if (policy.state === "inactive" || policy.state === "disabled") return UNCONFINED;
199
200
  if (policy.state !== "enabled" || !policy.writableRoot) {
200
201
  throw new ForegroundSandboxBlockedError(policy);
201
202
  }
@@ -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 {