@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +212 -14
  3. package/package.json +6 -5
  4. package/src/bridge/http-server.ts +9 -1
  5. package/src/bridge/protocol.ts +12 -3
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/bridges/eval-environment-schemas.ts +48 -0
  8. package/src/bridges/eval-virtual-schemas.ts +12 -4
  9. package/src/bridges/reserved-dispatch.ts +13 -1
  10. package/src/config/executable-settings.json +5 -0
  11. package/src/config/project-trust.ts +51 -0
  12. package/src/config/settings-overrides.ts +2 -0
  13. package/src/environments/js-environments.ts +208 -0
  14. package/src/environments/js-installer.ts +268 -0
  15. package/src/environments/js-revision-files.ts +91 -0
  16. package/src/environments/no-links.ts +132 -0
  17. package/src/environments/packages-install.ts +98 -0
  18. package/src/environments/project-resolves.ts +54 -0
  19. package/src/environments/py-environment.ts +7 -3
  20. package/src/environments/py-installer.ts +68 -4
  21. package/src/environments/python-environments.ts +85 -0
  22. package/src/environments/revision-store.ts +38 -7
  23. package/src/extension/bridge-tool-call.ts +7 -1
  24. package/src/extension/cell-kernel-tools.ts +28 -0
  25. package/src/extension/eval-notifier.ts +13 -2
  26. package/src/extension/kernel-registration.ts +7 -2
  27. package/src/extension/kernel-replacement.ts +20 -0
  28. package/src/extension/runtime-factory.ts +53 -3
  29. package/src/extension/runtime-info.ts +6 -2
  30. package/src/extension/session-manager-contract.ts +3 -0
  31. package/src/extension/session-manager-proxy.ts +6 -0
  32. package/src/extension/session-manager.ts +26 -4
  33. package/src/index.ts +4 -2
  34. package/src/interpreters/detect.ts +34 -1
  35. package/src/kernels/AGENTS.md +2 -0
  36. package/src/kernels/jl/prelude.jl +6 -1
  37. package/src/kernels/js/cell-dispatch.ts +44 -0
  38. package/src/kernels/js/cell-ownership.js +268 -0
  39. package/src/kernels/js/cell-run-context.js +89 -0
  40. package/src/kernels/js/context-manager.ts +48 -39
  41. package/src/kernels/js/control-frames.ts +24 -0
  42. package/src/kernels/js/host-entries.ts +62 -0
  43. package/src/kernels/js/kernel-contract.ts +16 -1
  44. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  45. package/src/kernels/js/kernel-memory.ts +79 -0
  46. package/src/kernels/js/kernel-tools-registry.js +1 -0
  47. package/src/kernels/js/local-module-loader.ts +21 -5
  48. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  49. package/src/kernels/js/process-drain-scan.js +28 -0
  50. package/src/kernels/js/process-entry.js +466 -0
  51. package/src/kernels/js/process-memory-host.ts +83 -0
  52. package/src/kernels/js/process-worker.ts +298 -0
  53. package/src/kernels/js/rejection-reports.js +61 -0
  54. package/src/kernels/js/run-queue.ts +7 -0
  55. package/src/kernels/js/worker-core.js +53 -8
  56. package/src/kernels/js/worker-cwd.js +5 -2
  57. package/src/kernels/js/worker-host.ts +9 -2
  58. package/src/kernels/js/worker-indirect-eval.js +37 -13
  59. package/src/kernels/js/worker-package-resolve.js +153 -0
  60. package/src/kernels/js/worker-require.d.ts +19 -0
  61. package/src/kernels/js/worker-require.js +52 -0
  62. package/src/kernels/js/worker-runtime.js +45 -9
  63. package/src/kernels/js/worker-shadow-guard.js +2 -6
  64. package/src/kernels/js/worker-shell-capture.js +5 -0
  65. package/src/kernels/js/worker-slot.ts +9 -0
  66. package/src/kernels/js/worker-startup.ts +49 -1
  67. package/src/kernels/js/worker-webview.js +15 -1
  68. package/src/kernels/js/workpool.js +5 -2
  69. package/src/kernels/py/host-cell.ts +8 -31
  70. package/src/kernels/py/kernel.ts +8 -2
  71. package/src/kernels/py/prelude.py +145 -11
  72. package/src/kernels/py/process.ts +4 -0
  73. package/src/kernels/py/transport.ts +13 -1
  74. package/src/kernels/rb/workpool.rb +7 -1
  75. package/src/kernels/sandbox/sandbox-cell.ts +141 -0
  76. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  77. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  78. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  79. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  80. package/src/kernels/shared/cell-source-at-start.ts +18 -0
  81. package/src/kernels/shared/host-cell.ts +57 -0
  82. package/src/kernels/shared/subprocess-process.ts +14 -0
  83. package/src/prompt/eval-prompt.ts +8 -1
  84. package/src/tool/AGENTS.md +1 -0
  85. package/src/tool/cell-handler.ts +13 -12
  86. package/src/tool/cell-reserved-dispatch.ts +1 -0
  87. package/src/tool/cell-runtime.ts +4 -2
  88. package/src/tool/detached-cell-contract.ts +3 -0
  89. package/src/tool/detached-cell-manager.ts +4 -8
  90. package/src/tool/detached-cell-notification.ts +11 -63
  91. package/src/tool/detached-cell-snapshot.ts +17 -0
  92. package/src/tool/detached-eval-result.ts +13 -7
  93. package/src/tool/detached-notification-queue.ts +1 -2
  94. package/src/tool/eval-request.ts +23 -1
  95. package/src/tool/eval-tool-options.ts +6 -0
  96. package/src/tool/eval-tool.ts +17 -4
  97. package/src/tool/interrupt-note.ts +4 -5
  98. package/src/tool/live-headline.ts +60 -0
  99. package/src/tool/load-cell.ts +88 -0
  100. package/src/tool/magic-cell-host.ts +123 -0
  101. package/src/tool/magic-cells.ts +75 -0
  102. package/src/tool/managed-cell.ts +0 -4
  103. package/src/tool/render.ts +74 -15
  104. package/src/tool/run-eval-cell.ts +73 -1
  105. package/src/tool/runtime-label.ts +2 -1
  106. 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
- activeCell = { cellId: message.cellId, interruption: null };
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 value = await runtime.run(message.code, message.cellId, {
57
- emit,
58
- callTool: async (toolName, args) => await callTool(toolName, args),
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
- if (message.memory) {
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
- if (isMainThread) throw new Error("installSessionCwd must only run inside a kernel worker thread");
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(`JavaScript worker exited with code ${exitCode}`);
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 { findShadowedGlobalName } from "./worker-shadow-guard.js";
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
- export function wrapUserCode(code) {
15
- const persistentCode = persistTopLevelDeclarations(code);
16
- if (scanTopLevelStatements(persistentCode).hasTopLevelReturn) return `(async () => {\n${persistentCode}\n})()`;
17
- return `(async () => {\n${captureLastExpression(persistentCode)}\n})()`;
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(`globalThis[${JSON.stringify(name)}] = ${name};`);
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 `globalThis[${JSON.stringify(pattern)}]`;
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;