@code-yeongyu/senpi-codemode 2026.9.13 → 2026.9.15

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,36 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.9.15] - 2026-09-15
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ - 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)).
26
+
27
+ ### Removed
28
+
29
+ ## [2026.9.13-2] - 2026-09-13
30
+
31
+ ### Breaking Changes
32
+
33
+ ### Added
34
+
35
+ - 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).
36
+
37
+ - 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)).
38
+
39
+ ### Changed
40
+
41
+ ### Fixed
42
+
43
+ ### Removed
44
+
15
45
  ## [2026.9.13] - 2026-09-13
16
46
 
17
47
  ### 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",
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",
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"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.9.13"
40
+ "@code-yeongyu/senpi": "2026.9.15"
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,9 @@ 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
+
22
26
  export class JavaScriptKernel {
23
27
  readonly #options: JavaScriptKernelOptions;
24
28
  readonly #moduleLoader: LocalModuleLoader;
@@ -31,6 +35,8 @@ export class JavaScriptKernel {
31
35
  #timeout: NodeJS.Timeout | null = null;
32
36
  #toolWaiters: Array<(message: ToolCallMessage) => void> = [];
33
37
  #pendingToolCalls: ToolCallMessage[] = [];
38
+ /** Live cell children the worker reported; retired by the host when the worker itself is lost. */
39
+ readonly #childPids = new Set<number>();
34
40
 
35
41
  constructor(options: JavaScriptKernelOptions) {
36
42
  this.#options = options;
@@ -208,6 +214,10 @@ export class JavaScriptKernel {
208
214
  this.#runs.active?.interruptAck?.resolve();
209
215
  return;
210
216
  }
217
+ if (message.type === "status" && message.event.op === CHILD_LIFECYCLE_OP) {
218
+ this.#trackChildEvent(message.event);
219
+ return;
220
+ }
211
221
  this.#options.onMessage?.(message);
212
222
  this.#runs.active?.input.onMessage?.(message);
213
223
  if (message.type === "tool-call") {
@@ -248,8 +258,28 @@ export class JavaScriptKernel {
248
258
  this.#timeout = null;
249
259
  }
250
260
 
261
+ #trackChildEvent(event: EvalStatusEvent): void {
262
+ const pid = event.pid;
263
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return;
264
+ if (event.state === "spawned") this.#childPids.add(pid);
265
+ else if (event.state === "exited") this.#childPids.delete(pid);
266
+ }
267
+
268
+ /**
269
+ * Retire the worker, then whatever cell children it still owned: a terminated or crashed worker never
270
+ * reaches its own cell-end cleanup, and a pid `ps` no longer lists as our child was reused and is skipped.
271
+ */
251
272
  async #terminate(): Promise<WorkerRetirement> {
252
273
  this.#clearTimeout();
253
- return await this.#slot.retire();
274
+ const retirement = await this.#slot.retire();
275
+ await this.#retireWorkerChildren();
276
+ return retirement;
277
+ }
278
+
279
+ async #retireWorkerChildren(): Promise<void> {
280
+ if (this.#childPids.size === 0) return;
281
+ const pids = [...this.#childPids];
282
+ this.#childPids.clear();
283
+ await terminateProcessTrees(pids, { graceMs: WORKER_LOSS_CHILD_GRACE_MS, ownerPid: process.pid });
254
284
  }
255
285
  }
@@ -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;
@@ -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;
@@ -154,7 +154,7 @@ function capturedSpawn(originalSpawn, options, pinEnv) {
154
154
  ? originalSpawn(...args)
155
155
  : originalSpawn(effective);
156
156
  }
157
- options.onChild?.(child);
157
+ options.onChild?.(child, Array.isArray(first) ? second : first);
158
158
  return child;
159
159
  };
160
160
  }
@@ -23,7 +23,7 @@ import uuid
23
23
  from collections.abc import Iterable
24
24
  from concurrent.futures import ThreadPoolExecutor, as_completed
25
25
  from pathlib import Path
26
- from threading import Lock
26
+ from threading import Lock, Thread
27
27
  from typing import Any, Callable
28
28
  from urllib.parse import unquote
29
29
 
@@ -1022,14 +1022,48 @@ def handle(message: dict[str, Any]) -> bool:
1022
1022
  return True
1023
1023
 
1024
1024
 
