@code-yeongyu/senpi-codemode 2026.10.10-9 → 2026.10.11

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/package.json +4 -4
  3. package/src/bridge/memory-protocol.ts +8 -1
  4. package/src/bridge/protocol.ts +1 -1
  5. package/src/bridges/eval-environment-schemas.ts +5 -1
  6. package/src/environments/editable-check.ts +1 -1
  7. package/src/extension/session-cwd.ts +25 -9
  8. package/src/extension/session-manager.ts +4 -4
  9. package/src/kernels/jl/globals.jl +128 -0
  10. package/src/kernels/jl/prelude.jl +15 -2
  11. package/src/kernels/jl/runner.jl +35 -143
  12. package/src/kernels/js/group-signal-notice.js +117 -0
  13. package/src/kernels/js/inline-worker.ts +2 -0
  14. package/src/kernels/js/local-module-loader.ts +8 -0
  15. package/src/kernels/js/process-tree-host.ts +4 -8
  16. package/src/kernels/js/process-tree.d.ts +11 -0
  17. package/src/kernels/js/process-tree.js +55 -10
  18. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  19. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  20. package/src/kernels/js/windows-tree-kill.js +100 -0
  21. package/src/kernels/js/worker-cwd.js +41 -10
  22. package/src/kernels/js/worker-runtime.js +23 -4
  23. package/src/kernels/js/worker-shell-capture.js +4 -0
  24. package/src/kernels/js/worker-slot.ts +2 -3
  25. package/src/kernels/py/prelude.py +36 -25
  26. package/src/kernels/py/transport.ts +4 -0
  27. package/src/kernels/rb/runner.rb +9 -19
  28. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  29. package/src/kernels/shared/bun-ffi.ts +24 -0
  30. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  31. package/src/kernels/shared/process-cpu.ts +7 -3
  32. package/src/kernels/shared/process-group-cpu.ts +6 -3
  33. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  34. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  35. package/src/kernels/shared/subprocess-process.ts +20 -12
  36. package/src/kernels/shared/subprocess-queue.ts +1 -0
  37. package/src/tool/eval-tool.ts +2 -1
  38. package/src/tool/render-agent.ts +110 -0
  39. package/src/tool/render-blocks.ts +252 -0
  40. package/src/tool/render-call.ts +130 -0
  41. package/src/tool/render-cell.ts +165 -0
  42. package/src/tool/render-detail.ts +152 -0
  43. package/src/tool/render-live.ts +288 -0
  44. package/src/tool/render-result.ts +202 -0
  45. package/src/tool/render-status.ts +166 -0
  46. package/src/tool/render-tail.ts +139 -0
  47. package/src/tool/render.ts +3 -1128
  48. package/src/tool/run-eval-cell.ts +7 -12
  49. package/src/tool/types.ts +11 -2
