@code-yeongyu/senpi-codemode 2026.10.10 → 2026.10.12

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 (110) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/README.md +113 -11
  3. package/package.json +7 -6
  4. package/src/bridge/memory-protocol.ts +8 -1
  5. package/src/bridge/protocol.ts +9 -4
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/bridges/eval-environment-schemas.ts +52 -0
  8. package/src/bridges/eval-virtual-schemas.ts +11 -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/editable-check.ts +1 -1
  14. package/src/environments/packages-install.ts +98 -0
  15. package/src/environments/py-environment.ts +7 -3
  16. package/src/environments/py-installer.ts +80 -44
  17. package/src/environments/python-environments.ts +1 -1
  18. package/src/extension/bridge-tool-call.ts +5 -1
  19. package/src/extension/eval-cells-rpc.ts +62 -0
  20. package/src/extension/eval-notifier.ts +13 -2
  21. package/src/extension/kernel-registration.ts +7 -2
  22. package/src/extension/runtime-factory.ts +42 -13
  23. package/src/extension/runtime-info.ts +6 -2
  24. package/src/extension/session-cwd.ts +25 -9
  25. package/src/extension/session-manager-contract.ts +3 -0
  26. package/src/extension/session-manager.ts +20 -6
  27. package/src/index.ts +8 -3
  28. package/src/interpreters/detect.ts +34 -1
  29. package/src/kernels/AGENTS.md +2 -0
  30. package/src/kernels/jl/globals.jl +128 -0
  31. package/src/kernels/jl/prelude.jl +15 -2
  32. package/src/kernels/jl/runner.jl +35 -143
  33. package/src/kernels/js/cell-ownership.js +268 -0
  34. package/src/kernels/js/cell-run-context.js +89 -0
  35. package/src/kernels/js/context-manager.ts +23 -24
  36. package/src/kernels/js/control-frames.ts +24 -0
  37. package/src/kernels/js/group-signal-notice.js +117 -0
  38. package/src/kernels/js/inline-worker.ts +2 -0
  39. package/src/kernels/js/kernel-contract.ts +16 -1
  40. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  41. package/src/kernels/js/kernel-memory.ts +79 -0
  42. package/src/kernels/js/kernel-tools-registry.js +1 -0
  43. package/src/kernels/js/local-module-loader.ts +8 -0
  44. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  45. package/src/kernels/js/process-drain-scan.js +28 -0
  46. package/src/kernels/js/process-entry.js +466 -0
  47. package/src/kernels/js/process-memory-host.ts +83 -0
  48. package/src/kernels/js/process-tree-host.ts +4 -8
  49. package/src/kernels/js/process-tree.d.ts +11 -0
  50. package/src/kernels/js/process-tree.js +55 -10
  51. package/src/kernels/js/process-worker.ts +298 -0
  52. package/src/kernels/js/rejection-reports.js +61 -0
  53. package/src/kernels/js/run-queue.ts +7 -0
  54. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  55. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  56. package/src/kernels/js/windows-tree-kill.js +100 -0
  57. package/src/kernels/js/worker-core.js +51 -8
  58. package/src/kernels/js/worker-cwd.js +46 -12
  59. package/src/kernels/js/worker-host.ts +9 -2
  60. package/src/kernels/js/worker-indirect-eval.js +37 -13
  61. package/src/kernels/js/worker-require.d.ts +19 -0
  62. package/src/kernels/js/worker-require.js +52 -0
  63. package/src/kernels/js/worker-runtime.js +66 -13
  64. package/src/kernels/js/worker-shadow-guard.js +2 -6
  65. package/src/kernels/js/worker-shell-capture.js +9 -0
  66. package/src/kernels/js/worker-slot.ts +8 -0
  67. package/src/kernels/js/worker-startup.ts +49 -1
  68. package/src/kernels/js/worker-webview.js +15 -1
  69. package/src/kernels/py/prelude.py +69 -27
  70. package/src/kernels/py/process.ts +4 -0
  71. package/src/kernels/py/transport.ts +4 -0
  72. package/src/kernels/rb/runner.rb +9 -19
  73. package/src/kernels/sandbox/sandbox-cell.ts +26 -1
  74. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  75. package/src/kernels/shared/bun-ffi.ts +24 -0
  76. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  77. package/src/kernels/shared/process-cpu.ts +7 -3
  78. package/src/kernels/shared/process-group-cpu.ts +6 -3
  79. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  80. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  81. package/src/kernels/shared/subprocess-process.ts +24 -2
  82. package/src/kernels/shared/subprocess-queue.ts +1 -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 +3 -0
  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 +20 -11
  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 +21 -10
  93. package/src/tool/detached-notification-queue.ts +1 -2
  94. package/src/tool/eval-tool.ts +4 -2
  95. package/src/tool/interrupt-note.ts +4 -5
  96. package/src/tool/live-headline.ts +60 -0
  97. package/src/tool/managed-cell.ts +0 -4
  98. package/src/tool/render-agent.ts +110 -0
  99. package/src/tool/render-blocks.ts +252 -0
  100. package/src/tool/render-call.ts +130 -0
  101. package/src/tool/render-cell.ts +165 -0
  102. package/src/tool/render-detail.ts +152 -0
  103. package/src/tool/render-live.ts +288 -0
  104. package/src/tool/render-result.ts +202 -0
  105. package/src/tool/render-status.ts +166 -0
  106. package/src/tool/render-tail.ts +139 -0
  107. package/src/tool/render.ts +3 -1069
  108. package/src/tool/run-eval-cell.ts +24 -14
  109. package/src/tool/runtime-label.ts +2 -1
  110. package/src/tool/types.ts +13 -2