1025
+ def _terminate_process_group() -> None:
1026
+ # The kernel is spawned into its own session (setsid), so its pid is its process
1027
+ # group id and a cell's subprocesses inherit that group. Killing the group takes
1028
+ # those children down with the kernel instead of orphaning them to init.
1029
+ if os.name != "posix":
1030
+ return
1031
+ with contextlib.suppress(OSError):
1032
+ os.killpg(os.getpgrp(), signal.SIGKILL)
1033
+
1034
+
1035
+ def _watch_parent(initial_ppid: int) -> None:
1036
+ # A cell blocked in the main thread (for example a multiprocessing pool) never
1037
+ # returns to the stdin loop, so it cannot notice the host closing its pipe. This
1038
+ # daemon thread notices the reparenting instead and takes the whole group down.
1039
+ while True:
1040
+ time.sleep(1.0)
1041
+ if os.getppid() != initial_ppid:
1042
+ _terminate_process_group()
1043
+ return
1044
+
1045
+
1046
+ def _start_parent_watch() -> None:
1047
+ if os.name != "posix":
1048
+ return
1049
+ Thread(target=_watch_parent, args=(os.getppid(),), name="senpi-parent-watch", daemon=True).start()
1050
+
1051
+
1025
1052
  def main() -> None:
1026
1053
  signal.signal(signal.SIGINT, signal.SIG_IGN)
1054
+ _start_parent_watch()
1055
+ host_closed = False
1027
1056
  for raw in sys.stdin:
1028
1057
  try:
1029
1058
  if not handle(json.loads(raw)):
1059
+ host_closed = True
1030
1060
  break
1031
1061
  except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.
1032
1062
  emit({"type": "init-failed", "error": bridge_error(exc)})
1063
+ # Reaching here without a close frame means the host's pipe hit EOF: it is gone,
1064
+ # so retire any subprocess the last cell left running before the interpreter exits.
1065
+ if not host_closed:
1066
+ _terminate_process_group()
1033
1067
 
1034
1068
 
1035
1069
  if __name__ == "__main__":
@@ -45,6 +45,12 @@ export function splitCommand(commandLine: string): { readonly command: string; r
45
45
  return { command, args };
46
46
  }
47
47
 
48
+ function errorCode(error: unknown): string | undefined {
49
+ if (typeof error !== "object" || error === null) return undefined;
50
+ const code = Reflect.get(error, "code");
51
+ return typeof code === "string" ? code : undefined;
52
+ }
53
+
48
54
  export function numberOrNull(value: unknown): number | null {
49
55
  return typeof value === "number" ? value : null;
50
56
  }
@@ -80,6 +86,20 @@ export async function waitForExit(child: KernelChild, timeoutMs: number): Promis
80
86
  });
81
87
  }
82
88
 
