@code-yeongyu/senpi-codemode 2026.9.13 → 2026.9.15-2

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/CHANGELOG.md CHANGED
@@ -12,6 +12,51 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.9.15-2] - 2026-09-15
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ - Detached eval result cards no longer arm the 1 Hz repaint ticker (they render static with frozen elapsed time), and a live ticker whose row stops rendering now stops itself after 60 idle ticks and rearms on the next render, so transcript rebuilds and session switches cannot accumulate intervals on idle sessions ([#1696](https://github.com/code-yeongyu/senpi/issues/1696)).
26
+ - Bounded three unbounded retentions that grew long-lived session heaps without limit: settled eval cells now leave the live registry into a 32-entry terminal snapshot LRU, the JS kernel's unconsumed tool-call queue is capped at 256 and cleared on interrupt/reset/close/crash (mirroring the subprocess kernel), and per-cell display buffers cap at 8 images / 24 MB / 64 JSON outputs with elision notes ([#1695](https://github.com/code-yeongyu/senpi/issues/1695)).
27
+
28
+ ### Removed
29
+
30
+ ## [2026.9.15] - 2026-09-15
31
+
32
+ ### Breaking Changes
33
+
34
+ ### Added
35
+
36
+ ### Changed
37
+
38
+ ### Fixed
39
+
40
+ - Eval cells no longer abandon the processes they spawn. When a JavaScript cell settles, is interrupted, or times out, its `Bun.spawn`/`node:child_process` children and their descendants are terminated (SIGTERM then SIGKILL after a grace), unless the cell asked for a detached process; a child whose worker is lost while blocked is retired by the host instead. Python kernels sweep their process group when they close gracefully, and a parent-death watchdog takes the kernel and its subprocesses down when the host dies mid-cell ([#1697](https://github.com/code-yeongyu/senpi/issues/1697)).
41
+
42
+ ### Removed
43
+
44
+ ## [2026.9.13-2] - 2026-09-13
45
+
46
+ ### Breaking Changes
47
+
48
+ ### Added
49
+
50
+ - Added `PI_SESSION_CWD` and `PI_GOAL_STORE_FILE` session environment keys for kernels and shell children, including clearing of inherited values when absent (fixes #1663).
51
+
52
+ - Interactive foreground eval cells detach on queued steering without cancelling their computation or in-flight tools. An occupied detached slot keeps the call waiting ([#1637](https://github.com/code-yeongyu/senpi/issues/1637)).
53
+
54
+ ### Changed
55
+
56
+ ### Fixed
57
+
58
+ ### Removed
59
+
15
60
  ## [2026.9.13] - 2026-09-13
16
61
 
17
62
  ### Breaking Changes
package/README.md CHANGED
@@ -57,8 +57,9 @@ schema; it is not an installation failure.
57
57
  ### Session environment
58
58
 
59
59
  Every kernel starts with the active session's `PI_*` environment — `PI_SESSION_ID`,
60
- `PI_SESSION_FILE` (when the session is persistent), `PI_PROVIDER`, `PI_MODEL`, and
61
- `PI_REASONING_LEVEL` (when set) — resolved at session start, mirroring the bash tool's
60
+ `PI_SESSION_FILE` (when the session is persistent), `PI_SESSION_CWD` (the session's
61
+ working directory), `PI_GOAL_STORE_FILE` (the authoritative goal-store path, when the
62
+ host provides it), `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` (when set) — resolved at session start, mirroring the bash tool's
62
63
  session environment contract. The values are visible to `env()`/`process.env`/`os.environ`
63
64
  inside cells and are inherited by every child process a cell spawns
64
65
  (`Bun.$`, `Bun.spawn`, `child_process`, `subprocess`, ...). Inherited `PI_*` values from
@@ -67,6 +68,11 @@ what a child spawned from the bash tool sees. The values snapshot at kernel star
67
68
  mid-session model switch updates the bash tool's next command but not already-running
68
69
  kernels; a new session starts fresh kernels with fresh values.
69
70
 
71
+ `PI_GOAL_STORE_FILE` is supplied by the host's optional `ExtensionContext.goalStoreFile`
72
+ getter and may name a file that does not exist yet. It honors session-directory overrides
73
+ and in-memory sessions; it cannot be derived reliably from `PI_SESSION_FILE`. If the host
74
+ omits the getter, the variable is unset rather than inherited from the launching process.
75
+
70
76
  ## Settings
71
77
 
72
78
  Configuration is loaded in this order:
@@ -177,6 +183,12 @@ cell keeps only its own language kernel busy. A new same-language call returns
177
183
  a busy error with its cell id and output tail; calls in other languages continue
178
184
  normally. Do not re-run the cell.
179
185
 
186
+ Queued steering also detaches an eligible interactive foreground call, including
187
+ one paused in a host tool bridge, without cancelling its computation. If the
188
+ language's detached slot is occupied, steering leaves the call waiting. Follow-up
189
+ messages, explicit `on_timeout: "error"`, and print/JSON calls do not trigger this
190
+ transition; caller abort and existing deadlines retain their cancellation behavior.
191
+
180
192
  Every cell, detached or not, is bounded by two kill deadlines. The run budget
181
193
  (`runBudgetSeconds`, or the call's `timeout`) charges only the cell's own
182
194
  execution time and is paused while a host tool call is in flight, so a cell
@@ -185,8 +197,8 @@ The hard limit (`hardLimitSeconds`, raised by a larger `timeout`) is wall-clock
185
197
  and bounds parked cells too. A cell killed by either deadline reports which one
186
198
  in its result or completion notification, together with whether kernel state
187
199
  survived; the tool schema states the configured numbers. The `timeout` value
188
- never changes when an interactive call detaches: that is `cellTimeoutSeconds`
189
- capped by `foregroundWindowSeconds`.
200
+ never changes the idle detach deadline: that is `cellTimeoutSeconds` capped by
201
+ `foregroundWindowSeconds`; queued steering can detach the call earlier.
190
202
 
191
203
  While any cell is detached, the interactive footer shows a highlighted
192
204
  `↗ <language> · <summary>` status on the extension status line (the cell id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.9.13",
3
+ "version": "2026.9.15-2",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -30,14 +30,14 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@babel/parser": "8.0.4",
33
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.13",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.15-2",
34
34
  "typebox": "1.3.27"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.9.13"
37
+ "@code-yeongyu/senpi": "2026.9.15-2"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.9.13"
40
+ "@code-yeongyu/senpi": "2026.9.15-2"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -11,3 +11,5 @@ export const TIMEOUT_PAUSE_OP = "timeout-pause" as const;
11
11
  export const TIMEOUT_RESUME_OP = "timeout-resume" as const;
12
12
  /** Status op the JS worker emits the moment it receives `interrupt`; proves its event loop is not blocked. */
13
13
  export const INTERRUPT_ACK_OP = "interrupt-ack" as const;
14
+ /** Status op the JS worker emits when a cell child is tracked or exits, so the host can retire it if the worker is lost. */
15
+ export const CHILD_LIFECYCLE_OP = "child" as const;
@@ -1,5 +1,5 @@
1
- import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
- import { INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
1
+ import type { EvalStatusEvent, HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
3
3
  import type { KernelInterruptHandle } from "../../tool/types.ts";
4
4
  import { abandonedWorkerNote, awaitCooperativeSettlement, type WorkerRetirement } from "./interrupt-bounds.ts";
5
5
  import {
@@ -11,6 +11,7 @@ import {
11
11
  type ToolCallMessage,
12
12
  } from "./kernel-contract.ts";
13
13
  import { type JavaScriptKernelOptions, LocalModuleLoader } from "./local-module-loader.ts";
14
+ import { terminateProcessTrees } from "./process-tree-host.ts";
14
15
  import { JavaScriptRunQueue, type PendingJavaScriptRun, stoppedResult } from "./run-queue.ts";
15
16
  import { bridgeError, WorkerStartupCancelledError } from "./worker-host.ts";
16
17
  import { WorkerSlot } from "./worker-slot.ts";
@@ -19,6 +20,13 @@ export { JavaScriptKernelClosedError, type JavaScriptKernelMode, type JavaScript
19
20
  export type { JavaScriptKernelOptions } from "./local-module-loader.ts";
20
21
  export { type JavaScriptWorkerEntryUrlOptions, resolveJsWorkerEntryUrl } from "./worker-startup.ts";
21
22
 
23
+ /** How long a lost worker's children get to honour SIGTERM before the host sends SIGKILL. */
24
+ const WORKER_LOSS_CHILD_GRACE_MS = 1_000;
25
+
26
+ // Pull-API fallback queue bound: a nextToolCall consumer this far behind is already stalled, and the
27
+ // normal push path (onMessage) never reads the queue, so unbounded growth only pins tool args (#1695).
28
+ const MAX_PENDING_TOOL_CALLS = 256;
29
+
22
30
  export class JavaScriptKernel {
23
31
  readonly #options: JavaScriptKernelOptions;
24
32
  readonly #moduleLoader: LocalModuleLoader;
@@ -31,6 +39,8 @@ export class JavaScriptKernel {
31
39
  #timeout: NodeJS.Timeout | null = null;
32
40
  #toolWaiters: Array<(message: ToolCallMessage) => void> = [];
33
41
  #pendingToolCalls: ToolCallMessage[] = [];
42
+ /** Live cell children the worker reported; retired by the host when the worker itself is lost. */
43
+ readonly #childPids = new Set<number>();
34
44
 
35
45
  constructor(options: JavaScriptKernelOptions) {
36
46
  this.#options = options;
@@ -71,6 +81,7 @@ export class JavaScriptKernel {
71
81
  async reset(): Promise<void> {
72
82
  assertJavaScriptKernelOpen(this.#lifecycle, "reset");
73
83
  await this.#terminate();
84
+ this.#clearToolCalls();
74
85
  assertJavaScriptKernelOpen(this.#lifecycle, "reset");
75
86
  await this.#ensureReady();
76
87
  this.#startNext();
@@ -91,6 +102,7 @@ export class JavaScriptKernel {
91
102
  this.#slot.postMessage({ type: "close" });
92
103
  this.#lifecycle = "closing";
93
104
  this.#runs.settleAll("JS kernel closed");
105
+ this.#clearToolCalls();
94
106
  const recovery = this.#recovery;
95
107
  const closePromise = (async () => {
96
108
  if (recovery) await recovery;
@@ -164,15 +176,19 @@ export class JavaScriptKernel {
164
176
  message: string,
165
177
  durationMs = 0,
166
178
  ): Promise<{ readonly retained: boolean; readonly note?: string }> {
167
- run.interruptResult = { type: "result", cellId: run.input.cellId, ok: false, error: { message }, durationMs };
168
- run.interruptAck ??= Promise.withResolvers<void>();
169
- this.#slot.postMessage({ type: "interrupt", reason });
170
- if ((await awaitCooperativeSettlement(run)) === "settled") return { retained: run.settledByWorker };
171
- if (!this.#runs.releaseActive(run)) return { retained: run.settledByWorker };
172
- const retirement = await this.#terminate();
173
- this.#runs.settle(run, run.interruptResult ?? stoppedResult(run.input.cellId, message));
174
- void this.#recover(() => Promise.resolve());
175
- return retirement === "abandoned" ? { retained: false, note: abandonedWorkerNote() } : { retained: false };
179
+ try {
180
+ run.interruptResult = { type: "result", cellId: run.input.cellId, ok: false, error: { message }, durationMs };
181
+ run.interruptAck ??= Promise.withResolvers<void>();
182
+ this.#slot.postMessage({ type: "interrupt", reason });
183
+ if ((await awaitCooperativeSettlement(run)) === "settled") return { retained: run.settledByWorker };
184
+ if (!this.#runs.releaseActive(run)) return { retained: run.settledByWorker };
185
+ const retirement = await this.#terminate();
186
+ this.#runs.settle(run, run.interruptResult ?? stoppedResult(run.input.cellId, message));
187
+ void this.#recover(() => Promise.resolve());
188
+ return retirement === "abandoned" ? { retained: false, note: abandonedWorkerNote() } : { retained: false };
189
+ } finally {
190
+ this.#clearToolCalls();
191
+ }
176
192
  }
177
193
 
178
194
  async #restartAfterStop(): Promise<void> {
@@ -208,12 +224,19 @@ export class JavaScriptKernel {
208
224
  this.#runs.active?.interruptAck?.resolve();
209
225
  return;
210
226
  }
227
+ if (message.type === "status" && message.event.op === CHILD_LIFECYCLE_OP) {
228
+ this.#trackChildEvent(message.event);
229
+ return;
230
+ }
211
231
  this.#options.onMessage?.(message);
212
232
  this.#runs.active?.input.onMessage?.(message);
213
233
  if (message.type === "tool-call") {
214
234
  const waiter = this.#toolWaiters.shift();
215
235
  if (waiter) waiter(message);
216
- else this.#pendingToolCalls.push(message);
236
+ else {
237
+ this.#pendingToolCalls.push(message);
238
+ if (this.#pendingToolCalls.length > MAX_PENDING_TOOL_CALLS) this.#pendingToolCalls.shift();
239
+ }
217
240
  return;
218
241
  }
219
242
  if (message.type !== "result") return;
@@ -240,16 +263,42 @@ export class JavaScriptKernel {
240
263
  durationMs: this.#runs.durationMs(active, performance.now()),
241
264
  });
242
265
  }
266
+ this.#clearToolCalls();
243
267
  void this.#restartAfterStop();
244
268
  }
245
269
 
270
+ #clearToolCalls(): void {
271
+ this.#pendingToolCalls.length = 0;
272
+ this.#toolWaiters.length = 0;
273
+ }
274
+
246
275
  #clearTimeout(): void {
247
276
  if (this.#timeout) clearTimeout(this.#timeout);
248
277
  this.#timeout = null;
249
278
  }
250
279
 
280
+ #trackChildEvent(event: EvalStatusEvent): void {
281
+ const pid = event.pid;
282
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return;
283
+ if (event.state === "spawned") this.#childPids.add(pid);
284
+ else if (event.state === "exited") this.#childPids.delete(pid);
285
+ }
286
+
287
+ /**
288
+ * Retire the worker, then whatever cell children it still owned: a terminated or crashed worker never
289
+ * reaches its own cell-end cleanup, and a pid `ps` no longer lists as our child was reused and is skipped.
290
+ */
251
291
  async #terminate(): Promise<WorkerRetirement> {
252
292
  this.#clearTimeout();
253
- return await this.#slot.retire();
293
+ const retirement = await this.#slot.retire();
294
+ await this.#retireWorkerChildren();
295
+ return retirement;
296
+ }
297
+
298
+ async #retireWorkerChildren(): Promise<void> {
299
+ if (this.#childPids.size === 0) return;
300
+ const pids = [...this.#childPids];
301
+ this.#childPids.clear();
302
+ await terminateProcessTrees(pids, { graceMs: WORKER_LOSS_CHILD_GRACE_MS, ownerPid: process.pid });
254
303
  }
255
304
  }
@@ -0,0 +1,158 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ // Host-side twin of worker-runtime's `process-tree.js`. The worker file must stay plain
4
+ // JavaScript (a Node worker thread spawned from a `.js` entry has no TypeScript loader), and
5
+ // `check-ts-relative-imports` forbids a `.ts` file from importing a `.js` one, so the host
6
+ // keeps its own copy the way `worker-core.js` mirrors `reserved.ts` constants. Keep the two in
7
+ // sync when either changes.
8
+
9
+ export interface TerminateProcessTreesOptions {
10
+ readonly graceMs: number;
11
+ readonly killWaitMs?: number;
12
+ // When set, only roots `ps` still lists as direct children of this pid are signalled, so a
13
+ // reused pid belonging to another process is never killed.
14
+ readonly ownerPid?: number;
15
+ }
16
+
17
+ const PS_MAX_BUFFER = 16 * 1024 * 1024;
18
+ const EXIT_POLL_MS = 25;
19
+ const ZOMBIE_CHECK_INTERVAL_MS = 250;
20
+
21
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
22
+ return typeof value === "object" && value !== null;
23
+ }
24
+
25
+ export function isProcessAlive(pid: number): boolean {
26
+ if (!Number.isInteger(pid) || pid <= 0) return false;
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ } catch (error) {
31
+ return isRecord(error) && error.code === "EPERM";
32
+ }
33
+ }
34
+
35
+ export function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
36
+ if (!Number.isInteger(pid) || pid <= 0) return false;
37
+ try {
38
+ process.kill(pid, signal);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ // `ps -p` exits non-zero when any listed pid is gone but still prints the rest.
46
+ function ps(args: readonly string[]): Promise<string> {
47
+ return new Promise((resolve) => {
48
+ execFile("ps", [...args], { maxBuffer: PS_MAX_BUFFER, windowsHide: true }, (_error, output) =>
49
+ resolve(typeof output === "string" ? output : String(output ?? "")),
50
+ );
51
+ });
52
+ }
53
+
54
+ export async function readProcessTable(): Promise<Map<number, number[]>> {
55
+ const children = new Map<number, number[]>();
56
+ if (process.platform === "win32") return children;
57
+ const stdout = await ps(["-axo", "pid=,ppid="]);
58
+ for (const line of stdout.split("\n")) {
59
+ const [pidText, ppidText] = line.trim().split(/\s+/u);
60
+ const pid = Number(pidText);
61
+ const ppid = Number(ppidText);
62
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
63
+ const siblings = children.get(ppid);
64
+ if (siblings) siblings.push(pid);
65
+ else children.set(ppid, [pid]);
66
+ }
67
+ return children;
68
+ }
69
+
70
+ export function collectDescendants(table: ReadonlyMap<number, readonly number[]>, roots: readonly number[]): number[] {
71
+ const seen = new Set<number>(roots);
72
+ const queue = [...roots];
73
+ const descendants: number[] = [];
74
+ while (queue.length > 0) {
75
+ const parent = queue.shift();
76
+ if (parent === undefined) break;
77
+ for (const child of table.get(parent) ?? []) {
78
+ if (seen.has(child)) continue;
79
+ seen.add(child);
80
+ descendants.push(child);
81
+ queue.push(child);
82
+ }
83
+ }
84
+ return descendants;
85
+ }
86
+
87
+ export function ownedRoots(
88
+ table: ReadonlyMap<number, readonly number[]>,
89
+ parentPid: number,
90
+ roots: readonly number[],
91
+ ): number[] {
92
+ const owned = new Set(table.get(parentPid) ?? []);
93
+ return roots.filter((pid) => owned.has(pid));
94
+ }
95
+
96
+ function sleep(ms: number): Promise<void> {
97
+ return new Promise((resolve) => setTimeout(resolve, ms));
98
+ }
99
+
100
+ /**
101
+ * Retire whole process trees: snapshot the descendants first (a parent that dies before the
102
+ * snapshot would reparent them to init), SIGTERM everything, wait `graceMs` for the pids to
103
+ * disappear, then SIGKILL whatever is left. On Windows `taskkill /T /F` does the walk and the kill.
104
+ */
105
+ export async function terminateProcessTrees(
106
+ roots: readonly number[],
107
+ options: TerminateProcessTreesOptions,
108
+ ): Promise<void> {
109
+ const liveRoots = roots.filter(isProcessAlive);
110
+ if (liveRoots.length === 0) return;
111
+ if (process.platform === "win32") {
112
+ for (const pid of liveRoots) await taskkillTree(pid);
113
+ return;
114
+ }
115
+ const table = await readProcessTable();
116
+ const owned = options.ownerPid === undefined ? liveRoots : ownedRoots(table, options.ownerPid, liveRoots);
117
+ if (owned.length === 0) return;
118
+ const targets = [...owned, ...collectDescendants(table, owned)];
119
+ for (const pid of targets) signalProcess(pid, "SIGTERM");
120
+ const survivors = await waitForExit(targets, options.graceMs);
121
+ if (survivors.length === 0) return;
122
+ for (const pid of survivors) signalProcess(pid, "SIGKILL");
123
+ await waitForExit(survivors, options.killWaitMs ?? options.graceMs);
124
+ }
125
+
126
+ // `kill(pid, 0)` still succeeds for a zombie, and a child whose owning worker is blocked or
127
+ // gone is never reaped, so liveness also consults `ps` state.
128
+ async function waitForExit(pids: readonly number[], graceMs: number): Promise<number[]> {
129
+ const deadline = Date.now() + graceMs;
130
+ let remaining = await withoutZombies(pids.filter(isProcessAlive));
131
+ let nextZombieCheck = Date.now() + ZOMBIE_CHECK_INTERVAL_MS;
132
+ while (remaining.length > 0 && Date.now() < deadline) {
133
+ await sleep(EXIT_POLL_MS);
134
+ remaining = remaining.filter(isProcessAlive);
135
+ if (remaining.length > 0 && Date.now() >= nextZombieCheck) {
136
+ remaining = await withoutZombies(remaining);
137
+ nextZombieCheck = Date.now() + ZOMBIE_CHECK_INTERVAL_MS;
138
+ }
139
+ }
140
+ return remaining;
141
+ }
142
+
143
+ async function withoutZombies(pids: number[]): Promise<number[]> {
144
+ if (pids.length === 0 || process.platform === "win32") return pids;
145
+ const stdout = await ps(["-o", "pid=,stat=", "-p", pids.join(",")]);
146
+ const zombies = new Set<number>();
147
+ for (const line of stdout.split("\n")) {
148
+ const [pidText, stat] = line.trim().split(/\s+/u);
149
+ if (typeof stat === "string" && stat.startsWith("Z")) zombies.add(Number(pidText));
150
+ }
151
+ return pids.filter((pid) => !zombies.has(pid));
152
+ }
153
+
154
+ function taskkillTree(pid: number): Promise<void> {
155
+ return new Promise((resolve) => {
156
+ execFile("taskkill", ["/T", "/F", "/PID", String(pid)], { windowsHide: true }, () => resolve());
157
+ });
158
+ }
@@ -0,0 +1,24 @@
1
+ export type ProcessTable = ReadonlyMap<number, readonly number[]>;
2
+
3
+ export interface TerminateProcessTreesOptions {
4
+ /** How long the trees get to honour SIGTERM before SIGKILL. */
5
+ readonly graceMs: number;
6
+ /** How long to wait after SIGKILL; defaults to `graceMs`. */
7
+ readonly killWaitMs?: number;
8
+ /** Resolves once every owned handle has exited; shortens the wait when it settles early. */
9
+ readonly settled?: Promise<unknown>;
10
+ /** When set, only roots `ps` still lists as direct children of this pid are signalled (guards against pid reuse). */
11
+ readonly ownerPid?: number;
12
+ }
13
+
14
+ export function isProcessAlive(pid: number): boolean;
15
+
16
+ export function signalProcess(pid: number, signal: NodeJS.Signals): boolean;
17
+
18
+ export function readProcessTable(): Promise<ProcessTable>;
19
+
20
+ export function collectDescendants(table: ProcessTable, roots: readonly number[]): number[];
21
+
22
+ export function ownedRoots(table: ProcessTable, parentPid: number, roots: readonly number[]): number[];
23
+
24
+ export function terminateProcessTrees(roots: readonly number[], options: TerminateProcessTreesOptions): Promise<void>;
@@ -0,0 +1,151 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ // Plain JS (with process-tree.d.ts) because the worker runtime imports it and
4
+ // worker files cannot import TypeScript; the host imports the same module so a
5
+ // process tree is retired by one procedure wherever its owner is lost.
6
+
7
+ const PS_MAX_BUFFER = 16 * 1024 * 1024;
8
+ const EXIT_POLL_MS = 25;
9
+ const ZOMBIE_CHECK_INTERVAL_MS = 250;
10
+
11
+ export function isProcessAlive(pid) {
12
+ if (!Number.isInteger(pid) || pid <= 0) return false;
13
+ try {
14
+ process.kill(pid, 0);
15
+ return true;
16
+ } catch (error) {
17
+ return isRecord(error) && error.code === "EPERM";
18
+ }
19
+ }
20
+
21
+ export function signalProcess(pid, signal) {
22
+ if (!Number.isInteger(pid) || pid <= 0) return false;
23
+ try {
24
+ process.kill(pid, signal);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Parent-to-children map of every process visible to `ps`. Windows has no
33
+ * `ps`; callers there kill through `taskkill /T`, which walks the tree itself.
34
+ */
35
+ export async function readProcessTable() {
36
+ if (process.platform === "win32") return new Map();
37
+ const stdout = await ps(["-axo", "pid=,ppid="]);
38
+ const children = new Map();
39
+ for (const line of stdout.split("\n")) {
40
+ const [pidText, ppidText] = line.trim().split(/\s+/u);
41
+ const pid = Number(pidText);
42
+ const ppid = Number(ppidText);
43
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
44
+ const siblings = children.get(ppid);
45
+ if (siblings) siblings.push(pid);
46
+ else children.set(ppid, [pid]);
47
+ }
48
+ return children;
49
+ }
50
+
51
+ export function collectDescendants(table, roots) {
52
+ const seen = new Set(roots);
53
+ const queue = [...roots];
54
+ const descendants = [];
55
+ while (queue.length > 0) {
56
+ const parent = queue.shift();
57
+ for (const child of table.get(parent) ?? []) {
58
+ if (seen.has(child)) continue;
59
+ seen.add(child);
60
+ descendants.push(child);
61
+ queue.push(child);
62
+ }
63
+ }
64
+ return descendants;
65
+ }
66
+
67
+ /** Roots that `ps` still reports as direct children of `parentPid`; a reused pid belongs to someone else. */
68
+ export function ownedRoots(table, parentPid, roots) {
69
+ const owned = new Set(table.get(parentPid) ?? []);
70
+ return roots.filter((pid) => owned.has(pid));
71
+ }
72
+
73
+ function sleep(ms) {
74
+ return new Promise((resolve) => setTimeout(resolve, ms));
75
+ }
76
+
77
+ /**
78
+ * Retire whole process trees: snapshot the descendants first (a parent that
79
+ * dies before the snapshot would reparent them to init), SIGTERM everything,
80
+ * wait `graceMs` for `settled` (or for the pids to disappear), then SIGKILL
81
+ * whatever is left. On Windows `taskkill /T /F` does the walk and the kill.
82
+ */
83
+ export async function terminateProcessTrees(roots, options) {
84
+ const liveRoots = roots.filter(isProcessAlive);
85
+ if (liveRoots.length === 0) return;
86
+ if (process.platform === "win32") {
87
+ for (const pid of liveRoots) await taskkillTree(pid);
88
+ return;
89
+ }
90
+ const table = await readProcessTable();
91
+ const owned = options.ownerPid === undefined ? liveRoots : ownedRoots(table, options.ownerPid, liveRoots);
92
+ if (owned.length === 0) return;
93
+ const targets = [...owned, ...collectDescendants(table, owned)];
94
+ for (const pid of targets) signalProcess(pid, "SIGTERM");
95
+ const survivors = await waitForExit(targets, options.graceMs, options.settled);
96
+ if (survivors.length === 0) return;
97
+ for (const pid of survivors) signalProcess(pid, "SIGKILL");
98
+ await waitForExit(survivors, options.killWaitMs ?? options.graceMs, options.settled);
99
+ }
100
+
101
+ // `kill(pid, 0)` still succeeds for a zombie, and a child whose owning worker is
102
+ // blocked or gone is never reaped, so liveness also consults `ps` state.
103
+ async function waitForExit(pids, graceMs, settled) {
104
+ const deadline = Date.now() + graceMs;
105
+ let remaining = await withoutZombies(pids.filter(isProcessAlive));
106
+ let nextZombieCheck = Date.now() + ZOMBIE_CHECK_INTERVAL_MS;
107
+ while (remaining.length > 0 && Date.now() < deadline) {
108
+ if (settled) {
109
+ const outcome = await Promise.race([settled.then(() => "settled"), sleep(EXIT_POLL_MS).then(() => "tick")]);
110
+ if (outcome === "settled") settled = undefined;
111
+ } else {
112
+ await sleep(EXIT_POLL_MS);
113
+ }
114
+ remaining = remaining.filter(isProcessAlive);
115
+ if (remaining.length > 0 && Date.now() >= nextZombieCheck) {
116
+ remaining = await withoutZombies(remaining);
117
+ nextZombieCheck = Date.now() + ZOMBIE_CHECK_INTERVAL_MS;
118
+ }
119
+ }
120
+ return remaining;
121
+ }
122
+
123
+ async function withoutZombies(pids) {
124
+ if (pids.length === 0 || process.platform === "win32") return pids;
125
+ const stdout = await ps(["-o", "pid=,stat=", "-p", pids.join(",")]);
126
+ const zombies = new Set();
127
+ for (const line of stdout.split("\n")) {
128
+ const [pidText, stat] = line.trim().split(/\s+/u);
129
+ if (typeof stat === "string" && stat.startsWith("Z")) zombies.add(Number(pidText));
130
+ }
131
+ return pids.filter((pid) => !zombies.has(pid));
132
+ }
133
+
134
+ // `ps -p` exits non-zero when any listed pid is gone but still prints the rest.
135
+ function ps(args) {
136
+ return new Promise((resolve) => {
137
+ execFile("ps", args, { maxBuffer: PS_MAX_BUFFER, windowsHide: true }, (_error, output) =>
138
+ resolve(typeof output === "string" ? output : String(output ?? "")),
139
+ );
140
+ });
141
+ }
142
+
143
+ function taskkillTree(pid) {
144
+ return new Promise((resolve) => {
145
+ execFile("taskkill", ["/T", "/F", "/PID", String(pid)], { windowsHide: true }, () => resolve());
146
+ });
147
+ }
148
+
149
+ function isRecord(value) {
150
+ return typeof value === "object" && value !== null;
151
+ }
@@ -1,7 +1,8 @@
1
1
  import { JsWorkerRuntime } from "./worker-runtime.js";
2
2
 
3
- // Mirrors INTERRUPT_ACK_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
3
+ // Mirrors INTERRUPT_ACK_OP and CHILD_LIFECYCLE_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
4
4
  const INTERRUPT_ACK_OP = "interrupt-ack";
5
+ const CHILD_LIFECYCLE_OP = "child";
5
6
 
6
7
  // Mirrors SESSION_ENVIRONMENT_KEYS in src/kernels/session-env.ts (this worker file
7
8
  // cannot import TypeScript). Keys the active session does not set must be dropped so a
@@ -9,6 +10,8 @@ const INTERRUPT_ACK_OP = "interrupt-ack";
9
10
  const SESSION_ENVIRONMENT_KEYS = [
10
11
  "PI_SESSION_ID",
11
12
  "PI_SESSION_FILE",
13
+ "PI_SESSION_CWD",
14
+ "PI_GOAL_STORE_FILE",
12
15
  "PI_PROVIDER",
13
16
  "PI_MODEL",
14
17
  "PI_REASONING_LEVEL",
@@ -71,6 +74,7 @@ export function createWorkerCore(transport, options) {
71
74
  parallelPoolWidth: options.parallelPoolWidth,
72
75
  localRoots: message.connection.localRoots,
73
76
  artifactsDir: message.connection.artifactsDir,
77
+ onChildEvent: (event) => emit({ type: "status", event: { op: CHILD_LIFECYCLE_OP, ...event } }),
74
78
  });
75
79
  emit({ type: "ready" });
76
80
  return;