@@ -0,0 +1,100 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ // Plain JS (with windows-tree-kill.d.ts) because the worker runtime imports it. `taskkill /T`
4
+ // adopts every process whose recorded ParentProcessId equals the root's pid, and Windows never rewrites that
5
+ // field when a parent exits and reuses pids (senpi#2999). The tree is computed from one process listing instead: a process
6
+ // belongs to it only when it started at or after the parent it names, and each pid is killed by name.
7
+
8
+ const LISTING_TIMEOUT_MS = 5_000;
9
+ const LISTING_MAX_BUFFER = 16 * 1024 * 1024;
10
+
11
+ const ROWS_SCRIPT = `Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID } | ForEach-Object {
12
+ $created = if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 }
13
+ "{0} {1} {2} {3}" -f $_.ProcessId, $_.ParentProcessId, $created, $_.Name }`;
14
+
15
+ const PROTECTED_IMAGES = new Set([
16
+ "system",
17
+ "smss.exe",
18
+ "csrss.exe",
19
+ "wininit.exe",
20
+ "winlogon.exe",
21
+ "services.exe",
22
+ "lsass.exe",
23
+ "lsaiso.exe",
24
+ "svchost.exe",
25
+ ]);
26
+
27
+ export function parseWindowsRows(stdout) {
28
+ const rows = [];
29
+ for (const line of stdout.split(/\r?\n/u)) {
30
+ const [pidText, parentText, createdText, ...nameParts] = line.trim().split(/\s+/u);
31
+ const pid = Number(pidText);
32
+ const parentPid = Number(parentText);
33
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !/^\d+$/u.test(createdText ?? "")) continue;
34
+ rows.push({ pid, parentPid, createdAt: BigInt(createdText ?? "0"), name: nameParts.join(" ") });
35
+ }
36
+ return rows;
37
+ }
38
+
39
+ /** The root and its genuine descendants; any protected image or ancestor of `selfPid` in it shrinks the kill to the root. */
40
+ export function windowsTreeKillPids(rows, rootPid, selfPid) {
41
+ const byPid = new Map(rows.map((row) => [row.pid, row]));
42
+ if (!byPid.has(rootPid)) return [];
43
+ const tree = new Set([rootPid]);
44
+ let grew = true;
45
+ while (grew) {
46
+ grew = false;
47
+ for (const row of rows) {
48
+ if (tree.has(row.pid) || !tree.has(row.parentPid)) continue;
49
+ const parent = byPid.get(row.parentPid);
50
+ if (parent === undefined || row.createdAt < parent.createdAt) continue;
51
+ tree.add(row.pid);
52
+ grew = true;
53
+ }
54
+ }
55
+ const ancestors = new Set([selfPid]);
56
+ for (let current = byPid.get(selfPid); current !== undefined; ) {
57
+ const parent = byPid.get(current.parentPid);
58
+ if (parent === undefined || ancestors.has(parent.pid) || parent.createdAt > current.createdAt) break;
59
+ ancestors.add(parent.pid);
60
+ current = parent;
61
+ }
62
+ const refused = [...tree].some((pid) => PROTECTED_IMAGES.has((byPid.get(pid)?.name ?? "").toLowerCase()) || ancestors.has(pid));
63
+ if (!refused) return [...tree];
64
+ const rootRefused = PROTECTED_IMAGES.has(byPid.get(rootPid).name.toLowerCase()) || ancestors.has(rootPid);
65
+ return rootRefused ? [] : [rootPid];
66
+ }
67
+
68
+ /** `taskkill` arguments: the checked tree pid by pid, or `/T` on the root when no listing could be read. */
69
+ export function windowsTreeKillArgs(rootPid, rows, selfPid = process.pid) {
70
+ if (rows === undefined) return ["/F", "/T", "/PID", String(rootPid)];
71
+ const pids = windowsTreeKillPids(rows, rootPid, selfPid);
72
+ if (pids.length === 0) return [];
73
+ return ["/F", ...pids.flatMap((pid) => ["/PID", String(pid)])];
74
+ }
75
+
76
+ export function listWindowsRows() {
77
+ return new Promise((resolve) => {
78
+ execFile(
79
+ "powershell.exe",
80
+ ["-NoProfile", "-NonInteractive", "-Command", ROWS_SCRIPT],
81
+ { timeout: LISTING_TIMEOUT_MS, maxBuffer: LISTING_MAX_BUFFER, windowsHide: true },
82
+ (error, stdout) => {
83
+ const rows = error ? [] : parseWindowsRows(String(stdout ?? ""));
84
+ resolve(rows.length > 0 ? rows : undefined);
85
+ },
86
+ );
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Kills the checked process tree rooted at `pid`; resolves once taskkill has run (or nothing needed killing).
92
+ * A batch passes one shared `listing` so every tree is planned from the same snapshot.
93
+ */
94
+ export async function killWindowsTree(pid, listing = listWindowsRows()) {
95
+ const args = windowsTreeKillArgs(pid, await listing);
96
+ if (args.length === 0) return;
97
+ await new Promise((resolve) => {
98
+ execFile("taskkill", args, { timeout: LISTING_TIMEOUT_MS, windowsHide: true }, () => resolve());
99
+ });
100
+ }
@@ -1,3 +1,5 @@
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";
@@ -5,6 +7,7 @@ import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
5
7
  import { installSessionCwd } from "./worker-cwd.js";
6
8
  import { installPackageResolver } from "./worker-package-resolve.js";
7
9
  import { createHeapProbe } from "./worker-heap.js";
10
+ import { createRejectionReports } from "./rejection-reports.js";
8
11
  import { createWorkerMemory } from "./worker-memory.js";
9
12
  import { JsWorkerRuntime } from "./worker-runtime.js";
10
13
  import { installKernelWebView } from "./worker-webview.js";
@@ -29,10 +32,17 @@ const SESSION_ENVIRONMENT_KEYS = [
29
32
  ];
30
33
 
31
34
  export function createWorkerCore(transport, options) {
35
+ const restoreOwnership = installCellOwnership();
32
36
  let runtime = null;
33
37
  let memory = null;
34
38
  let heapProbe = null;
35
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);
36
46
  const pendingTools = new Map();
37
47
  const pendingWebViewPorts = new Map();
38
48
  const nestedInvokes = new Map();
@@ -46,23 +56,38 @@ export function createWorkerCore(transport, options) {
46
56
  transport.send(message);
47
57
  }
48
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
+
49
64
  async function runCell(message) {
50
65
  if (!runtime) {
51
66
  emit({ type: "result", cellId: message.cellId, ok: false, error: { message: "JS runtime not initialized" }, durationMs: 0 });
52
67
  return;
53
68
  }
54
69
  const startedAtMs = performance.now();
55
- 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);
56
74
  try {
57
- const value = await runtime.run(message.code, message.cellId, {
58
- emit,
59
- callTool: async (toolName, args) => await callTool(toolName, args),
60
- });
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);
61
85
  emit({ type: "result", cellId: message.cellId, ok: true, valueRepr: valueRepr(value), durationMs: durationMs(startedAtMs), ...memoryReport() });
62
86
  } catch (error) {
87
+ rejections.finishCell(cell);
63
88
  emit({ type: "result", cellId: message.cellId, ok: false, error: bridgeError(error), durationMs: durationMs(startedAtMs), ...memoryReport() });
64
89
  } finally {
65
- activeCell = null;
90
+ if (activeCell === cell) activeCell = null;
66
91
  }
67
92
  }
68
93
 
@@ -71,6 +96,8 @@ export function createWorkerCore(transport, options) {
71
96
  }
72
97
 
73
98
  async function callTool(toolName, args) {
99
+ const released = releasedCellError();
100
+ if (released !== undefined) throw released;
74
101
  const nested = kernelToolCallContext.getStore();
75
102
  if (!nested && activeCell?.interruption) throw activeCell.interruption;
76
103
  if (nested?.signal.aborted) throw nested.signal.reason;
@@ -105,6 +132,17 @@ export function createWorkerCore(transport, options) {
105
132
  }
106
133
  kernelTools.abortAll(kernelToolError("kernel_tool_stale", interruption.message));
107
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));
108
146
  }
