@code-yeongyu/senpi-codemode 2026.10.8 → 2026.10.10-10
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 +178 -0
- package/README.md +212 -14
- package/package.json +6 -5
- package/src/bridge/http-server.ts +9 -1
- package/src/bridge/protocol.ts +12 -3
- package/src/bridge/reserved.ts +2 -0
- package/src/bridges/eval-environment-schemas.ts +48 -0
- package/src/bridges/eval-virtual-schemas.ts +12 -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/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 +68 -4
- package/src/environments/python-environments.ts +85 -0
- package/src/environments/revision-store.ts +38 -7
- package/src/extension/bridge-tool-call.ts +7 -1
- package/src/extension/cell-kernel-tools.ts +28 -0
- package/src/extension/eval-notifier.ts +13 -2
- package/src/extension/kernel-registration.ts +7 -2
- package/src/extension/kernel-replacement.ts +20 -0
- package/src/extension/runtime-factory.ts +53 -3
- package/src/extension/runtime-info.ts +6 -2
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager-proxy.ts +6 -0
- package/src/extension/session-manager.ts +26 -4
- package/src/index.ts +4 -2
- package/src/interpreters/detect.ts +34 -1
- package/src/kernels/AGENTS.md +2 -0
- package/src/kernels/jl/prelude.jl +6 -1
- 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 +48 -39
- package/src/kernels/js/control-frames.ts +24 -0
- package/src/kernels/js/host-entries.ts +62 -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 +21 -5
- 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-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/worker-core.js +53 -8
- package/src/kernels/js/worker-cwd.js +5 -2
- 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 +45 -9
- package/src/kernels/js/worker-shadow-guard.js +2 -6
- package/src/kernels/js/worker-shell-capture.js +5 -0
- package/src/kernels/js/worker-slot.ts +9 -0
- package/src/kernels/js/worker-startup.ts +49 -1
- package/src/kernels/js/worker-webview.js +15 -1
- package/src/kernels/js/workpool.js +5 -2
- package/src/kernels/py/host-cell.ts +8 -31
- package/src/kernels/py/kernel.ts +8 -2
- package/src/kernels/py/prelude.py +145 -11
- package/src/kernels/py/process.ts +4 -0
- package/src/kernels/py/transport.ts +13 -1
- package/src/kernels/rb/workpool.rb +7 -1
- 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/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/cell-source-at-start.ts +18 -0
- package/src/kernels/shared/host-cell.ts +57 -0
- package/src/kernels/shared/subprocess-process.ts +14 -0
- package/src/prompt/eval-prompt.ts +8 -1
- package/src/tool/AGENTS.md +1 -0
- package/src/tool/cell-handler.ts +13 -12
- 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 +6 -0
- package/src/tool/eval-tool.ts +17 -4
- package/src/tool/interrupt-note.ts +4 -5
- package/src/tool/live-headline.ts +60 -0
- package/src/tool/load-cell.ts +88 -0
- package/src/tool/magic-cell-host.ts +123 -0
- package/src/tool/magic-cells.ts +75 -0
- package/src/tool/managed-cell.ts +0 -4
- package/src/tool/render.ts +74 -15
- package/src/tool/run-eval-cell.ts +73 -1
- package/src/tool/runtime-label.ts +2 -1
- package/src/tool/types.ts +50 -1
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { accessSync, constants } from "node:fs";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
7
|
+
import { isKernelToHostMessage, parseBridgeJsonLine, validateBridgeMessage } from "../../bridge/protocol.ts";
|
|
8
|
+
import type { EvalRuntimeInfo } from "../../tool/types.ts";
|
|
9
|
+
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
10
|
+
import { type SubprocessLike, SubprocessProcess, spawnSubprocess } from "../shared/subprocess-process.ts";
|
|
11
|
+
import type { WorkerLike } from "./inline-worker.ts";
|
|
12
|
+
import { JavaScriptWorkerExitedError } from "./worker-host.ts";
|
|
13
|
+
|
|
14
|
+
const PROCESS_CLOSE_GRACE_MS = 2_000;
|
|
15
|
+
|
|
16
|
+
export interface JavaScriptProcessEntryUrlOptions extends CodemodeRuntimeAssetEnvironment {
|
|
17
|
+
readonly localPath?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveJsProcessEntryUrl(options: JavaScriptProcessEntryUrlOptions = {}): URL {
|
|
21
|
+
const localPath = options.localPath ?? join(dirname(fileURLToPath(import.meta.url)), "process-entry.js");
|
|
22
|
+
return pathToFileURL(requireCodemodeRuntimeAsset(localPath, join("kernels", "js", "process-entry.js"), options));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class JavaScriptProcessRuntimeUnavailableError extends Error {
|
|
26
|
+
readonly name = "JavaScriptProcessRuntimeUnavailableError";
|
|
27
|
+
readonly runtime: string;
|
|
28
|
+
readonly searchPath: string;
|
|
29
|
+
|
|
30
|
+
constructor(runtime: string, searchPath: string) {
|
|
31
|
+
super(
|
|
32
|
+
`JavaScript runtime is unavailable: no ${runtime} executable found on PATH${searchPath.length === 0 ? " (empty)" : ""}. Install bun or node, or use isolation.js: "worker".`,
|
|
33
|
+
);
|
|
34
|
+
this.runtime = runtime;
|
|
35
|
+
this.searchPath = searchPath;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface JavaScriptProcessWorkerOptions {
|
|
40
|
+
readonly cwd: string;
|
|
41
|
+
readonly parallelPoolWidth: number;
|
|
42
|
+
readonly searchPath?: string;
|
|
43
|
+
readonly execPath?: string;
|
|
44
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
45
|
+
readonly spawn?: (
|
|
46
|
+
command: string,
|
|
47
|
+
args: readonly string[],
|
|
48
|
+
options: { cwd?: string; env?: NodeJS.ProcessEnv },
|
|
49
|
+
) => SubprocessLike;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface JavaScriptProcessWorker extends WorkerLike {
|
|
53
|
+
readonly pid?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The runtime that runs the kernel child: the host's own runtime (bun when senpi runs on Bun, node otherwise), taken
|
|
58
|
+
* from the host's executable when that is the runtime itself, else found on PATH, the host's runtime first.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveJavaScriptProcessCommand(
|
|
61
|
+
searchPath: string | undefined,
|
|
62
|
+
platform: NodeJS.Platform = process.platform,
|
|
63
|
+
hostRuntime: string = process.versions.bun === undefined ? "node" : "bun",
|
|
64
|
+
execPath: string = process.execPath,
|
|
65
|
+
): string {
|
|
66
|
+
const candidates = hostRuntime === "bun" ? ["bun", "node"] : ["node", "bun"];
|
|
67
|
+
const extensions = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
68
|
+
if (runtimeNameOf(execPath) !== undefined && isExecutable(execPath)) return execPath;
|
|
69
|
+
const pathValue = searchPath ?? process.env.PATH ?? "";
|
|
70
|
+
if (pathValue.trim().length === 0) throw new JavaScriptProcessRuntimeUnavailableError(hostRuntime, "");
|
|
71
|
+
const directories = pathValue.split(platform === "win32" ? ";" : ":").filter((directory) => directory.length > 0);
|
|
72
|
+
for (const name of candidates) {
|
|
73
|
+
for (const directory of directories) {
|
|
74
|
+
for (const extension of extensions) {
|
|
75
|
+
const candidate = join(directory, `${name}${extension}`);
|
|
76
|
+
if (isExecutable(candidate)) return candidate;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw new JavaScriptProcessRuntimeUnavailableError(hostRuntime, pathValue);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The badge identity of the child a process-mode kernel runs: the host's own identity when the child is the host's
|
|
85
|
+
* executable, else the resolved runtime with the version it reports. Undefined when no runtime resolves (the kernel
|
|
86
|
+
* then reports the capability gap on its first cell).
|
|
87
|
+
*/
|
|
88
|
+
export function processRuntimeInfo(searchPath: string | undefined): EvalRuntimeInfo | undefined {
|
|
89
|
+
let command: string;
|
|
90
|
+
try {
|
|
91
|
+
command = resolveJavaScriptProcessCommand(searchPath);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof JavaScriptProcessRuntimeUnavailableError) return undefined;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
const name = runtimeNameOf(command);
|
|
97
|
+
if (command === process.execPath || name === undefined) return undefined;
|
|
98
|
+
const version = execFileSync(command, ["--version"], { encoding: "utf8", timeout: 5_000 }).trim().replace(/^v/, "");
|
|
99
|
+
return { name, version, path: command, isolation: "process" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** "bun" or "node" from an executable path; undefined for anything else (a compiled senpi binary, for one). */
|
|
103
|
+
export function runtimeNameOf(command: string): "bun" | "node" | undefined {
|
|
104
|
+
const name = basename(command).replace(/\.(exe|cmd|bat)$/i, "");
|
|
105
|
+
return name === "bun" || name === "node" ? name : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isExecutable(path: string): boolean {
|
|
109
|
+
try {
|
|
110
|
+
accessSync(path, constants.X_OK);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (
|
|
114
|
+
error instanceof Error &&
|
|
115
|
+
"code" in error &&
|
|
116
|
+
(error.code === "ENOENT" || error.code === "EACCES" || error.code === "ENOTDIR")
|
|
117
|
+
) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function spawnProcessWorker(url: URL, options: JavaScriptProcessWorkerOptions): JavaScriptProcessWorker {
|
|
125
|
+
const command = resolveJavaScriptProcessCommand(options.searchPath, process.platform, undefined, options.execPath);
|
|
126
|
+
const isBun = runtimeNameOf(command) === "bun";
|
|
127
|
+
const args = isBun ? [fileURLToPath(url)] : ["--experimental-strip-types", fileURLToPath(url)];
|
|
128
|
+
const env: NodeJS.ProcessEnv = {
|
|
129
|
+
...process.env,
|
|
130
|
+
...options.env,
|
|
131
|
+
SENPI_CODEMODE_PROCESS_CWD: options.cwd,
|
|
132
|
+
SENPI_CODEMODE_PROCESS_POOL_WIDTH: String(options.parallelPoolWidth),
|
|
133
|
+
};
|
|
134
|
+
const child =
|
|
135
|
+
options.spawn === undefined
|
|
136
|
+
? spawnSubprocess(undefined, { command, args, cwd: options.cwd, env })
|
|
137
|
+
: options.spawn(command, args, { cwd: options.cwd, env });
|
|
138
|
+
const messageHandlers = new Set<(message: KernelToHostMessage) => void>();
|
|
139
|
+
const errorHandlers = new Set<(error: Error) => void>();
|
|
140
|
+
const frameToken = randomBytes(16).toString("hex");
|
|
141
|
+
const framePrefix = `${frameToken} `;
|
|
142
|
+
let crashCause: Error | undefined;
|
|
143
|
+
let stderrTail = "";
|
|
144
|
+
// A line that fails to parse may be the end of a frame a dying child was writing; it is reported only once the
|
|
145
|
+
// channel carries on, so the crash cause or the exit itself, not "invalid frame", settles the cell.
|
|
146
|
+
let pendingInvalidFrame: Error | undefined;
|
|
147
|
+
const reportPendingInvalidFrame = () => {
|
|
148
|
+
const error = pendingInvalidFrame;
|
|
149
|
+
pendingInvalidFrame = undefined;
|
|
150
|
+
if (error !== undefined) for (const handler of [...errorHandlers]) handler(error);
|
|
151
|
+
};
|
|
152
|
+
// Attached directly, not through SubprocessProcess, which stops reading stderr as soon as the exit is observed.
|
|
153
|
+
child.stderr.on("data", (chunk: string | Buffer) => {
|
|
154
|
+
stderrTail = `${stderrTail}${String(chunk)}`.slice(-STDERR_TAIL_CHARS);
|
|
155
|
+
const crash = crashCauseFrom(stderrTail, frameToken);
|
|
156
|
+
if (crash !== undefined) crashCause = crash;
|
|
157
|
+
});
|
|
158
|
+
const subprocess = new SubprocessProcess(child, {
|
|
159
|
+
onLine: (_process, line) => {
|
|
160
|
+
// Only a line carrying this process's token is a frame; any other line is output that
|
|
161
|
+
// reached the channel (a raw fd write, a child process) and is delivered as text.
|
|
162
|
+
if (!line.startsWith(framePrefix)) {
|
|
163
|
+
if (line.trim().length === 0) return;
|
|
164
|
+
reportPendingInvalidFrame();
|
|
165
|
+
for (const handler of [...messageHandlers]) handler({ type: "text", stream: "stdout", data: line });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const parsed = decodeProcessFrame(line.slice(framePrefix.length), frameToken);
|
|
169
|
+
if (!parsed.ok) {
|
|
170
|
+
reportPendingInvalidFrame();
|
|
171
|
+
pendingInvalidFrame = new Error(
|
|
172
|
+
`JavaScript kernel process emitted an invalid frame: ${parsed.error.message}`,
|
|
173
|
+
);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
reportPendingInvalidFrame();
|
|
177
|
+
const message = parsed.message;
|
|
178
|
+
if (!isKernelToHostMessage(message)) return;
|
|
179
|
+
for (const handler of [...messageHandlers]) handler(message);
|
|
180
|
+
},
|
|
181
|
+
// Crash causes are read by the listener below, which stays attached until stderr ends.
|
|
182
|
+
onStderr: () => {},
|
|
183
|
+
onExit: (_process, code, signal) => {
|
|
184
|
+
pendingInvalidFrame = undefined;
|
|
185
|
+
// A dead child's last stderr bytes can still be in flight when its exit is observed: settle once stderr has
|
|
186
|
+
// ended (bounded), so the cause it reported is not replaced by the bare exit.
|
|
187
|
+
void stderrEnded(child.stderr, STDERR_DRAIN_GRACE_MS).then(() => {
|
|
188
|
+
// The child's own report of what killed it, as worker mode reports a thread's error; else the exit itself.
|
|
189
|
+
const error = crashCause ?? new JavaScriptWorkerExitedError(code ?? -1, signal);
|
|
190
|
+
for (const handler of [...errorHandlers]) handler(error);
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
onError: (_process, error) => {
|
|
194
|
+
for (const handler of [...errorHandlers]) handler(error);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
// The token is the first line the entry reads, before any cell can run.
|
|
198
|
+
subprocess.send(`${frameToken}\n`);
|
|
199
|
+
return {
|
|
200
|
+
mode: "process",
|
|
201
|
+
get pid() {
|
|
202
|
+
return child.pid;
|
|
203
|
+
},
|
|
204
|
+
postMessage(message) {
|
|
205
|
+
subprocess.send(`${JSON.stringify(message)}\n`);
|
|
206
|
+
},
|
|
207
|
+
onMessage(handler) {
|
|
208
|
+
messageHandlers.add(handler);
|
|
209
|
+
return () => {
|
|
210
|
+
messageHandlers.delete(handler);
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
onError(handler) {
|
|
214
|
+
errorHandlers.add(handler);
|
|
215
|
+
return () => {
|
|
216
|
+
errorHandlers.delete(handler);
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
async terminate() {
|
|
220
|
+
const exited = await subprocess.shutdown(`${JSON.stringify({ type: "close" })}\n`);
|
|
221
|
+
if (!exited) await subprocess.terminate("SIGKILL", PROCESS_CLOSE_GRACE_MS);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const STDERR_TAIL_CHARS = 16 * 1024;
|
|
227
|
+
const STDERR_DRAIN_GRACE_MS = 1_000;
|
|
228
|
+
|
|
229
|
+
/** Resolves when the stream has ended or closed, or after the grace period, whichever comes first. */
|
|
230
|
+
function stderrEnded(stream: NodeJS.ReadableStream, graceMs: number): Promise<void> {
|
|
231
|
+
if (("readableEnded" in stream && stream.readableEnded === true) || ("closed" in stream && stream.closed === true)) {
|
|
232
|
+
return Promise.resolve();
|
|
233
|
+
}
|
|
234
|
+
return new Promise((resolve) => {
|
|
235
|
+
const done = () => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
stream.removeListener("end", done);
|
|
238
|
+
stream.removeListener("close", done);
|
|
239
|
+
resolve();
|
|
240
|
+
};
|
|
241
|
+
const timer = setTimeout(done, graceMs);
|
|
242
|
+
stream.once("end", done);
|
|
243
|
+
stream.once("close", done);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const BIGINT_MARKER = "\u0000senpi:bigint:";
|
|
247
|
+
const UNDEFINED_MARKER = "\u0000senpi:undefined:";
|
|
248
|
+
const BIGINT_DIGITS = /^-?\d+$/;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A frame's `BigInt` and `undefined` values travel as markers (see process-entry.js `replacer`). They are revived
|
|
252
|
+
* before the frame is checked against the bridge schema: an optional field the child left `undefined` (a cell whose
|
|
253
|
+
* value is `undefined` has no `valueRepr`) arrives as a marker, and checked as-is it would fail the schema.
|
|
254
|
+
*/
|
|
255
|
+
function decodeProcessFrame(line: string, token: string): ReturnType<typeof validateBridgeMessage> {
|
|
256
|
+
const parsed = parseBridgeJsonLine(line);
|
|
257
|
+
if (!parsed.ok) return parsed;
|
|
258
|
+
const value = parsed.value;
|
|
259
|
+
if (typeof value === "object" && value !== null) reviveFrameValues(value, token);
|
|
260
|
+
return validateBridgeMessage(value);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The cause the child reported on stderr just before exiting (see process-entry.js `reportCrash`). */
|
|
264
|
+
function crashCauseFrom(stderr: string, token: string): Error | undefined {
|
|
265
|
+
const prefix = `senpi-kernel-crash ${token} `;
|
|
266
|
+
const line = stderr.split("\n").find((candidate) => candidate.startsWith(prefix));
|
|
267
|
+
if (line === undefined) return undefined;
|
|
268
|
+
try {
|
|
269
|
+
const cause: unknown = JSON.parse(line.slice(prefix.length));
|
|
270
|
+
if (typeof cause !== "object" || cause === null || !("message" in cause) || typeof cause.message !== "string") {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
const error = new Error(cause.message);
|
|
274
|
+
if ("name" in cause && typeof cause.name === "string") error.name = cause.name;
|
|
275
|
+
return error;
|
|
276
|
+
} catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Turns the child's markers back, in place, into the values JSON cannot carry (a BigInt, an `undefined` property or
|
|
283
|
+
* element), so a frame matches what worker mode's structured clone delivers. A marker carries this child's frame
|
|
284
|
+
* token, so a cell's own data that merely looks like one is delivered as it is.
|
|
285
|
+
*/
|
|
286
|
+
function reviveFrameValues(container: object, token: string): void {
|
|
287
|
+
const bigintKey = `${BIGINT_MARKER}${token}`;
|
|
288
|
+
const undefinedKey = `${UNDEFINED_MARKER}${token}`;
|
|
289
|
+
for (const key of Object.keys(container)) {
|
|
290
|
+
const value: unknown = Reflect.get(container, key);
|
|
291
|
+
if (typeof value !== "object" || value === null) continue;
|
|
292
|
+
const keys = Object.keys(value);
|
|
293
|
+
const digits: unknown = keys.length === 1 && keys[0] === bigintKey ? Reflect.get(value, bigintKey) : undefined;
|
|
294
|
+
if (typeof digits === "string" && BIGINT_DIGITS.test(digits)) Reflect.set(container, key, BigInt(digits));
|
|
295
|
+
else if (keys.length === 1 && keys[0] === undefinedKey) Reflect.set(container, key, undefined);
|
|
296
|
+
else reviveFrameValues(value, token);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { cellOfRejection } from "./cell-run-context.js";
|
|
2
|
+
|
|
3
|
+
const STACK_FRAMES = 3;
|
|
4
|
+
|
|
5
|
+
// An unhandled rejection is reported, never fatal: the kernel keeps its variables (the Node REPL and Jupyter norm). The
|
|
6
|
+
// report names the cell that started the work when it is known and lands on the cell running now, or on the next one.
|
|
7
|
+
// A burst is folded into one report plus a count, so a stopped loop cannot flood a cell's output.
|
|
8
|
+
export function createRejectionReports({ activeCell, emitText }) {
|
|
9
|
+
let waiting = null;
|
|
10
|
+
const shownInCell = new WeakMap();
|
|
11
|
+
|
|
12
|
+
function report(reason) {
|
|
13
|
+
const text = describe(reason, cellOfRejection(reason), activeCell());
|
|
14
|
+
const cell = activeCell();
|
|
15
|
+
if (cell === null || cell.released) {
|
|
16
|
+
if (waiting === null) waiting = { text, more: 0 };
|
|
17
|
+
else waiting.more++;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const shown = shownInCell.get(cell) ?? 0;
|
|
21
|
+
shownInCell.set(cell, shown + 1);
|
|
22
|
+
if (shown === 0) emitText(text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
report,
|
|
27
|
+
startCell(cell) {
|
|
28
|
+
if (waiting === null) return;
|
|
29
|
+
emitText(`${waiting.text}${moreLine(waiting.more)}`);
|
|
30
|
+
shownInCell.set(cell, 1);
|
|
31
|
+
waiting = null;
|
|
32
|
+
},
|
|
33
|
+
finishCell(cell) {
|
|
34
|
+
const shown = shownInCell.get(cell) ?? 0;
|
|
35
|
+
if (shown > 1) emitText(moreLine(shown - 1));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function moreLine(more) {
|
|
41
|
+
return more === 0 ? "" : `... and ${more} more unhandled promise ${more === 1 ? "rejection" : "rejections"}\n`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function describe(reason, origin, running) {
|
|
45
|
+
const where =
|
|
46
|
+
origin === undefined
|
|
47
|
+
? "(the cell that started it is unknown)"
|
|
48
|
+
: origin === running && !origin.released
|
|
49
|
+
? "in this cell"
|
|
50
|
+
: `from cell ${origin.cellId}${origin.released ? ", after it was stopped" : ""}`;
|
|
51
|
+
return `Unhandled promise rejection ${where}: ${summary(reason)}\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function summary(reason) {
|
|
55
|
+
if (!(reason instanceof Error)) return String(reason);
|
|
56
|
+
const frames = (reason.stack ?? "")
|
|
57
|
+
.split("\n")
|
|
58
|
+
.filter((line) => line.trim().startsWith("at "))
|
|
59
|
+
.slice(0, STACK_FRAMES);
|
|
60
|
+
return [`${reason.name}: ${reason.message}`, ...frames.map((line) => ` ${line.trim()}`)].join("\n");
|
|
61
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EvalStatusEvent, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
2
|
import type { JavaScriptRunInput } from "./kernel-contract.ts";
|
|
3
|
+
import { crashedResult } from "./worker-host.ts";
|
|
3
4
|
|
|
4
5
|
type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
|
|
5
6
|
|
|
@@ -83,6 +84,12 @@ export class JavaScriptRunQueue {
|
|
|
83
84
|
return Math.max(0, Math.round(finishedAtMs - run.startedAtMs));
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/** Settles `run` as crashed with `error`: the worker died under it. */
|
|
88
|
+
settleCrashed(run: PendingJavaScriptRun, error: Error): void {
|
|
89
|
+
this.releaseActive(run);
|
|
90
|
+
this.settle(run, crashedResult(run.input.cellId, error, this.durationMs(run, performance.now())));
|
|
91
|
+
}
|
|
92
|
+
|
|
86
93
|
releaseActive(run: PendingJavaScriptRun): boolean {
|
|
87
94
|
if (this.#active !== run) return false;
|
|
88
95
|
this.#active = null;
|
|
@@ -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
|
};
|
|
@@ -52,8 +52,11 @@ const FS_PATH_ARGS = {
|
|
|
52
52
|
const CHILD_PROCESS_FILE_ARGS = ["spawn", "spawnSync", "execFile", "execFileSync", "fork"];
|
|
53
53
|
const CHILD_PROCESS_COMMAND_ARGS = ["exec", "execSync"];
|
|
54
54
|
|
|
55
|
-
export function installSessionCwd(cwd) {
|
|
56
|
-
|
|
55
|
+
export function installSessionCwd(cwd, options) {
|
|
56
|
+
// A process-mode kernel child IS the main thread; the worker guard relaxes only for that entry.
|
|
57
|
+
if (isMainThread && options?.allowMainThread !== true) {
|
|
58
|
+
throw new Error("installSessionCwd must only run inside a kernel worker thread");
|
|
59
|
+
}
|
|
57
60
|
const root = resolve(cwd);
|
|
58
61
|
const rootBytes = Buffer.from(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
59
62
|
// fs also takes Buffer/Uint8Array paths; a relative one gets the session root prepended as bytes,
|
|
@@ -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;
|