@code-yeongyu/senpi-codemode 2026.9.13-2 → 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,35 @@
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
+
15
44
  ## [2026.9.13-2] - 2026-09-13
16
45
 
17
46
  ### Breaking Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.9.13-2",
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-2",
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-2"
37
+ "@code-yeongyu/senpi": "2026.9.15-2"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.9.13-2"
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
@@ -73,6 +74,7 @@ export function createWorkerCore(transport, options) {
73
74
  parallelPoolWidth: options.parallelPoolWidth,
74
75
  localRoots: message.connection.localRoots,
75
76
  artifactsDir: message.connection.artifactsDir,
77
+ onChildEvent: (event) => emit({ type: "status", event: { op: CHILD_LIFECYCLE_OP, ...event } }),
76
78
  });
77
79
  emit({ type: "ready" });
78
80
  return;
@@ -3,10 +3,14 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
4
4
  import { inspect } from "node:util";
5
5
  import { encodeDisplayImage, resolveDisplayOps } from "./display-image.js";
6
+ import { terminateProcessTrees } from "./process-tree.js";
6
7
  import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect-eval.js";
7
8
  import { installShellCapture } from "./worker-shell-capture.js";
8
9
 
9
10
  const PREPARED_CELL_PREFIX = "/*senpi:prepared-cell*/";
11
+ // How long a child gets to honour SIGTERM before SIGKILL. Short, because the
12
+ // cell has already produced its value and the caller is waiting on settle.
13
+ const CHILD_TERMINATION_GRACE_MS = 1_000;
10
14
  const INTERNAL_URL = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/iu;
11
15
 
12
16
  export class JsWorkerRuntime {
@@ -17,10 +21,12 @@ export class JsWorkerRuntime {
17
21
  #hooks = null;
18
22
  #pendingDisplays = [];
19
23
  #children = new Set();
24
+ #onChildEvent;
20
25
 
21
26
  constructor(options) {
22
27
  this.#cwd = options.cwd;
23
28
  this.#parallelPoolWidth = options.parallelPoolWidth;
29
+ this.#onChildEvent = typeof options.onChildEvent === "function" ? options.onChildEvent : null;
24
30
  this.#localRoots = { ...(options.localRoots ?? {}) };
25
31
  if (options.artifactsDir && !this.#localRoots.local) this.#localRoots.local = join(options.artifactsDir, "local");
26
32
  this.#installGlobals();
@@ -42,24 +48,46 @@ export class JsWorkerRuntime {
42
48
  return value;
43
49
  } finally {
44
50
  this.#pendingDisplays = [];
45
- this.#children.clear();
51
+ // A child still running here has lost its only owner: the cell that
52
+ // spawned it is over, nothing will await it again, and it would be
53
+ // reparented to init. Retire it the way timeout and abort cleanup
54
+ // already do, unless the cell asked for a detached process.
55
+ await this.#terminateChildren();
46
56
  this.#hooks = null;
47
57
  }
48
58
  }
49
59
 
50
60
  interrupt() {
51
- for (const child of this.#children) {
52
- if (child.exitCode === null && child.signalCode === null) child.kill();
53
- }
61
+ // The tree snapshot, SIGTERM, and SIGKILL escalation run on their own so
62
+ // the caller's interrupt latency stays that of the acknowledgement.
63
+ void this.#terminateChildren();
54
64
  }
55
65
 
56
- #trackChild(child) {
66
+ #trackChild(child, spawnOptions) {
57
67
  if (child === null || typeof child !== "object" || typeof child.kill !== "function") return;
68
+ // `detached: true` is the cell saying it wants the process to outlive it.
69
+ if (isPlainObject(spawnOptions) && spawnOptions.detached === true) return;
58
70
  this.#children.add(child);
59
- const forget = () => this.#children.delete(child);
71
+ const pid = Number.isInteger(child.pid) && child.pid > 0 ? child.pid : null;
72
+ // The host keeps its own copy of live pids: if this worker is terminated
73
+ // while blocked, only the host can still retire them.
74
+ if (pid !== null) this.#onChildEvent?.({ pid, state: "spawned" });
75
+ const forget = () => {
76
+ this.#children.delete(child);
77
+ if (pid !== null) this.#onChildEvent?.({ pid, state: "exited" });
78
+ };
60
79
  if (child.exited instanceof Promise) child.exited.then(forget, forget);
61
80
  }
62
81
 
82
+ #terminateChildren() {
83
+ const children = [...this.#children].filter(child => child.exitCode === null && child.signalCode === null);
84
+ this.#children.clear();
85
+ if (children.length === 0) return undefined;
86
+ const roots = children.map(child => child.pid).filter(pid => Number.isInteger(pid) && pid > 0);
87
+ const settled = Promise.allSettled(children.map(child => (child.exited instanceof Promise ? child.exited : Promise.resolve())));
88
+ return terminateProcessTrees(roots, { graceMs: CHILD_TERMINATION_GRACE_MS, settled });
89
+ }
90
+
63
91
  async #drainPendingDisplays() {
64
92
  while (this.#pendingDisplays.length > 0) {
65
93
  const pending = this.#pendingDisplays;
@@ -114,7 +142,7 @@ export class JsWorkerRuntime {
114
142
  const restoreShellCapture = installShellCapture({
115
143
  isActive: () => this.#hooks !== null,
116
144
  emitText: (stream, data) => this.#emitText(stream, data),
117
- onChild: (child) => this.#trackChild(child),
145
+ onChild: (child, spawnOptions) => this.#trackChild(child, spawnOptions),
118
146
  });
119
147
  globalThis.__senpi_restore_console__ = () => {
120
148
  console.log = originalLog;
@@ -3,6 +3,7 @@ export type ShellCaptureStream = "stdout" | "stderr";
3
3
  export type ShellCaptureRestore = () => void;
4
4
 
5
5
  export interface ShellCaptureChild {
6
+ readonly pid?: number;
6
7
  readonly exitCode: number | null;
7
8
  readonly signalCode: string | null;
8
9
  readonly exited: Promise<number>;
@@ -13,7 +14,7 @@ export interface ShellCaptureOptions {
13
14
  readonly isActive: () => boolean;
14
15
  readonly emitText: (stream: ShellCaptureStream, data: string) => void;
15
16
  /** Receives every `Bun.spawn` child created while a cell is active so the runtime can kill it on interrupt. */
16
- readonly onChild?: (child: ShellCaptureChild) => void;
17
+ readonly onChild?: (child: ShellCaptureChild, spawnOptions?: unknown) => void;
17
18
  }
18
19
 
19
20
  export function installShellCapture(options: ShellCaptureOptions): ShellCaptureRestore;