@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10-3

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 (65) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +131 -5
  3. package/package.json +6 -5
  4. package/src/bridge/protocol.ts +8 -3
  5. package/src/config/executable-settings.json +5 -0
  6. package/src/config/project-trust.ts +51 -0
  7. package/src/config/settings-overrides.ts +2 -0
  8. package/src/environments/js-environments.ts +208 -0
  9. package/src/environments/js-installer.ts +268 -0
  10. package/src/environments/js-revision-files.ts +91 -0
  11. package/src/environments/no-links.ts +132 -0
  12. package/src/environments/project-resolves.ts +54 -0
  13. package/src/environments/revision-store.ts +20 -4
  14. package/src/extension/kernel-registration.ts +7 -2
  15. package/src/extension/runtime-factory.ts +40 -3
  16. package/src/extension/runtime-info.ts +6 -2
  17. package/src/extension/session-manager.ts +16 -2
  18. package/src/index.ts +1 -0
  19. package/src/interpreters/detect.ts +34 -1
  20. package/src/kernels/AGENTS.md +2 -0
  21. package/src/kernels/js/cell-dispatch.ts +44 -0
  22. package/src/kernels/js/context-manager.ts +46 -51
  23. package/src/kernels/js/control-frames.ts +24 -0
  24. package/src/kernels/js/host-entries.ts +62 -0
  25. package/src/kernels/js/kernel-contract.ts +16 -1
  26. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  27. package/src/kernels/js/kernel-memory.ts +79 -0
  28. package/src/kernels/js/kernel-tools-registry.js +1 -0
  29. package/src/kernels/js/local-module-loader.ts +17 -6
  30. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  31. package/src/kernels/js/process-drain-scan.js +28 -0
  32. package/src/kernels/js/process-entry.js +462 -0
  33. package/src/kernels/js/process-memory-host.ts +83 -0
  34. package/src/kernels/js/process-worker.ts +298 -0
  35. package/src/kernels/js/run-queue.ts +7 -0
  36. package/src/kernels/js/worker-core.js +7 -2
  37. package/src/kernels/js/worker-cwd.js +5 -2
  38. package/src/kernels/js/worker-host.ts +9 -2
  39. package/src/kernels/js/worker-package-resolve.js +153 -0
  40. package/src/kernels/js/worker-runtime.js +1 -0
  41. package/src/kernels/js/worker-slot.ts +9 -0
  42. package/src/kernels/js/worker-startup.ts +49 -1
  43. package/src/kernels/js/worker-webview.js +8 -0
  44. package/src/kernels/py/host-cell.ts +8 -31
  45. package/src/kernels/py/prelude.py +10 -2
  46. package/src/kernels/py/process.ts +4 -0
  47. package/src/kernels/sandbox/sandbox-cell.ts +116 -0
  48. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  49. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  50. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  51. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  52. package/src/kernels/shared/host-cell.ts +57 -0
  53. package/src/kernels/shared/subprocess-process.ts +14 -0
  54. package/src/prompt/eval-prompt.ts +8 -1
  55. package/src/tool/AGENTS.md +1 -0
  56. package/src/tool/cell-handler.ts +9 -11
  57. package/src/tool/eval-request.ts +23 -1
  58. package/src/tool/eval-tool-options.ts +3 -0
  59. package/src/tool/eval-tool.ts +17 -4
  60. package/src/tool/load-cell.ts +18 -3
  61. package/src/tool/magic-cell-host.ts +60 -2
  62. package/src/tool/magic-cells.ts +12 -2
  63. package/src/tool/run-eval-cell.ts +30 -3
  64. package/src/tool/runtime-label.ts +2 -1
  65. package/src/tool/types.ts +23 -1