@@ -0,0 +1,117 @@
1
+ // senpi#2995: Bun.$, node:child_process exec/execFile and the synchronous spawners leave their children in the
2
+ // agent's process group (Bun.$ has no such option, Bun ignores `detached` for exec/execFile, and a synchronous call
3
+ // keeps the terminal), so a group-wide signal from the same cell stops the agent. Such a command gets a notice; the
4
+ // notice only warns and never fails the cell.
5
+
6
+ // A command starts a (possibly indented) line, follows `;`, `|`, `&`, `(`, a backtick, `{`, `!`, a shell keyword
7
+ // (`then`, `do`, `else`) or a runner (`nohup`, `time`, `xargs`, `env`, `exec`, `sudo` with flags), or opens a `-c '...'` script; a
8
+ // `kill` word anywhere else (`git log --grep kill`, `echo kill ...`) is text.
9
+ const COMMAND_POSITION = String.raw`(?:^\s*|[;|&(\x60]\s*|\b(?:then|do|else|nohup|time|xargs|env|exec)\s+|[{!]\s+|\bsudo(?:\s+-\S+)*\s+|\s-[A-Za-z]*c\s+['"]\s*)`;
10
+
11
+ // A group target is a dash argument after the signal options: a number, `$var`, `${var}` or `$(...)`.
12
+ const GROUP_TARGET = String.raw`-(?:\d+|\$\$|\$\{?\w+\}?|\$\()`;
13
+ const SIGNAL_OPTION = String.raw`(?:-s\s+\w+|-n\s+\d+|-[A-Za-z]+\d*|-\d+)`;
14
+ // The first dash argument is a signal, so a group is one after `--`, after a signal option, or after a plain pid:
15
+ // `kill -- -<pgid>`, `kill -SIG -<pgid>`, `kill -TERM 1234 -5678`, `kill -- -$$`.
16
+ const PID = String.raw`[^\s\-;|&][^\s;|&]*`;
17
+ const KILL_GROUP = new RegExp(
18
+ String.raw`${COMMAND_POSITION}kill\b(?:` +
19
+ String.raw`\s+--\s+${GROUP_TARGET}` +
20
+ String.raw`|(?:\s+${SIGNAL_OPTION})+(?:\s+${PID})*\s+(?:--\s+)?${GROUP_TARGET}` +
21
+ String.raw`|(?:\s+${SIGNAL_OPTION})*(?:\s+${PID})+\s+${GROUP_TARGET})`,
22
+ "mu",
23
+ );
24
+ const PKILL_GROUP = new RegExp(String.raw`${COMMAND_POSITION}pkill\b[^\n;|&]*\s(?:-g\d*|--pgroup)\b`, "mu");
25
+ // `killall` in command position (also after `sudo` or as a `-c` script) signals every process with that name, which
26
+ // can include the agent's own (bun, node, senpi). Inside an `echo` string it is only text.
27
+ const KILLALL = new RegExp(String.raw`${COMMAND_POSITION}killall\s`, "mu");
28
+
29
+ export const GROUP_SIGNAL_NOTICE_TAG = "[senpi:group-signal]";
30
+
31
+ export function signalsProcessGroup(commandText) {
32
+ return KILL_GROUP.test(commandText) || PKILL_GROUP.test(commandText) || KILLALL.test(commandText);
33
+ }
34
+
35
+ export function groupSignalNotice(api, readGroup = agentProcessGroup) {
36
+ const pgid = readGroup();
37
+ const group = pgid === undefined ? "the agent's process group" : `the agent's process group (${pgid})`;
38
+ return (
39
+ `${GROUP_SIGNAL_NOTICE_TAG} This command signals a process group. Children of ${api} share ${group}, so ` +
40
+ "signalling that group can stop this session. Start background jobs with Bun.spawn or child_process.spawn, " +
41
+ "which give them their own group, or signal them by pid.\n"
42
+ );
43
+ }
44
+
45
+ let cachedGroup;
46
+
47
+ // A bounded `ps` lookup, kept once it succeeds; a missing `ps` or a failed run only drops the number from this notice.
48
+ export function agentProcessGroup(spawnSync = globalThis.Bun?.spawnSync) {
49
+ if (cachedGroup !== undefined) return cachedGroup;
50
+ let text = "";
51
+ try {
52
+ const result = spawnSync?.(["ps", "-o", "pgid=", "-p", String(process.pid)], { timeout: 2_000 });
53
+ text = result?.success ? String(result.stdout).trim() : "";
54
+ } catch {
55
+ text = "";
56
+ }
57
+ if (!/^\d+$/u.test(text)) return undefined;
58
+ cachedGroup = text;
59
+ return cachedGroup;
60
+ }
61
+
62
+ export function shellCommandText(strings, expressions) {
63
+ if (!Array.isArray(strings)) return String(strings ?? "");
64
+ return strings.reduce((text, part, index) => text + part + (index < expressions.length ? String(expressions[index]) : ""), "");
65
+ }
66
+
67
+ const { promisify } = process.getBuiltinModule("node:util");
68
+ const SCANNED_SPAWNERS = ["exec", "execFile", "execSync", "execFileSync", "spawnSync"];
69
+
70
+ function commandTextOf(name, args) {
71
+ if (name === "exec" || name === "execSync") return String(args[0]);
72
+ return [args[0], ...(Array.isArray(args[1]) ? args[1] : [])].join(" ");
73
+ }
74
+
75
+ export function noticeChildProcessGroupSignals(emitText, isActive) {
76
+ const childProcess = process.getBuiltinModule("node:child_process");
77
+ const originals = new Map();
78
+ for (const name of SCANNED_SPAWNERS) {
79
+ const original = childProcess[name];
80
+ if (typeof original !== "function") continue;
81
+ originals.set(name, original);
82
+ const scan = (args) => {
83
+ try {
84
+ if (isActive() && signalsProcessGroup(commandTextOf(name, args))) {
85
+ emitText("stderr", groupSignalNotice(`child_process.${name}`));
86
+ }
87
+ } catch {
88
+ // The notice is advisory; the call itself always runs.
89
+ }
90
+ };
91
+ const scanned = function scannedSpawner(...args) {
92
+ scan(args);
93
+ return original.apply(this, args);
94
+ };
95
+ // Every own property, symbols included: `util.promisify.custom` is what makes promisify(exec) resolve to
96
+ // { stdout, stderr }. That custom function calls the original directly, so it is scanned too.
97
+ for (const key of Reflect.ownKeys(original)) {
98
+ if (key === "length" || key === "name" || key === "prototype" || key === promisify.custom) continue;
99
+ const descriptor = Object.getOwnPropertyDescriptor(original, key);
100
+ if (descriptor) Object.defineProperty(scanned, key, descriptor);
101
+ }
102
+ const customPromisify = original[promisify.custom];
103
+ if (typeof customPromisify === "function") {
104
+ Object.defineProperty(scanned, promisify.custom, {
105
+ configurable: true,
106
+ value: function scannedPromisified(...args) {
107
+ scan(args);
108
+ return customPromisify.apply(this, args);
109
+ },
110
+ });
111
+ }
112
+ childProcess[name] = scanned;
113
+ }
114
+ return () => {
115
+ for (const [name, original] of originals) childProcess[name] = original;
116
+ };
117
+ }
@@ -19,6 +19,8 @@ export function resolveInlineWorkerEntryUrl(options: JavaScriptInlineWorkerEntry
19
19
 
20
20
  export interface WorkerLike {
21
21
  readonly mode: JavaScriptKernelMode;
22
+ /** The child pid when `mode` is `"process"`; the slot reports it for orphan accounting after a crash. */
23
+ readonly pid?: number;
22
24
  postMessage(message: HostToKernelMessage, transfer?: readonly MessagePort[]): void;
23
25
  onMessage(handler: (message: KernelToHostMessage) => void): () => void;
24
26
  onError(handler: (error: Error) => void): () => void;
@@ -79,6 +79,14 @@ function loaderPrelude(context: RuntimeModuleContext): string {
79
79
  " const specifier = String(source);",
80
80
  " const kernelBun = globalThis[Symbol.for('senpi.kernel.bun')];",
81
81
  " if (specifier === 'bun' && kernelBun) return { ...(await import('bun')), WebView: kernelBun.WebView, default: kernelBun };",
82
+ // Under Bun a cell's named import (`import { spawn } from 'node:child_process'`) binds the builtin's original
83
+ // export even after the worker patched the module object (worker-cwd.js), so the session cwd and the cell
84
+ // children's own process group (senpi#2995) were skipped. The patched module objects are served instead.
85
+ " const patched = /^(?:node:)?(child_process|fs|fs\\/promises|path)$/.exec(specifier);",
86
+ " if (patched && typeof process.getBuiltinModule === 'function') {",
87
+ " const builtin = process.getBuiltinModule('node:' + patched[1]);",
88
+ " if (builtin) return { ...builtin, default: builtin };",
89
+ " }",
82
90
  " const match = /^([a-z][a-z0-9+.-]*):\\/\\/(.*)$/i.exec(specifier);",
83
91
  " let target = specifier;",
84
92
  " if (match) {",
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { killWindowsTree, listWindowsRows } from "./windows-tree-kill-host.ts";
2
3
 
3
4
  // Host-side twin of worker-runtime's `process-tree.js`. The worker file must stay plain
4
5
  // JavaScript (a Node worker thread spawned from a `.js` entry has no TypeScript loader), and
@@ -100,7 +101,7 @@ function sleep(ms: number): Promise<void> {
100
101
  /**
101
102
  * Retire whole process trees: snapshot the descendants first (a parent that dies before the
102
103
  * 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
+ * disappear, then SIGKILL whatever is left. On Windows the checked tree is killed by pid (`windows-tree-kill.js`).
104
105
  */
105
106
  export async function terminateProcessTrees(
106
107
  roots: readonly number[],
@@ -109,7 +110,8 @@ export async function terminateProcessTrees(
109
110
  const liveRoots = roots.filter(isProcessAlive);
110
111
  if (liveRoots.length === 0) return;
111
112
  if (process.platform === "win32") {
112
- for (const pid of liveRoots) await taskkillTree(pid);
113
+ const listing = listWindowsRows();
114
+ for (const pid of liveRoots) await killWindowsTree(pid, listing);
113
115
  return;
114
116
  }
115
117
  const table = await readProcessTable();
@@ -150,9 +152,3 @@ async function withoutZombies(pids: number[]): Promise<number[]> {
150
152
  }
151
153
  return pids.filter((pid) => !zombies.has(pid));
152
154
  }
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
- }
@@ -22,3 +22,14 @@ export function collectDescendants(table: ProcessTable, roots: readonly number[]
22
22
  export function ownedRoots(table: ProcessTable, parentPid: number, roots: readonly number[]): number[];
23
23
 
24
24
  export function terminateProcessTrees(roots: readonly number[], options: TerminateProcessTreesOptions): Promise<void>;
25
+ export interface CellProcessGroup {
26
+ readonly pgid: number;
27
+ readonly leaderExited: boolean;
28
+ }
29
+ export interface TerminateProcessGroupsOptions {
30
+ readonly graceMs: number;
31
+ }
32
+ export function terminateProcessGroups(
33
+ groups: readonly CellProcessGroup[],
34
+ options: TerminateProcessGroupsOptions,
35
+ ): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { killWindowsTree, listWindowsRows } from "./windows-tree-kill.js";
2
3
 
3
4
  // Plain JS (with process-tree.d.ts) because the worker runtime imports it and
4
5
  // worker files cannot import TypeScript; the host imports the same module so a
@@ -6,6 +7,8 @@ import { execFile } from "node:child_process";
6
7
 
7
8
  const PS_MAX_BUFFER = 16 * 1024 * 1024;
8
9
  const EXIT_POLL_MS = 25;
10
+ // A hung `ps` must not hold a cell's retirement; empty output then means nothing is signalled.
11
+ const PS_LOOKUP_TIMEOUT_MS = 2_000;
9
12
  const ZOMBIE_CHECK_INTERVAL_MS = 250;
10
13
 
11
14
  export function isProcessAlive(pid) {
@@ -30,7 +33,7 @@ export function signalProcess(pid, signal) {
30
33
 
31
34
  /**
32
35
  * 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.
36
+ * `ps`; callers there kill the checked tree from `windows-tree-kill.js`.
34
37
  */
35
38
  export async function readProcessTable() {
36
39
  if (process.platform === "win32") return new Map();
@@ -78,13 +81,14 @@ function sleep(ms) {
78
81
  * Retire whole process trees: snapshot the descendants first (a parent that
79
82
  * dies before the snapshot would reparent them to init), SIGTERM everything,
80
83
  * 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.
84
+ * whatever is left. On Windows the checked tree is killed by pid (`windows-tree-kill.js`).
82
85
  */
83
86
  export async function terminateProcessTrees(roots, options) {
84
87
  const liveRoots = roots.filter(isProcessAlive);
85
88
  if (liveRoots.length === 0) return;
86
89
  if (process.platform === "win32") {
87
- for (const pid of liveRoots) await taskkillTree(pid);
90
+ const listing = listWindowsRows();
91
+ for (const pid of liveRoots) await killWindowsTree(pid, listing);
88
92
  return;
89
93
  }
90
94
  const table = await readProcessTable();
@@ -98,6 +102,52 @@ export async function terminateProcessTrees(roots, options) {
98
102
  await waitForExit(survivors, options.killWaitMs ?? options.graceMs, options.settled);
99
103
  }
100
104
 
105
+ /**
106
+ * Retire whole process groups the worker created (POSIX): SIGTERM each group that still has members, wait `graceMs`,
107
+ * then SIGKILL what is left. A group whose leader already exited is skipped while a live process holds the leader's
108
+ * pid, since that pid was reused. The agent's own group is never signalled.
109
+ */
110
+ export async function terminateProcessGroups(groups, options) {
111
+ if (process.platform === "win32") return;
112
+ const ours = (group) => groupHasMembers(group.pgid) && !(group.leaderExited && isProcessAlive(group.pgid));
113
+ const candidates = groups.filter((group) => Number.isInteger(group.pgid) && group.pgid > 1 && ours(group));
114
+ if (candidates.length === 0) return;
115
+ // Without the agent's own group there is no way to rule it out, so nothing is signalled.
116
+ const ownGroup = await processGroupOf(process.pid);
117
+ if (ownGroup === undefined) return;
118
+ const live = candidates.filter((group) => group.pgid !== ownGroup && ours(group));
119
+ for (const group of live) signalGroup(group.pgid, "SIGTERM");
120
+ const deadline = Date.now() + options.graceMs;
121
+ let remaining = live;
122
+ while (remaining.length > 0 && Date.now() < deadline) {
123
+ await sleep(EXIT_POLL_MS);
124
+ remaining = remaining.filter(ours);
125
+ }
126
+ for (const group of remaining) signalGroup(group.pgid, "SIGKILL");
127
+ }
128
+
129
+ function groupHasMembers(pgid) {
130
+ try {
131
+ process.kill(-pgid, 0);
132
+ return true;
133
+ } catch (error) {
134
+ return isRecord(error) && error.code === "EPERM";
135
+ }
136
+ }
137
+
138
+ function signalGroup(pgid, signal) {
139
+ try {
140
+ process.kill(-pgid, signal);
141
+ } catch {
142
+ // The group emptied between the check and the signal.
143
+ }
144
+ }
145
+
146
+ async function processGroupOf(pid) {
147
+ const text = (await ps(["-o", "pgid=", "-p", String(pid)], { timeout: PS_LOOKUP_TIMEOUT_MS })).trim();
148
+ return /^\d+$/u.test(text) ? Number(text) : undefined;
149
+ }
150
+
101
151
  // `kill(pid, 0)` still succeeds for a zombie, and a child whose owning worker is
102
152
  // blocked or gone is never reaped, so liveness also consults `ps` state.
103
153
  async function waitForExit(pids, graceMs, settled) {
@@ -132,19 +182,14 @@ async function withoutZombies(pids) {
132
182
  }
133
183
 
134
184
  // `ps -p` exits non-zero when any listed pid is gone but still prints the rest.
135
- function ps(args) {
185
+ function ps(args, options = {}) {
136
186
  return new Promise((resolve) => {
137
- execFile("ps", args, { maxBuffer: PS_MAX_BUFFER, windowsHide: true }, (_error, output) =>
187
+ execFile("ps", args, { maxBuffer: PS_MAX_BUFFER, windowsHide: true, ...options }, (_error, output) =>
138
188
  resolve(typeof output === "string" ? output : String(output ?? "")),
139
189
  );
140
190
  });
141
191
  }
142
192
 
143
- function taskkillTree(pid) {
144
- return new Promise((resolve) => {
145
- execFile("taskkill", ["/T", "/F", "/PID", String(pid)], { windowsHide: true }, () => resolve());
146
- });
147
- }
148
193
 
149
194
  function isRecord(value) {
150
195
  return typeof value === "object" && value !== null;
@@ -0,0 +1,117 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ // Host-side twin of windows-tree-kill.js (the worker imports that one; .ts files may not import .js). `taskkill /T`
4
+ // adopts every process whose recorded ParentProcessId equals the root's pid, and Windows never rewrites that
5
+ // field when a parent exits and reuses pids (senpi#2999). The tree is computed from one process listing instead: a process
6
+ // belongs to it only when it started at or after the parent it names, and each pid is killed by name.
7
+
8
+ const LISTING_TIMEOUT_MS = 5_000;
9
+ const LISTING_MAX_BUFFER = 16 * 1024 * 1024;
10
+
11
+ const ROWS_SCRIPT = `Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID } | ForEach-Object {
12
+ $created = if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 }
13
+ "{0} {1} {2} {3}" -f $_.ProcessId, $_.ParentProcessId, $created, $_.Name }`;
14
+
15
+ const PROTECTED_IMAGES = new Set([
16
+ "system",
17
+ "smss.exe",
18
+ "csrss.exe",
19
+ "wininit.exe",
20
+ "winlogon.exe",
21
+ "services.exe",
22
+ "lsass.exe",
23
+ "lsaiso.exe",
24
+ "svchost.exe",
25
+ ]);
26
+
27
+ export interface WindowsProcessRow {
28
+ readonly pid: number;
29
+ readonly parentPid: number;
30
+ readonly createdAt: bigint;
31
+ readonly name: string;
32
+ }
33
+
34
+ export function parseWindowsRows(stdout: string): WindowsProcessRow[] {
35
+ const rows: WindowsProcessRow[] = [];
36
+ for (const line of stdout.split(/\r?\n/u)) {
37
+ const [pidText, parentText, createdText, ...nameParts] = line.trim().split(/\s+/u);
38
+ const pid = Number(pidText);
39
+ const parentPid = Number(parentText);
40
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !/^\d+$/u.test(createdText ?? ""))
41
+ continue;
42
+ rows.push({ pid, parentPid, createdAt: BigInt(createdText ?? "0"), name: nameParts.join(" ") });
43
+ }
44
+ return rows;
45
+ }
46
+
47
+ /** The root and its genuine descendants; any protected image or ancestor of `selfPid` in it shrinks the kill to the root. */
48
+ export function windowsTreeKillPids(rows: readonly WindowsProcessRow[], rootPid: number, selfPid: number): number[] {
49
+ const byPid = new Map(rows.map((row) => [row.pid, row]));
50
+ if (!byPid.has(rootPid)) return [];
51
+ const tree = new Set<number>([rootPid]);
52
+ let grew = true;
53
+ while (grew) {
54
+ grew = false;
55
+ for (const row of rows) {
56
+ if (tree.has(row.pid) || !tree.has(row.parentPid)) continue;
57
+ const parent = byPid.get(row.parentPid);
58
+ if (parent === undefined || row.createdAt < parent.createdAt) continue;
59
+ tree.add(row.pid);
60
+ grew = true;
61
+ }
62
+ }
63
+ const ancestors = new Set<number>([selfPid]);
64
+ for (let current = byPid.get(selfPid); current !== undefined; ) {
65
+ const parent = byPid.get(current.parentPid);
66
+ if (parent === undefined || ancestors.has(parent.pid) || parent.createdAt > current.createdAt) break;
67
+ ancestors.add(parent.pid);
68
+ current = parent;
69
+ }
70
+ const refused = [...tree].some(
71
+ (pid) => PROTECTED_IMAGES.has((byPid.get(pid)?.name ?? "").toLowerCase()) || ancestors.has(pid),
72
+ );
73
+ if (!refused) return [...tree];
74
+ const rootRefused = PROTECTED_IMAGES.has((byPid.get(rootPid)?.name ?? "").toLowerCase()) || ancestors.has(rootPid);
75
+ return rootRefused ? [] : [rootPid];
76
+ }
77
+
78
+ /** `taskkill` arguments: the checked tree pid by pid, or `/T` on the root when no listing could be read. */
79
+ export function windowsTreeKillArgs(
80
+ rootPid: number,
81
+ rows: readonly WindowsProcessRow[] | undefined,
82
+ selfPid: number = process.pid,
83
+ ): string[] {
84
+ if (rows === undefined) return ["/F", "/T", "/PID", String(rootPid)];
85
+ const pids = windowsTreeKillPids(rows, rootPid, selfPid);
86
+ if (pids.length === 0) return [];
87
+ return ["/F", ...pids.flatMap((pid) => ["/PID", String(pid)])];
88
+ }
89
+
90
+ export function listWindowsRows(): Promise<WindowsProcessRow[] | undefined> {
91
+ return new Promise((resolve) => {
92
+ execFile(
93
+ "powershell.exe",
94
+ ["-NoProfile", "-NonInteractive", "-Command", ROWS_SCRIPT],
95
+ { timeout: LISTING_TIMEOUT_MS, maxBuffer: LISTING_MAX_BUFFER, windowsHide: true },
96
+ (error, stdout) => {
97
+ const rows = error ? [] : parseWindowsRows(String(stdout ?? ""));
98
+ resolve(rows.length > 0 ? rows : undefined);
99
+ },
100
+ );
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Kills the checked process tree rooted at `pid`; resolves once taskkill has run (or nothing needed killing).
106
+ * A batch passes one shared `listing` so every tree is planned from the same snapshot.
107
+ */
108
+ export async function killWindowsTree(
109
+ pid: number,
110
+ listing: Promise<readonly WindowsProcessRow[] | undefined> = listWindowsRows(),
111
+ ): Promise<void> {
112
+ const args = windowsTreeKillArgs(pid, await listing);
113
+ if (args.length === 0) return;
114
+ await new Promise<void>((resolve) => {
115
+ execFile("taskkill", args, { timeout: LISTING_TIMEOUT_MS, windowsHide: true }, () => resolve());
116
+ });
117
+ }
@@ -0,0 +1,19 @@
1
+ export interface WindowsProcessRow {
2
+ readonly pid: number;
3
+ readonly parentPid: number;
4
+ readonly createdAt: bigint;
5
+ readonly name: string;
6
+ }
7
+
8
+ export function parseWindowsRows(stdout: string): WindowsProcessRow[];
9
+ export function windowsTreeKillPids(rows: readonly WindowsProcessRow[], rootPid: number, selfPid: number): number[];
10
+ export function windowsTreeKillArgs(
11
+ rootPid: number,
12
+ rows: readonly WindowsProcessRow[] | undefined,
13
+ selfPid?: number,
14
+ ): string[];
15
+ export function listWindowsRows(): Promise<WindowsProcessRow[] | undefined>;
16
+ export function killWindowsTree(
17
+ pid: number,
18
+ listing?: Promise<readonly WindowsProcessRow[] | undefined>,
19
+ ): Promise<void>;
@@ -0,0 +1,100 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ // Plain JS (with windows-tree-kill.d.ts) because the worker runtime imports it. `taskkill /T`
4
+ // adopts every process whose recorded ParentProcessId equals the root's pid, and Windows never rewrites that
5
+ // field when a parent exits and reuses pids (senpi#2999). The tree is computed from one process listing instead: a process
6
+ // belongs to it only when it started at or after the parent it names, and each pid is killed by name.
7
+
8
+ const LISTING_TIMEOUT_MS = 5_000;
9
+ const LISTING_MAX_BUFFER = 16 * 1024 * 1024;
10
+
11
+ const ROWS_SCRIPT = `Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID } | ForEach-Object {
12
+ $created = if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 }
13
+ "{0} {1} {2} {3}" -f $_.ProcessId, $_.ParentProcessId, $created, $_.Name }`;
14
+
15
+ const PROTECTED_IMAGES = new Set([
16
+ "system",
17
+ "smss.exe",
18
+ "csrss.exe",
19
+ "wininit.exe",
20
+ "winlogon.exe",
21
+ "services.exe",
22
+ "lsass.exe",
23
+ "lsaiso.exe",
24
+ "svchost.exe",
25
+ ]);
26
+
27
+ export function parseWindowsRows(stdout) {
28
+ const rows = [];
29
+ for (const line of stdout.split(/\r?\n/u)) {
30
+ const [pidText, parentText, createdText, ...nameParts] = line.trim().split(/\s+/u);
31
+ const pid = Number(pidText);
32
+ const parentPid = Number(parentText);
33
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !/^\d+$/u.test(createdText ?? "")) continue;
34
+ rows.push({ pid, parentPid, createdAt: BigInt(createdText ?? "0"), name: nameParts.join(" ") });
35
+ }
36
+ return rows;
37
+ }
38
+
39
+ /** The root and its genuine descendants; any protected image or ancestor of `selfPid` in it shrinks the kill to the root. */
40
+ export function windowsTreeKillPids(rows, rootPid, selfPid) {
41
+ const byPid = new Map(rows.map((row) => [row.pid, row]));
42
+ if (!byPid.has(rootPid)) return [];
43
+ const tree = new Set([rootPid]);
44
+ let grew = true;
45
+ while (grew) {
46
+ grew = false;
47
+ for (const row of rows) {
48
+ if (tree.has(row.pid) || !tree.has(row.parentPid)) continue;
49
+ const parent = byPid.get(row.parentPid);
50
+ if (parent === undefined || row.createdAt < parent.createdAt) continue;
51
+ tree.add(row.pid);
52
+ grew = true;
53
+ }
54
+ }
55
+ const ancestors = new Set([selfPid]);
56
+ for (let current = byPid.get(selfPid); current !== undefined; ) {
57
+ const parent = byPid.get(current.parentPid);
58
+ if (parent === undefined || ancestors.has(parent.pid) || parent.createdAt > current.createdAt) break;
59
+ ancestors.add(parent.pid);
60
+ current = parent;
61
+ }
62
+ const refused = [...tree].some((pid) => PROTECTED_IMAGES.has((byPid.get(pid)?.name ?? "").toLowerCase()) || ancestors.has(pid));
63
+ if (!refused) return [...tree];
64
+ const rootRefused = PROTECTED_IMAGES.has(byPid.get(rootPid).name.toLowerCase()) || ancestors.has(rootPid);
65
+ return rootRefused ? [] : [rootPid];
66
+ }
67
+
68
+ /** `taskkill` arguments: the checked tree pid by pid, or `/T` on the root when no listing could be read. */
69
+ export function windowsTreeKillArgs(rootPid, rows, selfPid = process.pid) {
70
+ if (rows === undefined) return ["/F", "/T", "/PID", String(rootPid)];
71
+ const pids = windowsTreeKillPids(rows, rootPid, selfPid);
72
+ if (pids.length === 0) return [];
73
+ return ["/F", ...pids.flatMap((pid) => ["/PID", String(pid)])];
74
+ }
75
+
76
+ export function listWindowsRows() {
77
+ return new Promise((resolve) => {
78
+ execFile(
79
+ "powershell.exe",
80
+ ["-NoProfile", "-NonInteractive", "-Command", ROWS_SCRIPT],
81
+ { timeout: LISTING_TIMEOUT_MS, maxBuffer: LISTING_MAX_BUFFER, windowsHide: true },
82
+ (error, stdout) => {
83
+ const rows = error ? [] : parseWindowsRows(String(stdout ?? ""));
84
+ resolve(rows.length > 0 ? rows : undefined);
85
+ },
86
+ );
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Kills the checked process tree rooted at `pid`; resolves once taskkill has run (or nothing needed killing).
92
+ * A batch passes one shared `listing` so every tree is planned from the same snapshot.
93
+ */
94
+ export async function killWindowsTree(pid, listing = listWindowsRows()) {
95
+ const args = windowsTreeKillArgs(pid, await listing);
96
+ if (args.length === 0) return;
97
+ await new Promise((resolve) => {
98
+ execFile("taskkill", args, { timeout: LISTING_TIMEOUT_MS, windowsHide: true }, () => resolve());
99
+ });
100
+ }
@@ -51,6 +51,7 @@ const FS_PATH_ARGS = {
51
51
 
52
52
  const CHILD_PROCESS_FILE_ARGS = ["spawn", "spawnSync", "execFile", "execFileSync", "fork"];
53
53
  const CHILD_PROCESS_COMMAND_ARGS = ["exec", "execSync"];
54
+ const ASYNC_GROUP_SPAWNERS = new Set(["spawn", "fork"]);
54
55
 
55
56
  export function installSessionCwd(cwd, options) {
56
57
  // A process-mode kernel child IS the main thread; the worker guard relaxes only for that entry.
@@ -95,7 +96,9 @@ function patchFs(fs, promises, at, root) {
95
96
  function patchChildProcess(childProcess, at, root) {
96
97
  for (const name of CHILD_PROCESS_FILE_ARGS) {
97
98
  replace(childProcess, name, (fn) => {
98
- const withCwd = withOptionsCwd(fn, fileOptionsIndex, root);
99
+ // Only asynchronous spawners start a new group: a synchronous call keeps the terminal (ssh/sudo/git prompts),
100
+ // and Bun ignores `detached` for execFile. Those calls get a notice instead (group-signal-notice.js).
101
+ const withCwd = withOptionsCwd(fn, fileOptionsIndex, root, ASYNC_GROUP_SPAWNERS.has(name));
99
102
  return name === "fork" ? resolvingArgs(withCwd, [0], at) : withCwd;
100
103
  });
101
104
  }
@@ -107,7 +110,7 @@ function patchBun(bun, at, root) {
107
110
  replace(bun, "file", (fn) => resolvingArgs(fn, [0], at));
108
111
  replace(bun, "write", (fn) => resolvingArgs(fn, [0], at));
109
112
  replace(bun, "spawn", (fn) => withBunSpawnCwd(fn, root));
110
- replace(bun, "spawnSync", (fn) => withBunSpawnCwd(fn, root));
113
+ replace(bun, "spawnSync", (fn) => withBunSpawnCwd(fn, root, false));
111
114
  if (typeof bun.$?.cwd === "function") bun.$.cwd(root);
112
115
  const glob = bun.Glob?.prototype;
113
116
  if (glob) {
@@ -149,32 +152,60 @@ function fileOptionsIndex(args) {
149
152
  return Array.isArray(args[1]) || (args[1] == null && args.length > 2) ? 2 : 1;
150
153
  }
151
154
 
152
- function withOptionsCwd(fn, optionsIndex, root) {
155
+ function withOptionsCwd(fn, optionsIndex, root, inOwnGroup = false) {
156
+ const group = inOwnGroup ? ownGroup : (options) => options;
153
157
  return function withSessionCwd(...args) {
154
158
  const index = optionsIndex(args);
155
159
  const options = args[index];
156
- if (options === undefined || options === null) args[index] = { cwd: root };
157
- else if (typeof options === "function") args.splice(index, 0, { cwd: root });
158
- else if (typeof options === "object") args[index] = { ...options, cwd: cwdFrom(options.cwd, root) };
159
- return fn.apply(this, args);
160
+ if (options === undefined || options === null) args[index] = group({ cwd: root });
161
+ else if (typeof options === "function") args.splice(index, 0, group({ cwd: root }));
162
+ else if (typeof options === "object") args[index] = group({ ...options, cwd: cwdFrom(options.cwd, root) });
163
+ if (!inOwnGroup || process.platform === "win32" || (isPlainOptions(options) && options.detached !== undefined)) {
164
+ return fn.apply(this, args);
165
+ }
166
+ // Bun's child_process spawns through Bun.spawn, whose child tracking skips `detached` children as ones the
167
+ // cell keeps on purpose. While this call runs, `detached` is ours, so the child is still retired with the cell.
168
+ const depth = globalThis[INJECTED_GROUP] ?? 0;
169
+ globalThis[INJECTED_GROUP] = depth + 1;
170
+ try {
171
+ return fn.apply(this, args);
172
+ } finally {
173
+ globalThis[INJECTED_GROUP] = depth;
174
+ }
160
175
  };
161
176
  }
162
177
 
163
- function withBunSpawnCwd(fn, root) {
178
+ function withBunSpawnCwd(fn, root, inOwnGroup = true) {
179
+ const group = inOwnGroup ? ownGroup : (options) => options;
164
180
  return function spawnInSessionCwd(...args) {
165
181
  const [first, second] = args;
166
182
  if (Array.isArray(first)) {
167
183
  const options = second === undefined || second === null ? {} : second;
168
184
  if (typeof options !== "object") return fn.apply(this, args);
169
- return fn.call(this, first, { ...options, cwd: cwdFrom(options.cwd, root) }, ...args.slice(2));
185
+ return fn.call(this, first, group({ ...options, cwd: cwdFrom(options.cwd, root) }), ...args.slice(2));
170
186
  }
171
187
  if (first !== null && typeof first === "object") {
172
- return fn.call(this, { ...first, cwd: cwdFrom(first.cwd, root) }, ...args.slice(1));
188
+ return fn.call(this, group({ ...first, cwd: cwdFrom(first.cwd, root) }), ...args.slice(1));
173
189
  }
174
190
  return fn.apply(this, args);
175
191
  };
176
192
  }
177
193
 
194
+ // senpi#2995: a JS kernel runs inside the agent process, so a child spawned from a cell would join the agent's
195
+ // process group, and a cell that later signals that group (`kill -TERM -- -$PGID`) stops the agent itself. On
196
+ // POSIX every cell child starts its own group instead; kernel teardown still retires it by pid and descendants.
197
+ // A cell that sets `detached` itself keeps its choice. Windows is left alone: there `detached` opens a console.
198
+ export const INJECTED_GROUP = Symbol.for("senpi.kernel.injectedProcessGroup");
199
+
200
+ function isPlainOptions(value) {
201
+ return value !== null && typeof value === "object";
202
+ }
203
+
204
+ function ownGroup(options) {
205
+ if (process.platform === "win32" || options.detached !== undefined) return options;
206
+ return { ...options, detached: true };
207
+ }
208
+
178
209
  function globScanOptions(options, root) {
179
210
  if (options === undefined || options === null) return { cwd: root };
180
211
  if (typeof options === "string") return cwdFrom(options, root);