@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.
- package/CHANGELOG.md +72 -0
- package/package.json +4 -4
- package/src/bridge/memory-protocol.ts +8 -1
- package/src/bridge/protocol.ts +1 -1
- package/src/bridges/eval-environment-schemas.ts +5 -1
- package/src/environments/editable-check.ts +1 -1
- package/src/extension/session-cwd.ts +25 -9
- package/src/extension/session-manager.ts +4 -4
- package/src/kernels/jl/globals.jl +128 -0
- package/src/kernels/jl/prelude.jl +15 -2
- package/src/kernels/jl/runner.jl +35 -143
- package/src/kernels/js/group-signal-notice.js +117 -0
- package/src/kernels/js/inline-worker.ts +2 -0
- package/src/kernels/js/local-module-loader.ts +8 -0
- package/src/kernels/js/process-tree-host.ts +4 -8
- package/src/kernels/js/process-tree.d.ts +11 -0
- package/src/kernels/js/process-tree.js +55 -10
- package/src/kernels/js/windows-tree-kill-host.ts +117 -0
- package/src/kernels/js/windows-tree-kill.d.ts +19 -0
- package/src/kernels/js/windows-tree-kill.js +100 -0
- package/src/kernels/js/worker-cwd.js +41 -10
- package/src/kernels/js/worker-runtime.js +23 -4
- package/src/kernels/js/worker-shell-capture.js +4 -0
- package/src/kernels/js/worker-slot.ts +2 -3
- package/src/kernels/py/prelude.py +36 -25
- package/src/kernels/py/transport.ts +4 -0
- package/src/kernels/rb/runner.rb +9 -19
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
- package/src/kernels/shared/bun-ffi.ts +24 -0
- package/src/kernels/shared/kernel-memory-host.ts +30 -7
- package/src/kernels/shared/process-cpu.ts +7 -3
- package/src/kernels/shared/process-group-cpu.ts +6 -3
- package/src/kernels/shared/subprocess-kernel.ts +29 -3
- package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
- package/src/kernels/shared/subprocess-process.ts +20 -12
- package/src/kernels/shared/subprocess-queue.ts +1 -0
- package/src/tool/eval-tool.ts +2 -1
- package/src/tool/render-agent.ts +110 -0
- package/src/tool/render-blocks.ts +252 -0
- package/src/tool/render-call.ts +130 -0
- package/src/tool/render-cell.ts +165 -0
- package/src/tool/render-detail.ts +152 -0
- package/src/tool/render-live.ts +288 -0
- package/src/tool/render-result.ts +202 -0
- package/src/tool/render-status.ts +166 -0
- package/src/tool/render-tail.ts +139 -0
- package/src/tool/render.ts +3 -1128
- package/src/tool/run-eval-cell.ts +7 -12
- 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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|