@@ -0,0 +1,79 @@
1
+ import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import { restartNotice } from "../shared/kernel-death.ts";
3
+ import type { ResultMessage } from "./kernel-contract.ts";
4
+ import { type JavaScriptMemoryReading, KernelMemoryBridge } from "./kernel-memory-bridge.ts";
5
+ import type { JavaScriptKernelOptions } from "./local-module-loader.ts";
6
+ import { JavaScriptProcessMemoryHost } from "./process-memory-host.ts";
7
+
8
+ /**
9
+ * The kernel's one view of memory: the worker's heap bridge, or in process mode the child process's footprint, plus
10
+ * the restart notice a crash leaves for the next result.
11
+ */
12
+ export class JavaScriptKernelMemory {
13
+ readonly #bridge: KernelMemoryBridge;
14
+ readonly #process: JavaScriptProcessMemoryHost | null;
15
+ readonly #pid: () => number | undefined;
16
+ readonly #processMode: boolean;
17
+ #restartNotice: string | null = null;
18
+
19
+ constructor(options: JavaScriptKernelOptions, pid: () => number | undefined) {
20
+ const processMode = options.isolation === "process";
21
+ this.#bridge = new KernelMemoryBridge(processMode ? undefined : options.memory, options.onMemoryCollected);
22
+ this.#process =
23
+ processMode && options.processMemory !== undefined
24
+ ? new JavaScriptProcessMemoryHost(options.processMemory.thresholds, options.processMemory.readFootprint)
25
+ : null;
26
+ this.#pid = pid;
27
+ this.#processMode = processMode;
28
+ }
29
+
30
+ get lastLiveBytes(): number | undefined {
31
+ return this.#process === null ? this.#bridge.lastLiveBytes : this.#process.lastLiveBytes(this.#pid);
32
+ }
33
+
34
+ /** A reading between cells. The worker is asked only through `post`, given when a worker is ready to answer. */
35
+ async query(
36
+ post: ((message: HostToKernelMessage) => void) | undefined,
37
+ ): Promise<JavaScriptMemoryReading | undefined> {
38
+ if (this.#process !== null) return this.#process.query(this.#pid);
39
+ return post === undefined ? undefined : await this.#bridge.query(post);
40
+ }
41
+
42
+ consume(message: KernelToHostMessage): boolean {
43
+ return this.#bridge.consume(message);
44
+ }
45
+
46
+ settled(message: ResultMessage): ResultMessage {
47
+ if (this.#process !== null) return this.#process.settled(message, this.#pid);
48
+ const settled = this.#bridge.settled(message);
49
+ if (this.#restartNotice === null) return settled;
50
+ const notice = this.#restartNotice;
51
+ this.#restartNotice = null;
52
+ return { ...settled, notice, kernelState: "restarted" };
53
+ }
54
+
55
+ /** Whether the kernel should restart for memory now; only once nothing is running or queued (`idle`). */
56
+ claimRecycle(idle: boolean): boolean {
57
+ const worker = this.#bridge.claimRecycle(idle);
58
+ return this.#process?.claimRecycle(idle) === true || worker;
59
+ }
60
+
61
+ /** The worker was retired: pending readings fail. */
62
+ workerLost(error: Error): void {
63
+ this.#bridge.workerLost(error);
64
+ }
65
+
66
+ /**
67
+ * The kernel crashed: pending readings fail. In process mode the next result also says the kernel restarted (a
68
+ * crashed child loses every global); worker mode reports its crash as it always has, with no notice.
69
+ */
70
+ crashed(error: Error): void {
71
+ this.#bridge.workerLost(error);
72
+ if (this.#process !== null) {
73
+ this.#process.workerLost();
74
+ this.#process.markRestarted(error);
75
+ return;
76
+ }
77
+ if (this.#processMode) this.#restartNotice = restartNotice("js", error.message);
78
+ }
79
+ }
@@ -45,6 +45,7 @@ export function createKernelToolRegistry(options = {}) {
45
45
  return generation;
46
46
  },
47
47
  define(fn, metadata) {
48
+ if (options.disabled === true) throw kernelToolError("tools_unavailable", "kernel tools are turned off for this project (kernelTools.enabled is false)");
48
49
  assertJs();
49
50
  const parsed = parseToolFunction(fn);
50
51
  const resolved = resolveToolMetadata(metadata, parsed.params);
@@ -98,7 +98,13 @@ function loaderPrelude(context: RuntimeModuleContext): string {
98
98
  " const urlModule = await import('node:url');",
99
99
  " target = urlModule.pathToFileURL(specifier).href;",
100
100
  " }",
101
- " return options === undefined ? import(target) : import(target, options);",
101
+ " const load = (resolved) => (options === undefined ? import(resolved) : import(resolved, options));",
102
+ " const resolvePackage = globalThis[Symbol.for('senpi.kernel.resolvePackage')];",
103
+ " if (target === specifier && typeof resolvePackage === 'function') {",
104
+ " const resolved = resolvePackage(specifier, [context.cwdUrl, context.packageRootUrl]);",
105
+ " if (resolved !== undefined) return await load(resolved);",
106
+ " }",
107
+ " return await load(target);",
102
108
  "};",
103
109
  ].join("\n");
104
110
  }
@@ -115,18 +121,23 @@ function contributionPrelude(plan: KernelPreludePlan): string {
115
121
 
116
122
  export class LocalModuleLoader {
117
123
  readonly #prelude: string;
124
+ readonly #cwdUrl: string;
118
125
  readonly #contributions = new KernelPreludeTracker();
119
126
 
120
127
  constructor(options: LocalModuleLoaderOptions) {
121
128
  this.#prelude = loaderPrelude(runtimeContext(options));
129
+ this.#cwdUrl = directoryUrl(options.cwd);
122
130
  }
123
131
 
124
- prepareCell(code: string, contributions: readonly KernelPreludeContribution[] = [], sourceFile?: string): string {
132
+ prepareCell(
133
+ code: string,
134
+ contributions: readonly KernelPreludeContribution[] = [],
135
+ sourceFile?: string,
136
+ packageRoot?: string,
137
+ ): string {
125
138
  // A %load cell resolves its relative imports from its own file's directory for that cell only.
126
- const base =
127
- sourceFile === undefined
128
- ? ""
129
- : `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(directoryUrl(dirname(sourceFile)))} };`;
139
+ const cwdUrl = sourceFile === undefined ? this.#cwdUrl : directoryUrl(dirname(sourceFile));
140
+ const base = `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(cwdUrl)}, packageRootUrl: ${JSON.stringify(packageRoot === undefined ? null : directoryUrl(packageRoot))} };`;
130
141
  const prelude = `${this.#prelude}${base}\n${contributionPrelude(this.#contributions.plan(contributions))}`;
131
142
  return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code), ...(sourceFile === undefined ? {} : { sourceFile }) })}`;
132
143
  }
@@ -0,0 +1,8 @@
1
+ export type DrainPart = { readonly text: string } | { readonly key: string };
2
+
3
+ export interface DrainScan {
4
+ readonly parts: DrainPart[];
5
+ readonly carry: string;
6
+ }
7
+
8
+ export function scanDrainText(carry: string, chunk: string, marker: string): DrainScan;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Splits text read from the process kernel's fd 1 pipe into the output it carries and the result keys its drain
3
+ * markers name, in order. A read boundary can cut a marker, so a trailing prefix of one is handed back as `carry` for
4
+ * the next read rather than sent as output.
5
+ *
6
+ * Self-contained on purpose: its source also runs inside the pipe reader thread (process-entry.js `DRAINER`).
7
+ */
8
+ export function scanDrainText(carry, chunk, marker) {
9
+ let text = carry + chunk;
10
+ const parts = [];
11
+ for (;;) {
12
+ const at = text.indexOf(marker);
13
+ if (at === -1) break;
14
+ const close = text.indexOf("\u0000", at + marker.length);
15
+ if (close === -1) break;
16
+ if (at > 0) parts.push({ text: text.slice(0, at) });
17
+ parts.push({ key: text.slice(at + marker.length, close) });
18
+ text = text.slice(close + 1);
19
+ }
20
+ let nextCarry = "";
21
+ const partial = text.lastIndexOf("\u0000");
22
+ if (partial !== -1 && marker.startsWith(text.slice(partial, partial + marker.length))) {
23
+ nextCarry = text.slice(partial);
24
+ text = text.slice(0, partial);
25
+ }
26
+ if (text.length > 0) parts.push({ text });
27
+ return { parts, carry: nextCarry };
28
+ }
@@ -0,0 +1,462 @@
1
+ // JavaScript kernel process-mode entry: worker-core over the framed subprocess transport.
2
+ //
3
+ // Trust model: process mode isolates CRASHES (memory, segfaults, out-of-memory), not hostile code. A cell runs in this
4
+ // process and can reach everything it holds, exactly like worker mode; hostile code belongs in isolate: true sandbox
5
+ // cells. The frame token below guards against ACCIDENTAL corruption of the channel (a cell's raw fd writes, a child
6
+ // process's output), not against a cell that sets out to forge frames.
7
+ //
8
+ // Channel: the host writes a random token as the first line of fd 0 and frames after it. Where the runtime allows it
9
+ // (Bun on macOS/Linux) the frame reader moves to a private duplicate of fd 0 and fd 0 becomes /dev/null, and fd 1 is
10
+ // re-pointed at a blocking pipe whose bytes a reader thread writes out as text frames; frames go out on a duplicate
11
+ // of the original fd 1 taken before the re-point, under one lock shared by both threads. Every frame is written as
12
+ // "<token> <json>", and the host parses only lines that carry it.
13
+ //
14
+ // Lifetime: the kernel exits the moment its control channel reaches end-of-file (the host closed it, exited or was
15
+ // killed). A watchdog thread also checks the parent pid and kills this process when the host is gone, so a cell
16
+ // that never yields (a busy loop, a blocking call) cannot keep the kernel alive past its host.
17
+ import { closeSync, createReadStream, openSync, writeSync } from "node:fs";
18
+ import { createRequire } from "node:module";
19
+ import { createInterface } from "node:readline";
20
+ import { Readable } from "node:stream";
21
+ import { Worker } from "node:worker_threads";
22
+ import { scanDrainText } from "./process-drain-scan.js";
23
+
24
+ // Captured before any cell runs, so a cell that later replaces these never sees a frame being built.
25
+ const stringify = JSON.stringify;
26
+ const writeBytes = writeSync;
27
+ const exit = process.exit.bind(process);
28
+ const fromString = (text) => Buffer.from(text, "utf8");
29
+
30
+ // The host refuses a frame over 10 MiB; text is split well below it (a char can escape to 6 bytes of JSON).
31
+ const TEXT_CHUNK_CHARS = 1 << 20;
32
+ const FRAME_LIMIT_BYTES = 9 * 1024 * 1024;
33
+ // Values JSON cannot carry are sent as markers the host turns back into the value, matching worker mode. Each marker
34
+ // carries the frame token, so a cell's own data can never be taken for one.
35
+ const BIGINT_MARKER = "\u0000senpi:bigint:";
36
+ const UNDEFINED_MARKER = "\u0000senpi:undefined:";
37
+ const DRAIN_MARKER = "\u0000senpi-drain:";
38
+
39
+ // Set when libc could not be loaded; shown once, as stderr output of the first cell, so the user sees why.
40
+ let libcUnavailableNotice;
41
+
42
+ function libcSymbols() {
43
+ if (process.platform === "win32") return null;
44
+ const libcPath = { darwin: "/usr/lib/libSystem.B.dylib", linux: "libc.so.6" }[process.platform];
45
+ if (libcPath === undefined) return null;
46
+ let ffi;
47
+ try {
48
+ ffi = createRequire(import.meta.url)("bun:ffi");
49
+ } catch {
50
+ return null;
51
+ }
52
+ try {
53
+ return loadLibcSymbols(ffi, libcPath);
54
+ } catch (error) {
55
+ // dlopen can fail where libc is not where this expects it (a minimal image, an unusual libc). The kernel then
56
+ // runs as it does on Windows, framing on fd 1 without the raw-output pipe, instead of dying before it can say why.
57
+ libcUnavailableNotice = `[senpi-codemode] process kernel: libc could not be loaded (${error instanceof Error ? error.message : String(error)}); raw fd 1 output from child processes is not captured\n`;
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function loadLibcSymbols(ffi, libcPath) {
63
+ const symbols = ffi.dlopen(libcPath, {
64
+ dup: { args: [ffi.FFIType.i32], returns: ffi.FFIType.i32 },
65
+ dup2: { args: [ffi.FFIType.i32, ffi.FFIType.i32], returns: ffi.FFIType.i32 },
66
+ fcntl: { args: [ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.i32], returns: ffi.FFIType.i32 },
67
+ pipe: { args: [ffi.FFIType.ptr], returns: ffi.FFIType.i32 },
68
+ ...(process.platform === "linux"
69
+ ? {
70
+ prctl: {
71
+ args: [ffi.FFIType.i32, ffi.FFIType.u64, ffi.FFIType.u64, ffi.FFIType.u64, ffi.FFIType.u64],
72
+ returns: ffi.FFIType.i32,
73
+ },
74
+ }
75
+ : {}),
76
+ }).symbols;
77
+ if (!(process.platform === "darwin" && process.arch === "arm64")) return symbols;
78
+ // fcntl is variadic. bun:ffi makes only non-variadic calls, and on Apple arm64 a variadic argument is read from the
79
+ // stack, not a register: declared with six padding arguments, the value is the ninth argument, which is the first
80
+ // stack slot. Elsewhere the three-argument form passes it where fcntl reads it.
81
+ const stackArgs = ffi.dlopen(libcPath, {
82
+ fcntl: {
83
+ args: [
84
+ ffi.FFIType.i32,
85
+ ffi.FFIType.i32,
86
+ ffi.FFIType.i64,
87
+ ffi.FFIType.i64,
88
+ ffi.FFIType.i64,
89
+ ffi.FFIType.i64,
90
+ ffi.FFIType.i64,
91
+ ffi.FFIType.i64,
92
+ ffi.FFIType.i64,
93
+ ],
94
+ returns: ffi.FFIType.i32,
95
+ },
96
+ }).symbols;
97
+ return { ...symbols, fcntlStack: stackArgs.fcntl };
98
+ }
99
+
100
+ const libc = libcSymbols();
101
+
102
+ // On Linux a crash is reported through the kernel's core-dump handler first; where that is a pipe to
103
+ // systemd-coredump or apport, the dying child is held until the dump is consumed, which can freeze its cell for
104
+ // about 30 s. Marking this process non-dumpable skips the dump, so the crash (signal included) is reported at once.
105
+ // exec resets the flag, so a cell's own subprocesses dump as usual. SENPI_KERNEL_CORE_DUMPS=1 keeps dumps.
106
+ const PR_SET_DUMPABLE = 4;
107
+ if (libc?.prctl !== undefined && process.env.SENPI_KERNEL_CORE_DUMPS !== "1") libc.prctl(PR_SET_DUMPABLE, 0, 0, 0, 0);
108
+
109
+ /** The control channel's fd: a private duplicate of fd 0 where possible, so a cell reading fd 0 reads /dev/null. */
110
+ function privateControlFd() {
111
+ if (libc === null) return 0;
112
+ const duplicate = libc.dup(0);
113
+ if (duplicate === -1) return 0;
114
+ const devNull = openSync("/dev/null", "r");
115
+ // dup2 returns the new descriptor (0) on success and -1 on failure.
116
+ if (libc.dup2(devNull, 0) === -1) {
117
+ closeSync(devNull);
118
+ return duplicate;
119
+ }
120
+ closeSync(devNull);
121
+ return duplicate;
122
+ }
123
+
124
+ const F_GETFL = 3;
125
+ const F_SETFL = 4;
126
+ const O_NONBLOCK = process.platform === "darwin" ? 0x4 : 0x800;
127
+ const variadicOnStack = process.platform === "darwin" && process.arch === "arm64";
128
+
129
+ function setFileStatusFlags(fd, flags) {
130
+ if (libc === null) return;
131
+ if (variadicOnStack) libc.fcntlStack(fd, F_SETFL, 0, 0, 0, 0, 0, 0, flags);
132
+ else libc.fcntl(fd, F_SETFL, flags);
133
+ }
134
+
135
+ /**
136
+ * Keeps fd 1 blocking. O_NONBLOCK belongs to the pipe's open file description, which every child inheriting fd 1
137
+ * shares: bun sets it the first time a process touches process.stdout and never clears it, and a SIGKILLed node child
138
+ * cannot restore it. A writer that does not retry EAGAIN (`cat`, writeSync) would then lose output whenever it outran
139
+ * the reader thread, so this runs before every cell and before and after every child process a cell starts.
140
+ */
141
+ function ensureBlocking() {
142
+ if (libc === null || textPipe === null) return;
143
+ const flags = libc.fcntl(1, F_GETFL, 0);
144
+ if (flags !== -1 && (flags & O_NONBLOCK) !== 0) setFileStatusFlags(1, flags & ~O_NONBLOCK);
145
+ }
146
+
147
+ /** Points fd 1 at a pipe this process reads, so raw fd 1 writes become text frames; null when unavailable. */
148
+ function repointStdoutToPipe() {
149
+ if (libc === null) return null;
150
+ const fds = new Int32Array(2);
151
+ if (libc.pipe(fds) !== 0) return null;
152
+ // dup2 returns the new descriptor (1) on success and -1 on failure.
153
+ if (libc.dup2(fds[1], 1) === -1) return null;
154
+ // Bun makes fd 1 non-blocking when process.stdout is first touched: set it up now, then keep it blocking.
155
+ void process.stdout;
156
+ return { readFd: fds[0] };
157
+ }
158
+
159
+ const controlFd = privateControlFd();
160
+ // Frames go out on a duplicate of the original fd 1, taken before the re-point. A duplicate rather than a reopen of
161
+ // /dev/fd/1: on Linux the spawn's stdout is a socket, which cannot be opened by path.
162
+ const duplicatedFd = libc === null ? -1 : libc.dup(1);
163
+ const frameFd = duplicatedFd === -1 ? 1 : duplicatedFd;
164
+ const textPipe = duplicatedFd === -1 ? null : repointStdoutToPipe();
165
+ ensureBlocking();
166
+ keepChildProcessesBlocking();
167
+
168
+ /** Wraps the ways a cell starts a child process, so fd 1 is blocking again before each starts and after it ends. */
169
+ function keepChildProcessesBlocking() {
170
+ if (textPipe === null) return;
171
+ const childProcess = createRequire(import.meta.url)("node:child_process");
172
+ for (const name of ["execSync", "execFileSync", "spawnSync"]) {
173
+ const original = childProcess[name];
174
+ childProcess[name] = function (...args) {
175
+ ensureBlocking();
176
+ try {
177
+ return original.apply(this, args);
178
+ } finally {
179
+ ensureBlocking();
180
+ }
181
+ };
182
+ }
183
+ for (const name of ["spawn", "exec", "execFile", "fork"]) {
184
+ const original = childProcess[name];
185
+ childProcess[name] = function (...args) {
186
+ ensureBlocking();
187
+ const child = original.apply(this, args);
188
+ child.once("exit", ensureBlocking);
189
+ return child;
190
+ };
191
+ }
192
+ if (globalThis.Bun === undefined) return;
193
+ const bun = globalThis.Bun;
194
+ const spawnSync = bun.spawnSync;
195
+ const spawn = bun.spawn;
196
+ // Bun.spawn and Bun.spawnSync are writable but not configurable, so they are replaced by assignment (as the
197
+ // per-cell shell capture also does); a redefinition would throw.
198
+ bun.spawnSync = (...args) => {
199
+ ensureBlocking();
200
+ try {
201
+ return spawnSync.apply(bun, args);
202
+ } finally {
203
+ ensureBlocking();
204
+ }
205
+ };
206
+ bun.spawn = (...args) => {
207
+ ensureBlocking();
208
+ const child = spawn.apply(bun, args);
209
+ child.exited.then(ensureBlocking, ensureBlocking);
210
+ return child;
211
+ };
212
+ }
213
+ const pause = new Int32Array(new SharedArrayBuffer(4));
214
+ // Frames are written by this thread and by the fd 1 reader thread; one lock keeps every frame line whole.
215
+ const frameLock = new Int32Array(new SharedArrayBuffer(4));
216
+
217
+ /** Writes all of `bytes`, waiting out a full pipe; a closed channel means the host is gone, so the kernel exits. */
218
+ function writeAll(fd, bytes) {
219
+ let offset = 0;
220
+ while (offset < bytes.length) {
221
+ try {
222
+ offset += writeBytes(fd, bytes, offset, bytes.length - offset);
223
+ } catch (error) {
224
+ if (error?.code === "EAGAIN") {
225
+ Atomics.wait(pause, 0, 0, 2);
226
+ continue;
227
+ }
228
+ exit(0);
229
+ }
230
+ }
231
+ }
232
+
233
+ // Without the re-point, cell output shares the channel: a leading newline ends any unterminated output line, so the
234
+ // frame always starts its own line.
235
+ const framePrefix = textPipe === null ? "\n" : "";
236
+ let frameToken;
237
+
238
+ function replacer(_key, value) {
239
+ if (typeof value === "bigint") return { [`${BIGINT_MARKER}${frameToken}`]: value.toString() };
240
+ if (value === undefined) return { [`${UNDEFINED_MARKER}${frameToken}`]: 1 };
241
+ return value;
242
+ }
243
+
244
+ function writeFrame(message) {
245
+ const bytes = fromString(stringify(message, replacer));
246
+ if (bytes.length <= FRAME_LIMIT_BYTES) {
247
+ const line = Buffer.concat([fromString(`${framePrefix}${frameToken} `), bytes, fromString("\n")]);
248
+ while (Atomics.compareExchange(frameLock, 0, 0, 1) !== 0) Atomics.wait(frameLock, 0, 1, 5);
249
+ try {
250
+ writeAll(frameFd, line);
251
+ } finally {
252
+ Atomics.store(frameLock, 0, 0);
253
+ Atomics.notify(frameLock, 0, 1);
254
+ }
255
+ return;
256
+ }
257
+ const tooLarge = (what) => ({ name: "RangeError", message: `${what} is too large to send (${bytes.length} bytes)` });
258
+ if (message.type === "result") {
259
+ const error = tooLarge("cell result");
260
+ writeFrame({ type: "result", cellId: message.cellId, ok: false, error, durationMs: message.durationMs ?? 0 });
261
+ return;
262
+ }
263
+ if (message.type === "tool-call") {
264
+ // The host never sees the call, so the cell gets the refusal as the call's own failure instead of waiting on it.
265
+ const reply = { type: "tool-reply", callId: message.callId, ok: false, error: tooLarge(`the ${message.toolName} call`) };
266
+ deliver(reply);
267
+ return;
268
+ }
269
+ const data = `[a ${message.type} message of ${bytes.length} bytes was too large to send and was dropped]\n`;
270
+ writeFrame({ type: "text", stream: "stderr", data });
271
+ }
272
+
273
+ function sendFrame(message) {
274
+ if (message.type !== "text" || typeof message.data !== "string" || message.data.length <= TEXT_CHUNK_CHARS) {
275
+ writeFrame(message);
276
+ return;
277
+ }
278
+ for (let start = 0; start < message.data.length; ) {
279
+ let end = Math.min(start + TEXT_CHUNK_CHARS, message.data.length);
280
+ // Never split a surrogate pair.
281
+ if (end < message.data.length && /[\uD800-\uDBFF]/.test(message.data[end - 1])) end -= 1;
282
+ writeFrame({ ...message, data: message.data.slice(start, end) });
283
+ start = end;
284
+ }
285
+ }
286
+
287
+ // A cell that reads stdin sees an already-ended stream instead of the control channel.
288
+ const ended = new Readable({
289
+ read() {
290
+ this.push(null);
291
+ },
292
+ });
293
+ Object.defineProperty(process, "stdin", { value: ended, configurable: true });
294
+ if (globalThis.Bun !== undefined) {
295
+ try {
296
+ Object.defineProperty(globalThis.Bun, "stdin", { get: () => ended, configurable: true });
297
+ } catch {
298
+ // Bun.stdin is already non-configurable on this runtime
299
+ }
300
+ }
301
+
302
+ const frameReader = createReadStream("", { fd: controlFd });
303
+ const frameLines = createInterface({ input: frameReader });
304
+ // End-of-file on the control channel means the host is gone (closed, exited or killed): never outlive it.
305
+ frameReader.once("end", () => exit(0));
306
+ frameReader.once("close", () => exit(0));
307
+ // The same, from a thread a running cell cannot block: once the parent pid changes the host is gone.
308
+ const WATCHDOG = `const { workerData } = require("node:worker_threads");
309
+ setInterval(() => {
310
+ if (process.ppid !== workerData.parent) process.kill(process.pid, "SIGKILL");
311
+ }, 100);`;
312
+ new Worker(WATCHDOG, { eval: true, workerData: { parent: process.ppid } }).unref();
313
+
314
+ let lineHandler;
315
+ const bufferedLines = [];
316
+ function deliver(message) {
317
+ if (message.type === "run") ensureBlocking();
318
+ if (message.type === "run" && libcUnavailableNotice !== undefined) {
319
+ sendFrame({ type: "text", stream: "stderr", data: libcUnavailableNotice });
320
+ libcUnavailableNotice = undefined;
321
+ }
322
+ if (lineHandler === undefined) bufferedLines.push(message);
323
+ else lineHandler(message);
324
+ }
325
+ const tokenReceived = new Promise((resolve) => {
326
+ frameLines.on("line", (line) => {
327
+ if (frameToken === undefined) {
328
+ frameToken = line;
329
+ resolve();
330
+ return;
331
+ }
332
+ if (line.length === 0) return;
333
+ deliver(JSON.parse(line));
334
+ });
335
+ });
336
+
337
+ const transport = {
338
+ send(message) {
339
+ if (message.type === "result" && textPipe !== null) {
340
+ drainSequence += 1;
341
+ const key = String(drainSequence);
342
+ pendingResults.set(key, message);
343
+ writeAll(1, fromString(`${DRAIN_MARKER}${key}\u0000`));
344
+ return;
345
+ }
346
+ sendFrame(message);
347
+ },
348
+ onMessage(handler) {
349
+ lineHandler = handler;
350
+ for (const message of bufferedLines.splice(0)) handler(message);
351
+ return () => frameLines.close();
352
+ },
353
+ close() {
354
+ setTimeout(() => exit(0), 0);
355
+ },
356
+ };
357
+
358
+ await tokenReceived;
359
+
360
+ // Raw fd 1 output is read on its own thread, which writes it out as text frames itself: a cell that fills the pipe
361
+ // from this thread (a child process inheriting fd 1, a writeSync loop) never waits on this thread, and output is never
362
+ // held in memory while the cell runs. A result waits behind a drain marker written to the same pipe, so every byte a
363
+ // cell wrote before it returned reaches the host before its result.
364
+ const pendingResults = new Map();
365
+ let drainSequence = 0;
366
+ const DRAINER = `const { readSync, writeSync } = require("node:fs");
367
+ const { StringDecoder } = require("node:string_decoder");
368
+ const { parentPort, workerData } = require("node:worker_threads");
369
+ const { fd, frameFd, token, lock, marker } = workerData;
370
+ const pause = new Int32Array(new SharedArrayBuffer(4));
371
+ function writeAll(bytes) {
372
+ let offset = 0;
373
+ while (offset < bytes.length) {
374
+ try {
375
+ offset += writeSync(frameFd, bytes, offset, bytes.length - offset);
376
+ } catch (error) {
377
+ if (error && error.code === "EAGAIN") { Atomics.wait(pause, 0, 0, 2); continue; }
378
+ // The host is gone. process.exit would end only this thread, leaving the frame lock held.
379
+ process.kill(process.pid, "SIGKILL");
380
+ }
381
+ }
382
+ }
383
+ // One read is at most 64 KiB, so a text frame never nears the frame limit.
384
+ function sendText(data) {
385
+ const line = Buffer.from(token + " " + JSON.stringify({ type: "text", stream: "stdout", data }) + "\\n", "utf8");
386
+ while (Atomics.compareExchange(lock, 0, 0, 1) !== 0) Atomics.wait(lock, 0, 1, 5);
387
+ try { writeAll(line); } finally { Atomics.store(lock, 0, 0); Atomics.notify(lock, 0, 1); }
388
+ }
389
+ const decoder = new StringDecoder("utf8");
390
+ const buffer = Buffer.alloc(1 << 16);
391
+ let carry = "";
392
+ ${scanDrainText.toString()}
393
+ for (;;) {
394
+ let read;
395
+ try {
396
+ read = readSync(fd, buffer, 0, buffer.length, null);
397
+ } catch {
398
+ break;
399
+ }
400
+ if (read === 0) break;
401
+ const scanned = scanDrainText(carry, decoder.write(buffer.subarray(0, read)), marker);
402
+ carry = scanned.carry;
403
+ for (const part of scanned.parts) {
404
+ if (part.key !== undefined) parentPort.postMessage(part.key);
405
+ else sendText(part.text);
406
+ }
407
+ }`;
408
+ if (textPipe !== null) {
409
+ const reader = new Worker(DRAINER, {
410
+ eval: true,
411
+ workerData: {
412
+ fd: textPipe.readFd,
413
+ frameFd,
414
+ token: frameToken,
415
+ lock: frameLock,
416
+ marker: DRAIN_MARKER,
417
+ },
418
+ });
419
+ reader.on("message", (key) => {
420
+ const result = pendingResults.get(key);
421
+ pendingResults.delete(key);
422
+ if (result !== undefined) sendFrame(result);
423
+ });
424
+ }
425
+
426
+ // The crash cause reaches the host on stderr, tagged with the token, before the kernel ends (worker mode reports it
427
+ // through the thread's error event). It ends by SIGKILL, not process.exit: a crashed kernel runs no more code, and
428
+ // Node's exit can stall joining its own platform threads, which would keep a dead kernel and its cell waiting (#2757).
429
+ function reportCrash(error) {
430
+ const message = error instanceof Error ? error.message : String(error);
431
+ // Capped well below the host's stderr window, so the cause line is never cut off.
432
+ const cause = { name: error instanceof Error ? error.name : "Error", message: message.slice(0, 4096) };
433
+ // Wait (briefly) for a frame another thread is writing, so the channel ends on a frame boundary and the host reads
434
+ // the crash, not a cut line. Holding the lock past this point keeps any new frame from starting.
435
+ const deadline = Date.now() + 200;
436
+ while (Atomics.compareExchange(frameLock, 0, 0, 1) !== 0 && Date.now() < deadline) Atomics.wait(frameLock, 0, 1, 5);
437
+ try {
438
+ writeBytes(2, fromString(`\nsenpi-kernel-crash ${frameToken} ${stringify(cause)}\n`));
439
+ } finally {
440
+ process.kill(process.pid, "SIGKILL");
441
+ }
442
+ }
443
+ process.on("uncaughtException", reportCrash);
444
+ process.on("unhandledRejection", reportCrash);
445
+
446
+ const cwd = process.env.SENPI_CODEMODE_PROCESS_CWD ?? process.cwd();
447
+ const poolWidth = Number.parseInt(process.env.SENPI_CODEMODE_PROCESS_POOL_WIDTH ?? "1", 10) || 1;
448
+ // Kernel plumbing, not the session's environment: cells and their children do not inherit it.
449
+ delete process.env.SENPI_CODEMODE_PROCESS_CWD;
450
+ delete process.env.SENPI_CODEMODE_PROCESS_POOL_WIDTH;
451
+
452
+ const { markKernelProcessMode } = await import("./worker-webview.js");
453
+ markKernelProcessMode();
454
+
455
+ const { createWorkerCore } = await import("./worker-core.js");
456
+
457
+ createWorkerCore(transport, {
458
+ cwd,
459
+ parallelPoolWidth: poolWidth,
460
+ cwdInstallOptions: { allowMainThread: true },
461
+ processModeMemory: true,
462
+ });