109
147
 
110
148
  function acknowledgeInterrupt() {
@@ -128,7 +166,7 @@ export function createWorkerCore(transport, options) {
128
166
  }
129
167
  if (message.type === "init") {
130
168
  applySessionEnvironment(message.sessionEnv);
131
- installSessionCwd(options.cwd);
169
+ installSessionCwd(options.cwd, options.cwdInstallOptions);
132
170
  installKernelWebView(requestWebViewPort);
133
171
  installPackageResolver();
134
172
  runtime = new JsWorkerRuntime({
@@ -139,12 +177,15 @@ export function createWorkerCore(transport, options) {
139
177
  kernelGeneration: message.kernelGeneration ?? 1,
140
178
  hostToolNames: message.hostToolNames ?? [],
141
179
  foreignLanguageNames: message.foreignLanguageNames ?? [],
180
+ kernelToolsDisabled: message.kernelToolsDisabled === true,
142
181
  onChildEvent: (event) => emit({ type: "status", event: { op: CHILD_LIFECYCLE_OP, ...event } }),
143
182
  onShellWaitChange: () => {
144
183
  if (activeCell?.interruption) acknowledgeInterrupt();
145
184
  },
146
185
  });
147
- 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) {
148
189
  memory = createWorkerMemory(message.memory, (report) => emit({ type: "status", event: { op: MEMORY_COLLECTED_OP, ...report } }));
149
190
  memory.captureBaseline();
150
191
  }
@@ -183,6 +224,8 @@ export function createWorkerCore(transport, options) {
183
224
  return {
184
225
  dispose() {
185
226
  unsubscribe();
227
+ restoreOwnership();
228
+ process.off("unhandledRejection", reportRejection);
186
229
  globalThis.__senpi_restore_console__?.();
187
230
  },
188
231
  };
@@ -51,9 +51,13 @@ const FS_PATH_ARGS = {
51
51
 
52
52
  const CHILD_PROCESS_FILE_ARGS = ["spawn", "spawnSync", "execFile", "execFileSync", "fork"];
53
53
  const CHILD_PROCESS_COMMAND_ARGS = ["exec", "execSync"];
54
+ const ASYNC_GROUP_SPAWNERS = new Set(["spawn", "fork"]);
54
55
 
55
- export function installSessionCwd(cwd) {
56
- if (isMainThread) throw new Error("installSessionCwd must only run inside a kernel worker thread");
56
+ export function installSessionCwd(cwd, options) {
57
+ // A process-mode kernel child IS the main thread; the worker guard relaxes only for that entry.
58
+ if (isMainThread && options?.allowMainThread !== true) {
59
+ throw new Error("installSessionCwd must only run inside a kernel worker thread");
60
+ }
57
61
  const root = resolve(cwd);
58
62
  const rootBytes = Buffer.from(root.endsWith(sep) ? root : `${root}${sep}`);
59
63
  // fs also takes Buffer/Uint8Array paths; a relative one gets the session root prepended as bytes,
@@ -92,7 +96,9 @@ function patchFs(fs, promises, at, root) {
92
96
  function patchChildProcess(childProcess, at, root) {
93
97
  for (const name of CHILD_PROCESS_FILE_ARGS) {
94
98
  replace(childProcess, name, (fn) => {
95
- const withCwd = withOptionsCwd(fn, fileOptionsIndex, root);
99
+ // Only asynchronous spawners start a new group: a synchronous call keeps the terminal (ssh/sudo/git prompts),
100
+ // and Bun ignores `detached` for execFile. Those calls get a notice instead (group-signal-notice.js).
101
+ const withCwd = withOptionsCwd(fn, fileOptionsIndex, root, ASYNC_GROUP_SPAWNERS.has(name));
96
102
  return name === "fork" ? resolvingArgs(withCwd, [0], at) : withCwd;
97
103
  });
98
104
  }
@@ -104,7 +110,7 @@ function patchBun(bun, at, root) {
104
110
  replace(bun, "file", (fn) => resolvingArgs(fn, [0], at));
105
111
  replace(bun, "write", (fn) => resolvingArgs(fn, [0], at));
106
112
  replace(bun, "spawn", (fn) => withBunSpawnCwd(fn, root));
107
- replace(bun, "spawnSync", (fn) => withBunSpawnCwd(fn, root));
113
+ replace(bun, "spawnSync", (fn) => withBunSpawnCwd(fn, root, false));
108
114
  if (typeof bun.$?.cwd === "function") bun.$.cwd(root);
109
115
  const glob = bun.Glob?.prototype;
110
116
  if (glob) {
@@ -146,32 +152,60 @@ function fileOptionsIndex(args) {
146
152
  return Array.isArray(args[1]) || (args[1] == null && args.length > 2) ? 2 : 1;
147
153
  }
148
154
 
149
- function withOptionsCwd(fn, optionsIndex, root) {
155
+ function withOptionsCwd(fn, optionsIndex, root, inOwnGroup = false) {
156
+ const group = inOwnGroup ? ownGroup : (options) => options;
150
157
  return function withSessionCwd(...args) {
151
158
  const index = optionsIndex(args);
152
159
  const options = args[index];
153
- if (options === undefined || options === null) args[index] = { cwd: root };
154
- else if (typeof options === "function") args.splice(index, 0, { cwd: root });
155
- else if (typeof options === "object") args[index] = { ...options, cwd: cwdFrom(options.cwd, root) };
156
- return fn.apply(this, args);
160
+ if (options === undefined || options === null) args[index] = group({ cwd: root });
161
+ else if (typeof options === "function") args.splice(index, 0, group({ cwd: root }));
162
+ else if (typeof options === "object") args[index] = group({ ...options, cwd: cwdFrom(options.cwd, root) });
163
+ if (!inOwnGroup || process.platform === "win32" || (isPlainOptions(options) && options.detached !== undefined)) {
164
+ return fn.apply(this, args);
165
+ }
166
+ // Bun's child_process spawns through Bun.spawn, whose child tracking skips `detached` children as ones the
167
+ // cell keeps on purpose. While this call runs, `detached` is ours, so the child is still retired with the cell.
168
+ const depth = globalThis[INJECTED_GROUP] ?? 0;
169
+ globalThis[INJECTED_GROUP] = depth + 1;
170
+ try {
171
+ return fn.apply(this, args);
172
+ } finally {
173
+ globalThis[INJECTED_GROUP] = depth;
174
+ }
157
175
  };
158
176
  }
159
177
 
160
- function withBunSpawnCwd(fn, root) {
178
+ function withBunSpawnCwd(fn, root, inOwnGroup = true) {
179
+ const group = inOwnGroup ? ownGroup : (options) => options;
161
180
  return function spawnInSessionCwd(...args) {
162
181
  const [first, second] = args;
163
182
  if (Array.isArray(first)) {
164
183
  const options = second === undefined || second === null ? {} : second;
165
184
  if (typeof options !== "object") return fn.apply(this, args);
166
- return fn.call(this, first, { ...options, cwd: cwdFrom(options.cwd, root) }, ...args.slice(2));
185
+ return fn.call(this, first, group({ ...options, cwd: cwdFrom(options.cwd, root) }), ...args.slice(2));
167
186
  }
168
187
  if (first !== null && typeof first === "object") {
169
- return fn.call(this, { ...first, cwd: cwdFrom(first.cwd, root) }, ...args.slice(1));
188
+ return fn.call(this, group({ ...first, cwd: cwdFrom(first.cwd, root) }), ...args.slice(1));
170
189
  }
171
190
  return fn.apply(this, args);
172
191
  };
173
192
  }
174
193
 
194
+ // senpi#2995: a JS kernel runs inside the agent process, so a child spawned from a cell would join the agent's
195
+ // process group, and a cell that later signals that group (`kill -TERM -- -$PGID`) stops the agent itself. On
196
+ // POSIX every cell child starts its own group instead; kernel teardown still retires it by pid and descendants.
197
+ // A cell that sets `detached` itself keeps its choice. Windows is left alone: there `detached` opens a console.
198
+ export const INJECTED_GROUP = Symbol.for("senpi.kernel.injectedProcessGroup");
199
+
200
+ function isPlainOptions(value) {
201
+ return value !== null && typeof value === "object";
202
+ }
203
+
204
+ function ownGroup(options) {
205
+ if (process.platform === "win32" || options.detached !== undefined) return options;
206
+ return { ...options, detached: true };
207
+ }
208
+
175
209
  function globScanOptions(options, root) {
176
210
  if (options === undefined || options === null) return { cwd: root };
177
211
  if (typeof options === "string") return cwdFrom(options, root);
@@ -1,6 +1,7 @@
1
1
  import type { EventEmitter } from "node:events";
2
2
  import { Worker } from "node:worker_threads";
3
3
  import type { KernelToHostMessage } from "../../bridge/protocol.ts";
4
+ import { describeExit } from "../shared/kernel-death.ts";
4
5
  import type { WorkerLike } from "./inline-worker.ts";
5
6
  import type { JavaScriptKernelMode } from "./kernel-contract.ts";
6
7
 
@@ -22,10 +23,16 @@ export class WorkerStartupCancelledError extends Error {
22
23
  export class JavaScriptWorkerExitedError extends Error {
23
24
  readonly name = "JavaScriptWorkerExitedError";
24
25
  readonly exitCode: number;
26
+ readonly exitSignal: string | null;
25
27
 
26
- constructor(exitCode: number) {
27
- super(`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;
@@ -0,0 +1,19 @@
1
+ export interface CellRequireContext {
2
+ readonly cwdUrl: string;
3
+ readonly packageRootUrl?: string | null;
4
+ }
5
+
6
+ export interface CellResolve {
7
+ (specifier: string, options?: { readonly paths?: readonly string[] }): string;
8
+ paths(specifier: string): string[] | null;
9
+ }
10
+
11
+ export interface CellRequire {
12
+ (specifier: string): unknown;
13
+ readonly resolve: CellResolve;
14
+ readonly cache: Record<string, unknown>;
15
+ }
16
+
17
+ export function createCellRequire(context: () => CellRequireContext): CellRequire;
18
+
19
+ export { createRequire } from "node:module";
@@ -0,0 +1,52 @@
1
+ import { createRequire, isBuiltin } from "node:module";
2
+
3
+ // `require` in a cell resolves the way `import` does there: builtins natively, relative paths from the cell's
4
+ // directory, bare packages from the session's project first and then its managed package environment
5
+ // (%bun add / %npm add). It uses the runtime's own CommonJS loader, so JSON files and the `require` export conditions
6
+ // behave as in Node. Like Node's own `require`, it carries `resolve`, `resolve.paths` and `cache`.
7
+ export function createCellRequire(context) {
8
+ const requirers = () => {
9
+ const { cwdUrl, packageRootUrl } = context();
10
+ const fromCwd = createRequire(new URL("package.json", cwdUrl));
11
+ const fromPackages = packageRootUrl ? createRequire(new URL("package.json", packageRootUrl)) : undefined;
12
+ return { fromCwd, fromPackages };
13
+ };
14
+ const isPathLike = (name) => name.startsWith(".") || name.startsWith("/") || /^[A-Za-z]:[\\/]/.test(name);
15
+ // The one lookup both the call and `resolve` use, so they can never pick different copies of a package. Builtins
16
+ // (`fs`, `node:fs`, Bun's `bun:sqlite`) and paths resolve natively; a bare name the project does not have falls
17
+ // back to the managed environment, and a miss there reports the project's own not-found error.
18
+ const resolve = function resolve(specifier, options) {
19
+ const name = String(specifier);
20
+ const { fromCwd, fromPackages } = requirers();
21
+ if (isBuiltin(name) || isPathLike(name) || fromPackages === undefined || options?.paths !== undefined) {
22
+ return fromCwd.resolve(name, options);
23
+ }
24
+ try {
25
+ return fromCwd.resolve(name, options);
26
+ } catch (error) {
27
+ if (error?.code !== "MODULE_NOT_FOUND") throw error;
28
+ try {
29
+ return fromPackages.resolve(name, options);
30
+ } catch (fallback) {
31
+ throw fallback?.code === "MODULE_NOT_FOUND" ? error : fallback;
32
+ }
33
+ }
34
+ };
35
+ resolve.paths = (specifier) => {
36
+ const name = String(specifier);
37
+ const { fromCwd, fromPackages } = requirers();
38
+ const own = fromCwd.resolve.paths(name);
39
+ if (own === null || fromPackages === undefined || isPathLike(name)) return own;
40
+ return [...new Set([...own, ...(fromPackages.resolve.paths(name) ?? [])])];
41
+ };
42
+ // The call loads exactly what `resolve` names: a builtin id or an absolute file, through the runtime's loader.
43
+ const require = function require(specifier) {
44
+ return requirers().fromCwd(resolve(specifier));
45
+ };
46
+ require.resolve = resolve;
47
+ // One CommonJS cache per runtime: the cell's view is the loader's own, so deleting an entry forces a reload.
48
+ Object.defineProperty(require, "cache", { enumerable: true, get: () => createRequire(import.meta.url).cache });
49
+ return require;
50
+ }
51
+
52
+ export { createRequire };