@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.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 +190 -0
- package/README.md +161 -11
- package/package.json +6 -5
- package/src/bridge/memory-protocol.ts +8 -1
- package/src/bridge/protocol.ts +9 -4
- package/src/bridge/reserved.ts +2 -0
- package/src/bridges/eval-environment-schemas.ts +52 -0
- package/src/bridges/eval-virtual-schemas.ts +11 -4
- package/src/bridges/reserved-dispatch.ts +13 -1
- package/src/config/executable-settings.json +5 -0
- package/src/config/project-trust.ts +51 -0
- package/src/config/settings-overrides.ts +2 -0
- package/src/environments/editable-check.ts +1 -1
- package/src/environments/js-environments.ts +208 -0
- package/src/environments/js-installer.ts +268 -0
- package/src/environments/js-revision-files.ts +91 -0
- package/src/environments/no-links.ts +132 -0
- package/src/environments/packages-install.ts +98 -0
- package/src/environments/project-resolves.ts +54 -0
- package/src/environments/py-environment.ts +7 -3
- package/src/environments/py-installer.ts +5 -1
- package/src/environments/python-environments.ts +1 -1
- package/src/environments/revision-store.ts +20 -4
- package/src/extension/bridge-tool-call.ts +5 -1
- package/src/extension/eval-notifier.ts +13 -2
- package/src/extension/kernel-registration.ts +7 -2
- package/src/extension/runtime-factory.ts +51 -13
- package/src/extension/runtime-info.ts +6 -2
- package/src/extension/session-cwd.ts +25 -9
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager.ts +20 -6
- package/src/index.ts +3 -2
- package/src/interpreters/detect.ts +34 -1
- package/src/kernels/AGENTS.md +2 -0
- package/src/kernels/jl/runner.jl +11 -22
- package/src/kernels/js/cell-dispatch.ts +44 -0
- package/src/kernels/js/cell-ownership.js +268 -0
- package/src/kernels/js/cell-run-context.js +89 -0
- package/src/kernels/js/context-manager.ts +46 -51
- package/src/kernels/js/control-frames.ts +24 -0
- package/src/kernels/js/group-signal-notice.js +117 -0
- package/src/kernels/js/host-entries.ts +62 -0
- package/src/kernels/js/inline-worker.ts +2 -0
- package/src/kernels/js/kernel-contract.ts +16 -1
- package/src/kernels/js/kernel-memory-bridge.ts +1 -1
- package/src/kernels/js/kernel-memory.ts +79 -0
- package/src/kernels/js/kernel-tools-registry.js +1 -0
- package/src/kernels/js/local-module-loader.ts +25 -6
- package/src/kernels/js/process-drain-scan.d.ts +8 -0
- package/src/kernels/js/process-drain-scan.js +28 -0
- package/src/kernels/js/process-entry.js +466 -0
- package/src/kernels/js/process-memory-host.ts +83 -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/process-worker.ts +298 -0
- package/src/kernels/js/rejection-reports.js +61 -0
- package/src/kernels/js/run-queue.ts +7 -0
- 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-core.js +53 -8
- package/src/kernels/js/worker-cwd.js +46 -12
- package/src/kernels/js/worker-host.ts +9 -2
- package/src/kernels/js/worker-indirect-eval.js +37 -13
- package/src/kernels/js/worker-package-resolve.js +153 -0
- package/src/kernels/js/worker-require.d.ts +19 -0
- package/src/kernels/js/worker-require.js +52 -0
- package/src/kernels/js/worker-runtime.js +66 -13
- package/src/kernels/js/worker-shadow-guard.js +2 -6
- package/src/kernels/js/worker-shell-capture.js +9 -0
- package/src/kernels/js/worker-slot.ts +8 -0
- package/src/kernels/js/worker-startup.ts +49 -1
- package/src/kernels/js/worker-webview.js +15 -1
- package/src/kernels/py/host-cell.ts +8 -31
- package/src/kernels/py/prelude.py +69 -27
- package/src/kernels/py/process.ts +4 -0
- package/src/kernels/py/transport.ts +4 -0
- package/src/kernels/rb/runner.rb +9 -19
- package/src/kernels/sandbox/sandbox-cell.ts +141 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
- package/src/kernels/shared/bun-ffi.ts +24 -0
- package/src/kernels/shared/host-cell.ts +57 -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 +24 -2
- package/src/kernels/shared/subprocess-queue.ts +1 -0
- package/src/prompt/eval-prompt.ts +8 -1
- package/src/tool/AGENTS.md +1 -0
- package/src/tool/cell-handler.ts +12 -11
- package/src/tool/cell-reserved-dispatch.ts +1 -0
- package/src/tool/cell-runtime.ts +4 -2
- package/src/tool/detached-cell-contract.ts +3 -0
- package/src/tool/detached-cell-manager.ts +4 -8
- package/src/tool/detached-cell-notification.ts +11 -63
- package/src/tool/detached-cell-snapshot.ts +17 -0
- package/src/tool/detached-eval-result.ts +13 -7
- package/src/tool/detached-notification-queue.ts +1 -2
- package/src/tool/eval-request.ts +23 -1
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/eval-tool.ts +19 -5
- package/src/tool/interrupt-note.ts +4 -5
- package/src/tool/live-headline.ts +60 -0
- package/src/tool/load-cell.ts +18 -3
- package/src/tool/magic-cell-host.ts +60 -2
- package/src/tool/magic-cells.ts +12 -2
- package/src/tool/managed-cell.ts +0 -4
- 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 -1069
- package/src/tool/run-eval-cell.ts +53 -16
- package/src/tool/runtime-label.ts +2 -1
- package/src/tool/types.ts +34 -3
|
@@ -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
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { installCellOwnership } from "./cell-ownership.js";
|
|
2
|
+
import { releaseCell, releasedCellError, runInCell } from "./cell-run-context.js";
|
|
1
3
|
import { kernelToolCallContext } from "./kernel-tools-context.js";
|
|
2
4
|
import { kernelToolError } from "./kernel-tools-errors.js";
|
|
3
5
|
import { createKernelToolPump } from "./kernel-tools-pump.js";
|
|
4
6
|
import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
|
|
5
7
|
import { installSessionCwd } from "./worker-cwd.js";
|
|
8
|
+
import { installPackageResolver } from "./worker-package-resolve.js";
|
|
6
9
|
import { createHeapProbe } from "./worker-heap.js";
|
|
10
|
+
import { createRejectionReports } from "./rejection-reports.js";
|
|
7
11
|
import { createWorkerMemory } from "./worker-memory.js";
|
|
8
12
|
import { JsWorkerRuntime } from "./worker-runtime.js";
|
|
9
13
|
import { installKernelWebView } from "./worker-webview.js";
|
|
@@ -28,10 +32,17 @@ const SESSION_ENVIRONMENT_KEYS = [
|
|
|
28
32
|
];
|
|
29
33
|
|
|
30
34
|
export function createWorkerCore(transport, options) {
|
|
35
|
+
const restoreOwnership = installCellOwnership();
|
|
31
36
|
let runtime = null;
|
|
32
37
|
let memory = null;
|
|
33
38
|
let heapProbe = null;
|
|
34
39
|
let activeCell = null;
|
|
40
|
+
const rejections = createRejectionReports({
|
|
41
|
+
activeCell: () => activeCell,
|
|
42
|
+
emitText: (data) => emit({ type: "text", stream: "stderr", data }),
|
|
43
|
+
});
|
|
44
|
+
const reportRejection = (reason) => rejections.report(reason);
|
|
45
|
+
process.on("unhandledRejection", reportRejection);
|
|
35
46
|
const pendingTools = new Map();
|
|
36
47
|
const pendingWebViewPorts = new Map();
|
|
37
48
|
const nestedInvokes = new Map();
|
|
@@ -45,23 +56,38 @@ export function createWorkerCore(transport, options) {
|
|
|
45
56
|
transport.send(message);
|
|
46
57
|
}
|
|
47
58
|
|
|
59
|
+
function stopProblem(cell, what, error) {
|
|
60
|
+
const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
61
|
+
emit({ type: "text", stream: "stderr", data: `Stopping cell ${cell.cellId}: ${what}: ${detail}\n` });
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
async function runCell(message) {
|
|
49
65
|
if (!runtime) {
|
|
50
66
|
emit({ type: "result", cellId: message.cellId, ok: false, error: { message: "JS runtime not initialized" }, durationMs: 0 });
|
|
51
67
|
return;
|
|
52
68
|
}
|
|
53
69
|
const startedAtMs = performance.now();
|
|
54
|
-
|
|
70
|
+
const release = Promise.withResolvers();
|
|
71
|
+
const cell = { cellId: message.cellId, interruption: null, released: false, release: release.reject };
|
|
72
|
+
activeCell = cell;
|
|
73
|
+
rejections.startCell(cell);
|
|
55
74
|
try {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
const run = runInCell(cell, () =>
|
|
76
|
+
runtime.run(message.code, message.cellId, {
|
|
77
|
+
emit: (event) => {
|
|
78
|
+
if (releasedCellError() === undefined) emit(event);
|
|
79
|
+
},
|
|
80
|
+
callTool: async (toolName, args) => await callTool(toolName, args),
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
const value = await Promise.race([run, release.promise]);
|
|
84
|
+
rejections.finishCell(cell);
|
|
60
85
|
emit({ type: "result", cellId: message.cellId, ok: true, valueRepr: valueRepr(value), durationMs: durationMs(startedAtMs), ...memoryReport() });
|
|
61
86
|
} catch (error) {
|
|
87
|
+
rejections.finishCell(cell);
|
|
62
88
|
emit({ type: "result", cellId: message.cellId, ok: false, error: bridgeError(error), durationMs: durationMs(startedAtMs), ...memoryReport() });
|
|
63
89
|
} finally {
|
|
64
|
-
activeCell = null;
|
|
90
|
+
if (activeCell === cell) activeCell = null;
|
|
65
91
|
}
|
|
66
92
|
}
|
|
67
93
|
|
|
@@ -70,6 +96,8 @@ export function createWorkerCore(transport, options) {
|
|
|
70
96
|
}
|
|
71
97
|
|
|
72
98
|
async function callTool(toolName, args) {
|
|
99
|
+
const released = releasedCellError();
|
|
100
|
+
if (released !== undefined) throw released;
|
|
73
101
|
const nested = kernelToolCallContext.getStore();
|
|
74
102
|
if (!nested && activeCell?.interruption) throw activeCell.interruption;
|
|
75
103
|
if (nested?.signal.aborted) throw nested.signal.reason;
|
|
@@ -104,6 +132,17 @@ export function createWorkerCore(transport, options) {
|
|
|
104
132
|
}
|
|
105
133
|
kernelTools.abortAll(kernelToolError("kernel_tool_stale", interruption.message));
|
|
106
134
|
runtime.interrupt();
|
|
135
|
+
// A free event loop with no Bun.$ wait can let the cell go and keep the VM. A Bun.$ wait keeps the
|
|
136
|
+
// restart (#2453): the shell cannot be cancelled, so only retiring the worker ends it.
|
|
137
|
+
if (runtime.shellWaitActive) return;
|
|
138
|
+
const cell = activeCell;
|
|
139
|
+
for (const error of releaseCell(cell)) stopProblem(cell, "a resource it opened would not close", error);
|
|
140
|
+
// The result settles after the cell's children are gone, so the next cell never overlaps them. A failed release
|
|
141
|
+
// is reported as such, never left to surface as an unhandled rejection blamed on the stopped cell.
|
|
142
|
+
void runtime
|
|
143
|
+
.release()
|
|
144
|
+
.catch((error) => stopProblem(cell, "releasing its work failed", error))
|
|
145
|
+
.finally(() => cell.release(interruption));
|
|
107
146
|
}
|
|
108
147
|
|
|
109
148
|
function acknowledgeInterrupt() {
|
|
@@ -127,8 +166,9 @@ export function createWorkerCore(transport, options) {
|
|
|
127
166
|
}
|
|
128
167
|
if (message.type === "init") {
|
|
129
168
|
applySessionEnvironment(message.sessionEnv);
|
|
130
|
-
installSessionCwd(options.cwd);
|
|
169
|
+
installSessionCwd(options.cwd, options.cwdInstallOptions);
|
|
131
170
|
installKernelWebView(requestWebViewPort);
|
|
171
|
+
installPackageResolver();
|
|
132
172
|
runtime = new JsWorkerRuntime({
|
|
133
173
|
cwd: options.cwd,
|
|
134
174
|
parallelPoolWidth: options.parallelPoolWidth,
|
|
@@ -137,12 +177,15 @@ export function createWorkerCore(transport, options) {
|
|
|
137
177
|
kernelGeneration: message.kernelGeneration ?? 1,
|
|
138
178
|
hostToolNames: message.hostToolNames ?? [],
|
|
139
179
|
foreignLanguageNames: message.foreignLanguageNames ?? [],
|
|
180
|
+
kernelToolsDisabled: message.kernelToolsDisabled === true,
|
|
140
181
|
onChildEvent: (event) => emit({ type: "status", event: { op: CHILD_LIFECYCLE_OP, ...event } }),
|
|
141
182
|
onShellWaitChange: () => {
|
|
142
183
|
if (activeCell?.interruption) acknowledgeInterrupt();
|
|
143
184
|
},
|
|
144
185
|
});
|
|
145
|
-
|
|
186
|
+
// A process-mode kernel measures its memory host-side as a footprint; the in-heap
|
|
187
|
+
// worker reading would contradict it, so the child keeps memory collection off.
|
|
188
|
+
if (message.memory && !options.processModeMemory) {
|
|
146
189
|
memory = createWorkerMemory(message.memory, (report) => emit({ type: "status", event: { op: MEMORY_COLLECTED_OP, ...report } }));
|
|
147
190
|
memory.captureBaseline();
|
|
148
191
|
}
|
|
@@ -181,6 +224,8 @@ export function createWorkerCore(transport, options) {
|
|
|
181
224
|
return {
|
|
182
225
|
dispose() {
|
|
183
226
|
unsubscribe();
|
|
227
|
+
restoreOwnership();
|
|
228
|
+
process.off("unhandledRejection", reportRejection);
|
|
184
229
|
globalThis.__senpi_restore_console__?.();
|
|
185
230
|
},
|
|
186
231
|
};
|
|
@@ -51,9 +51,13 @@ 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
|
-
export function installSessionCwd(cwd) {
|
|
56
|
-
|
|
56
|
+
export function installSessionCwd(cwd, options) {
|
|
57
|
+
// A process-mode kernel child IS the main thread; the worker guard relaxes only for that entry.
|
|
58
|
+
if (isMainThread && options?.allowMainThread !== true) {
|
|
59
|
+
throw new Error("installSessionCwd must only run inside a kernel worker thread");
|
|
60
|
+
}
|
|
57
61
|
const root = resolve(cwd);
|
|
58
62
|
const rootBytes = Buffer.from(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
59
63
|
// fs also takes Buffer/Uint8Array paths; a relative one gets the session root prepended as bytes,
|
|
@@ -92,7 +96,9 @@ function patchFs(fs, promises, at, root) {
|
|
|
92
96
|
function patchChildProcess(childProcess, at, root) {
|
|
93
97
|
for (const name of CHILD_PROCESS_FILE_ARGS) {
|
|
94
98
|
replace(childProcess, name, (fn) => {
|
|
95
|
-
|
|
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));
|
|
96
102
|
return name === "fork" ? resolvingArgs(withCwd, [0], at) : withCwd;
|
|
97
103
|
});
|
|
98
104
|
}
|
|
@@ -104,7 +110,7 @@ function patchBun(bun, at, root) {
|
|
|
104
110
|
replace(bun, "file", (fn) => resolvingArgs(fn, [0], at));
|
|
105
111
|
replace(bun, "write", (fn) => resolvingArgs(fn, [0], at));
|
|
106
112
|
replace(bun, "spawn", (fn) => withBunSpawnCwd(fn, root));
|
|
107
|
-
replace(bun, "spawnSync", (fn) => withBunSpawnCwd(fn, root));
|
|
113
|
+
replace(bun, "spawnSync", (fn) => withBunSpawnCwd(fn, root, false));
|
|
108
114
|
if (typeof bun.$?.cwd === "function") bun.$.cwd(root);
|
|
109
115
|
const glob = bun.Glob?.prototype;
|
|
110
116
|
if (glob) {
|
|
@@ -146,32 +152,60 @@ function fileOptionsIndex(args) {
|
|
|
146
152
|
return Array.isArray(args[1]) || (args[1] == null && args.length > 2) ? 2 : 1;
|
|
147
153
|
}
|
|
148
154
|
|
|
149
|
-
function withOptionsCwd(fn, optionsIndex, root) {
|
|
155
|
+
function withOptionsCwd(fn, optionsIndex, root, inOwnGroup = false) {
|
|
156
|
+
const group = inOwnGroup ? ownGroup : (options) => options;
|
|
150
157
|
return function withSessionCwd(...args) {
|
|
151
158
|
const index = optionsIndex(args);
|
|
152
159
|
const options = args[index];
|
|
153
|
-
if (options === undefined || options === null) args[index] = { cwd: root };
|
|
154
|
-
else if (typeof options === "function") args.splice(index, 0, { cwd: root });
|
|
155
|
-
else if (typeof options === "object") args[index] = { ...options, cwd: cwdFrom(options.cwd, root) };
|
|
156
|
-
|
|
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
|
+
}
|
|
157
175
|
};
|
|
158
176
|
}
|
|
159
177
|
|
|
160
|
-
function withBunSpawnCwd(fn, root) {
|
|
178
|
+
function withBunSpawnCwd(fn, root, inOwnGroup = true) {
|
|
179
|
+
const group = inOwnGroup ? ownGroup : (options) => options;
|
|
161
180
|
return function spawnInSessionCwd(...args) {
|
|
162
181
|
const [first, second] = args;
|
|
163
182
|
if (Array.isArray(first)) {
|
|
164
183
|
const options = second === undefined || second === null ? {} : second;
|
|
165
184
|
if (typeof options !== "object") return fn.apply(this, args);
|
|
166
|
-
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));
|
|
167
186
|
}
|
|
168
187
|
if (first !== null && typeof first === "object") {
|
|
169
|
-
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));
|
|
170
189
|
}
|
|
171
190
|
return fn.apply(this, args);
|
|
172
191
|
};
|
|
173
192
|
}
|
|
174
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
|
+
|
|
175
209
|
function globScanOptions(options, root) {
|
|
176
210
|
if (options === undefined || options === null) return { cwd: root };
|
|
177
211
|
if (typeof options === "string") return cwdFrom(options, root);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EventEmitter } from "node:events";
|
|
2
2
|
import { Worker } from "node:worker_threads";
|
|
3
3
|
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
4
|
+
import { describeExit } from "../shared/kernel-death.ts";
|
|
4
5
|
import type { WorkerLike } from "./inline-worker.ts";
|
|
5
6
|
import type { JavaScriptKernelMode } from "./kernel-contract.ts";
|
|
6
7
|
|
|
@@ -22,10 +23,16 @@ export class WorkerStartupCancelledError extends Error {
|
|
|
22
23
|
export class JavaScriptWorkerExitedError extends Error {
|
|
23
24
|
readonly name = "JavaScriptWorkerExitedError";
|
|
24
25
|
readonly exitCode: number;
|
|
26
|
+
readonly exitSignal: string | null;
|
|
25
27
|
|
|
26
|
-
constructor(exitCode: number) {
|
|
27
|
-
super(
|
|
28
|
+
constructor(exitCode: number, exitSignal: string | null = null) {
|
|
29
|
+
super(
|
|
30
|
+
exitSignal === null
|
|
31
|
+
? `JavaScript worker exited with code ${exitCode}`
|
|
32
|
+
: `JavaScript worker exited with ${describeExit(null, exitSignal)} (code ${exitCode})`,
|
|
33
|
+
);
|
|
28
34
|
this.exitCode = exitCode;
|
|
35
|
+
this.exitSignal = exitSignal;
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isKernelGlobal } from "./worker-shadow-guard.js";
|
|
2
|
+
|
|
3
|
+
// A cell may declare a name the kernel or the platform already defines (`const log = []`, `const fetch = ...`). The
|
|
4
|
+
// binding lives in a per-kernel user scope that later cells read through `with`, so it persists for the user's cells
|
|
5
|
+
// while the kernel, its prelude and imported libraries keep the original global. `delete <name>` in a cell restores it.
|
|
6
|
+
const USER_SCOPE_KEY = Symbol.for("senpi.kernel.userScope");
|
|
7
|
+
const USER_SCOPE = "__senpiUserScope";
|
|
8
|
+
// The persistence rewrite itself writes through `globalThis`, so that one name cannot be shadowed.
|
|
9
|
+
const UNSHADOWABLE = new Set(["globalThis"]);
|
|
10
|
+
let shadowedNames = null;
|
|
2
11
|
|
|
3
12
|
export function indirectEval(source, filename) {
|
|
4
13
|
const withPragma = filename ? `${source}\n//# sourceURL=${filename}` : source;
|
|
@@ -11,10 +20,31 @@ export async function awaitMaybePromise(value) {
|
|
|
11
20
|
return await value;
|
|
12
21
|
}
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
/** Wraps a cell for persistent evaluation; `shadowed` receives the kernel-global names the cell declares. */
|
|
24
|
+
export function wrapUserCode(code, shadowed = []) {
|
|
25
|
+
shadowedNames = shadowed;
|
|
26
|
+
let persistentCode;
|
|
27
|
+
try {
|
|
28
|
+
persistentCode = persistTopLevelDeclarations(code);
|
|
29
|
+
} finally {
|
|
30
|
+
shadowedNames = null;
|
|
31
|
+
}
|
|
32
|
+
const body = scanTopLevelStatements(persistentCode).hasTopLevelReturn ? persistentCode : captureLastExpression(persistentCode);
|
|
33
|
+
const scope = globalThis[USER_SCOPE_KEY];
|
|
34
|
+
if (shadowed.length === 0 && (scope === undefined || Object.keys(scope).length === 0)) return `(async () => {\n${body}\n})()`;
|
|
35
|
+
globalThis[USER_SCOPE_KEY] ??= Object.create(null);
|
|
36
|
+
return `(async (${USER_SCOPE}) => { with (${USER_SCOPE}) { return await (async () => {\n${body}\n})(); } })(globalThis[Symbol.for("senpi.kernel.userScope")])`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function persistTarget(name) {
|
|
40
|
+
if (UNSHADOWABLE.has(name)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`eval cell declares top-level \`${name}\`, which the JS kernel needs to keep cell variables between cells. Rename the binding (for example \`${name}Local\`).`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (!isKernelGlobal(name)) return `globalThis[${JSON.stringify(name)}]`;
|
|
46
|
+
if (shadowedNames !== null && !shadowedNames.includes(name)) shadowedNames.push(name);
|
|
47
|
+
return `${USER_SCOPE}[${JSON.stringify(name)}]`;
|
|
18
48
|
}
|
|
19
49
|
|
|
20
50
|
const IDENTIFIER_START_RE = /[$_\p{ID_Start}]/u;
|
|
@@ -267,14 +297,8 @@ function rewriteDeclaration(code, declarationStart, start, end, keyword) {
|
|
|
267
297
|
const bindings = [];
|
|
268
298
|
collectPatternNames(pattern, bindings);
|
|
269
299
|
if (bindings.length === 0) return undefined;
|
|
270
|
-
const shadowed = findShadowedGlobalName(bindings);
|
|
271
|
-
if (shadowed !== undefined) {
|
|
272
|
-
throw new Error(
|
|
273
|
-
`eval cell declares top-level \`${shadowed}\`, but a global with that name already exists in the JS kernel. Persisting it would replace that global for every later cell. Rename the binding (for example \`${shadowed}Local\`), or assign globalThis.${shadowed} explicitly if replacing it is truly intended.`,
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
300
|
if (preserveDeclaration) {
|
|
277
|
-
for (const name of bindings) assignments.push(
|
|
301
|
+
for (const name of bindings) assignments.push(`${persistTarget(name)} = ${name};`);
|
|
278
302
|
continue;
|
|
279
303
|
}
|
|
280
304
|
const target = rewriteBindingPattern(pattern);
|
|
@@ -296,7 +320,7 @@ function rewriteBindingPattern(source) {
|
|
|
296
320
|
const target = rewriteBindingPattern(pattern.slice(0, defaultStart));
|
|
297
321
|
return target === undefined ? undefined : `${target} = ${pattern.slice(defaultStart + 1).trim()}`;
|
|
298
322
|
}
|
|
299
|
-
if (IDENTIFIER_RE.test(pattern)) return
|
|
323
|
+
if (IDENTIFIER_RE.test(pattern)) return persistTarget(pattern);
|
|
300
324
|
if (pattern.startsWith("{") && pattern.endsWith("}")) {
|
|
301
325
|
const properties = splitPatternElements(pattern.slice(1, -1)).map((property) => rewriteObjectBinding(property));
|
|
302
326
|
if (properties.some((property) => property === undefined)) return undefined;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { isBuiltin } from "node:module";
|
|
3
|
+
import { dirname, join, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
// A cell's bare import runs `import()` from this worker's own module, so the runtime resolves it from Senpi's install
|
|
7
|
+
// tree, not the session's project or its managed package revision. This resolver walks `node_modules` from each root
|
|
8
|
+
// the cell names, in order, and resolves the package entry the way ESM does (the `exports` field with import
|
|
9
|
+
// conditions), so ESM-only packages load and the first root that has the package wins.
|
|
10
|
+
const RESOLVE_PACKAGE = Symbol.for("senpi.kernel.resolvePackage");
|
|
11
|
+
const CONDITIONS = typeof globalThis.Bun === "object" ? ["bun", "node", "import", "default"] : ["node", "import", "default"];
|
|
12
|
+
|
|
13
|
+
function isFile(path) {
|
|
14
|
+
try {
|
|
15
|
+
return statSync(path).isFile();
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isDirectory(path) {
|
|
22
|
+
try {
|
|
23
|
+
return statSync(path).isDirectory();
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function splitSpecifier(specifier) {
|
|
30
|
+
const parts = specifier.split("/");
|
|
31
|
+
const nameParts = specifier.startsWith("@") ? 2 : 1;
|
|
32
|
+
if (parts.length < nameParts || parts.slice(0, nameParts).some((part) => part === "" || part === "." || part === ".."))
|
|
33
|
+
return undefined;
|
|
34
|
+
const rest = parts.slice(nameParts).join("/");
|
|
35
|
+
return { name: parts.slice(0, nameParts).join("/"), subpath: rest === "" ? "." : `./${rest}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function conditionalTarget(target) {
|
|
39
|
+
if (typeof target === "string") return target;
|
|
40
|
+
if (Array.isArray(target)) {
|
|
41
|
+
for (const candidate of target) {
|
|
42
|
+
const resolved = conditionalTarget(candidate);
|
|
43
|
+
if (resolved !== undefined) return resolved;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (target === null || typeof target !== "object") return undefined;
|
|
48
|
+
for (const [condition, value] of Object.entries(target)) {
|
|
49
|
+
if (condition !== "default" && !CONDITIONS.includes(condition)) continue;
|
|
50
|
+
const resolved = conditionalTarget(value);
|
|
51
|
+
if (resolved !== undefined) return resolved;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function exportsTarget(exportsField, subpath) {
|
|
57
|
+
const subpathKeyed =
|
|
58
|
+
exportsField !== null &&
|
|
59
|
+
typeof exportsField === "object" &&
|
|
60
|
+
!Array.isArray(exportsField) &&
|
|
61
|
+
Object.keys(exportsField).some((key) => key.startsWith("."));
|
|
62
|
+
if (!subpathKeyed) return subpath === "." ? conditionalTarget(exportsField) : undefined;
|
|
63
|
+
if (Object.hasOwn(exportsField, subpath)) return conditionalTarget(exportsField[subpath]);
|
|
64
|
+
let best;
|
|
65
|
+
for (const key of Object.keys(exportsField)) {
|
|
66
|
+
const star = key.indexOf("*");
|
|
67
|
+
if (star < 0 || key.includes("*", star + 1)) continue;
|
|
68
|
+
const prefix = key.slice(0, star);
|
|
69
|
+
const suffix = key.slice(star + 1);
|
|
70
|
+
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix) || subpath.length < prefix.length + suffix.length)
|
|
71
|
+
continue;
|
|
72
|
+
if (best === undefined || prefix.length > best.prefix.length) {
|
|
73
|
+
best = { key, prefix, match: subpath.slice(prefix.length, subpath.length - suffix.length) };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (best === undefined || hasInvalidSegment(best.match)) return undefined;
|
|
77
|
+
const target = conditionalTarget(exportsField[best.key]);
|
|
78
|
+
return target === undefined ? undefined : target.replaceAll("*", best.match);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Node refuses a pattern match or target with "." / ".." / "node_modules" segments (ERR_INVALID_MODULE_SPECIFIER).
|
|
82
|
+
function hasInvalidSegment(path) {
|
|
83
|
+
return path.split(/[\\/]/).some((segment) => segment === "." || segment === ".." || segment === "node_modules");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function legacyFile(path) {
|
|
87
|
+
for (const candidate of [path, `${path}.js`, `${path}.mjs`, `${path}.cjs`, join(path, "index.js")]) {
|
|
88
|
+
if (isFile(candidate)) return candidate;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function packageEntry(packageDir, subpath, specifier) {
|
|
94
|
+
let manifest;
|
|
95
|
+
try {
|
|
96
|
+
manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
|
97
|
+
} catch {
|
|
98
|
+
return legacyFile(join(packageDir, subpath === "." ? "index.js" : subpath));
|
|
99
|
+
}
|
|
100
|
+
if (manifest.exports !== undefined && manifest.exports !== null) {
|
|
101
|
+
const target = exportsTarget(manifest.exports, subpath);
|
|
102
|
+
if (typeof target !== "string" || !target.startsWith("./") || hasInvalidSegment(target.slice(2))) {
|
|
103
|
+
const error = new Error(`Package subpath '${subpath}' is not exported for import by '${specifier}'`);
|
|
104
|
+
error.code = "ERR_PACKAGE_PATH_NOT_EXPORTED";
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
return join(packageDir, target);
|
|
108
|
+
}
|
|
109
|
+
if (subpath !== ".") return legacyFile(join(packageDir, subpath));
|
|
110
|
+
const moduleEntry = typeof globalThis.Bun === "object" && typeof manifest.module === "string" ? manifest.module : undefined;
|
|
111
|
+
const main = moduleEntry ?? (typeof manifest.main === "string" ? manifest.main : "index.js");
|
|
112
|
+
return legacyFile(join(packageDir, main));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function findPackageDir(name, rootUrl) {
|
|
116
|
+
let directory = fileURLToPath(rootUrl);
|
|
117
|
+
for (;;) {
|
|
118
|
+
const candidate = join(directory, "node_modules", name);
|
|
119
|
+
if (isDirectory(candidate)) return candidate;
|
|
120
|
+
const parent = dirname(directory);
|
|
121
|
+
if (parent === directory) return undefined;
|
|
122
|
+
directory = parent;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The file URL the bare `specifier` resolves to from the first of `rootUrls` whose `node_modules` chain has its
|
|
128
|
+
* package, or undefined when it names a builtin or no root has the package (the caller then imports it natively).
|
|
129
|
+
*/
|
|
130
|
+
export function resolvePackage(specifier, rootUrls) {
|
|
131
|
+
if (specifier.includes(":") || isBuiltin(specifier)) return undefined;
|
|
132
|
+
const parts = splitSpecifier(specifier);
|
|
133
|
+
if (parts === undefined) return undefined;
|
|
134
|
+
for (const rootUrl of rootUrls) {
|
|
135
|
+
if (typeof rootUrl !== "string") continue;
|
|
136
|
+
const packageDir = findPackageDir(parts.name, rootUrl);
|
|
137
|
+
if (packageDir === undefined) continue;
|
|
138
|
+
const entry = packageEntry(packageDir, parts.subpath, specifier);
|
|
139
|
+
if (entry === undefined) continue;
|
|
140
|
+
let real = entry;
|
|
141
|
+
try {
|
|
142
|
+
real = realpathSync(entry);
|
|
143
|
+
} catch {
|
|
144
|
+
// A missing target fails in the import itself, with the runtime's own error.
|
|
145
|
+
}
|
|
146
|
+
return pathToFileURL(real.endsWith(sep) ? real.slice(0, -1) : real).href;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function installPackageResolver() {
|
|
152
|
+
globalThis[RESOLVE_PACKAGE] = resolvePackage;
|
|
153
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface CellRequireContext {
|
|
2
|
+
readonly cwdUrl: string;
|
|
3
|
+
readonly packageRootUrl?: string | null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface CellResolve {
|
|
7
|
+
(specifier: string, options?: { readonly paths?: readonly string[] }): string;
|
|
8
|
+
paths(specifier: string): string[] | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CellRequire {
|
|
12
|
+
(specifier: string): unknown;
|
|
13
|
+
readonly resolve: CellResolve;
|
|
14
|
+
readonly cache: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createCellRequire(context: () => CellRequireContext): CellRequire;
|
|
18
|
+
|
|
19
|
+
export { createRequire } from "node:module";
|