89
+ // A cell's own subprocess is spawned into the detached kernel's process group. When the
90
+ // leader exits gracefully on close, kill the group so a Popen the cell left running does
91
+ // not outlive the kernel; the group is addressed by the leader pid (== pgid) while members live.
92
+ export function sweepProcessGroup(child: KernelChild): void {
93
+ if (child.pid === undefined || process.platform === "win32") return;
94
+ try {
95
+ process.kill(-child.pid, "SIGKILL");
96
+ } catch (error) {
97
+ // ESRCH means the group is already gone (the success case). Any other error
98
+ // (for example EPERM on a member we cannot signal) must not fail a graceful close.
99
+ if (errorCode(error) !== "ESRCH") return;
100
+ }
101
+ }
102
+
83
103
  export async function hardKill(child: KernelChild, timeoutMs: number): Promise<void> {
84
104
  await new Promise<void>((resolve, reject) => {
85
105
  let timer: NodeJS.Timeout | undefined;
@@ -19,6 +19,7 @@ import {
19
19
  numberOrNull,
20
20
  signalOrNull,
21
21
  splitCommand,
22
+ sweepProcessGroup,
22
23
  waitForExit,
23
24
  withTimeout,
24
25
  } from "./process.ts";
@@ -145,7 +146,10 @@ export class PythonKernelTransport {
145
146
  } catch (error) {
146
147
  if (!(error instanceof Error)) throw error;
147
148
  }
148
- if (!(await exited)) await hardKill(this.#child, hardKillWaitMs);
149
+ // hardKill kills the whole group; a graceful leader exit does not, so sweep it
150
+ // to retire any subprocess the cell left running in the kernel's process group.
151
+ if (await exited) sweepProcessGroup(this.#child);
152
+ else await hardKill(this.#child, hardKillWaitMs);
149
153
  }
150
154
 
151
155
  retire(): Promise<void> {
@@ -12,6 +12,8 @@
12
12
  export const SESSION_ENVIRONMENT_KEYS = [
13
13
  "PI_SESSION_ID",
14
14
  "PI_SESSION_FILE",
15
+ "PI_SESSION_CWD",
16
+ "PI_GOAL_STORE_FILE",
15
17
  "PI_PROVIDER",
16
18
  "PI_MODEL",
17
19
  "PI_REASONING_LEVEL",
@@ -22,6 +24,8 @@ export type SessionEnvironment = Readonly<Record<string, string>>;
22
24
 
23
25
  /** Structural slice of `ExtensionContext` the session environment is resolved from. */
24
26
  export interface SessionEnvironmentSource {
27
+ readonly cwd: string;
28
+ readonly goalStoreFile?: string;
25
29
  readonly sessionManager: {
26
30
  getSessionId(): string;
27
31
  getSessionFile(): string | undefined;
@@ -33,6 +37,8 @@ export interface SessionEnvironmentSource {
33
37
  export function sessionEnvironmentFrom(source: SessionEnvironmentSource): SessionEnvironment {
34
38
  const env: Record<string, string> = {};
35
39
  env.PI_SESSION_ID = source.sessionManager.getSessionId();
40
+ env.PI_SESSION_CWD = source.cwd;
41
+ if (source.goalStoreFile) env.PI_GOAL_STORE_FILE = source.goalStoreFile;
36
42
  const sessionFile = source.sessionManager.getSessionFile();
37
43
  if (sessionFile) env.PI_SESSION_FILE = sessionFile;
38
44
  const model = source.model;
@@ -144,7 +144,12 @@ export class EvalDetachedCellManager {
144
144
  }
145
145
 
146
146
  detach(cell: ManagedCell): boolean {
147
- if (!cell.canDetach || !allowsDetachedCellTransition(cell.state, "detached")) return false;
147
+ if (
148
+ !cell.canDetach ||
149
+ !allowsDetachedCellTransition(cell.state, "detached") ||
150
+ this.#detachedByLanguage.has(cell.input.language)
151
+ )
152
+ return false;
148
153
  cell.state = "detached";
149
154
  cell.wasDetached = true;
150
155
  this.#detachedByLanguage.set(cell.input.language, cell);
@@ -49,6 +49,12 @@ export async function runEvalCell(
49
49
  let detached = false;
50
50
  let execution: CellExecution;
51
51
  const cell = cellManager.create(invocation.cellId, invocation.input, (error) => execution.cancel(error));
52
+ const detach = (): boolean => {
53
+ if (!cellManager.detach(cell)) return false;
54
+ detached = true;
55
+ execution.detach();
56
+ return true;
57
+ };
52
58
  execution = new CellExecution({
53
59
  callerSignal: invocation.signal,
54
60
  cellId: invocation.cellId,
@@ -58,12 +64,7 @@ export async function runEvalCell(
58
64
  timeoutMs: detachAfterMs,
59
65
  maxPauseGraceMs: foregroundWindowMs,
60
66
  onTimeout: (error: Error) => {
61
- if (cellManager.detach(cell)) {
62
- detached = true;
63
- execution.detach();
64
- return;
65
- }
66
- execution.cancel(error);
67
+ if (!detach()) execution.cancel(error);
67
68
  },
68
69
  },
69
70
  }
@@ -74,6 +75,16 @@ export async function runEvalCell(
74
75
  bridgeAbortController.abort(error);
75
76
  },
76
77
  });
78
+ const steeringSignal =
79
+ detaches && invocation.ctx.mode !== "print" && invocation.ctx.mode !== "json"
80
+ ? invocation.ctx.steeringSignal
81
+ : undefined;
82
+ const onSteering = (): void => {
83
+ if (!steeringSignal?.aborted || detached || !state.active || invocation.signal.aborted) return;
84
+ // Losing the slot keeps this call foreground; only the existing deadlines/caller may cancel it.
85
+ detach();
86
+ };
87
+ steeringSignal?.addEventListener("abort", onSteering, { once: true });
77
88
  const running = executeCell(
78
89
  options,
79
90
  invocation,
@@ -83,6 +94,7 @@ export async function runEvalCell(
83
94
  execution,
84
95
  bridgeContext,
85
96
  bridgeAbortController,
97
+ onSteering,
86
98
  );
87
99
  let settleEventEmitted = false;
88
100
  const emitSettled = (outcome: EvalExecutionSettleOutcome): void => {
@@ -110,12 +122,16 @@ export async function runEvalCell(
110
122
  throw error;
111
123
  },
112
124
  );
113
- const outcome = await Promise.race([
114
- finalized.then((result) => ({ kind: "result" as const, result })),
115
- execution.detached.then(() => ({ kind: "detached" as const })),
116
- ]);
117
- if (outcome.kind === "detached") return resultAfterDetach(cellManager.peek(invocation.cellId), invocation.input);
118
- return outcome.result;
125
+ try {
126
+ const outcome = await Promise.race([
127
+ finalized.then((result) => ({ kind: "result" as const, result })),
128
+ execution.detached.then(() => ({ kind: "detached" as const })),
129
+ ]);
130
+ if (outcome.kind === "detached") return resultAfterDetach(cellManager.peek(invocation.cellId), invocation.input);
131
+ return outcome.result;
132
+ } finally {
133
+ steeringSignal?.removeEventListener("abort", onSteering);
134
+ }
119
135
  }
120
136
 
121
137
  async function executeCell(
@@ -127,6 +143,7 @@ async function executeCell(
127
143
  execution: CellExecution,
128
144
  bridgeContext: ExtensionContext,
129
145
  bridgeAbortController: AbortController,
146
+ onReady: () => void,
130
147
  ): Promise<AgentToolResult<EvalToolDetails>> {
131
148
  let handler: CellHandler | undefined;
132
149
  try {
@@ -168,6 +185,8 @@ async function executeCell(
168
185
  () => activeHandler.liveResult(),
169
186
  (error) => execution.cancel(error),
170
187
  );
188
+ // Includes a steer already queued at execute start or received while acquiring the kernel.
189
+ onReady();
171
190
  if ("setContext" in options.kernelManager && typeof options.kernelManager.setContext === "function") {
172
191
  options.kernelManager.setContext(bridgeContext);
173
192
  }
@@ -139,6 +139,15 @@ function renderWith<TParams extends TSchema, TDetails, TState>(
139
139
  }
140
140
  }
141
141
 
142
+ function grepDetailsSummary(summary: EvalToolCallSummary): string | undefined {
143
+ const details = summary.details;
144
+ if (details === undefined || details === null || typeof details !== "object") return undefined;
145
+ const record = details as { matchCount?: unknown; fileCount?: unknown; engine?: unknown };
146
+ if (typeof record.fileCount !== "number" || typeof record.engine !== "string") return undefined;
147
+ if (record.matchCount !== null && typeof record.matchCount !== "number") return undefined;
148
+ return `matches=${record.matchCount ?? "n/a"} files=${record.fileCount} (${record.engine})`;
149
+ }
150
+
142
151
  function createCoreWidgets(cwd: string): CoreWidgets {
143
152
  const bashDefinition = createBashToolDefinition(cwd);
144
153
  const readDefinition = createReadToolDefinition(cwd);
@@ -155,7 +164,13 @@ function createCoreWidgets(cwd: string): CoreWidgets {
155
164
  }),
156
165
  read: (summary, options) => renderWith(readDefinition, summary, options, {}),
157
166
  write: (summary, options) => renderWith(writeDefinition, summary, options, {}),
158
- grep: (summary, options) => renderWith(grepDefinition, summary, options, {}),
167
+ grep: (summary, options) => {
168
+ const lines = renderWith(grepDefinition, summary, options, {});
169
+ if (lines === undefined) return undefined;
170
+ const extra = grepDetailsSummary(summary);
171
+ if (extra !== undefined) lines.push(` ${style(options.theme, "muted", extra)}`);
172
+ return lines;
173
+ },
159
174
  find: (summary, options) => renderWith(findDefinition, summary, options, {}),
160
175
  ls: (summary, options) => renderWith(lsDefinition, summary, options, {}),
161
176
  };
package/src/tool/types.ts CHANGED
@@ -153,6 +153,7 @@ export interface EvalToolCallSummary {
153
153
  readonly argsTruncated?: boolean;
154
154
  readonly durationMs?: number;
155
155
  readonly resultPreview?: string;
156
+ readonly details?: unknown;
156
157
  }
157
158
 
158
159
  export type EvalStatusEvent = { readonly op: string } & Readonly<Record<string, unknown>>;