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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +161 -11
  3. package/package.json +6 -5
  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/js-environments.ts +208 -0
  15. package/src/environments/js-installer.ts +268 -0
  16. package/src/environments/js-revision-files.ts +91 -0
  17. package/src/environments/no-links.ts +132 -0
  18. package/src/environments/packages-install.ts +98 -0
  19. package/src/environments/project-resolves.ts +54 -0
  20. package/src/environments/py-environment.ts +7 -3
  21. package/src/environments/py-installer.ts +5 -1
  22. package/src/environments/python-environments.ts +1 -1
  23. package/src/environments/revision-store.ts +20 -4
  24. package/src/extension/bridge-tool-call.ts +5 -1
  25. package/src/extension/eval-notifier.ts +13 -2
  26. package/src/extension/kernel-registration.ts +7 -2
  27. package/src/extension/runtime-factory.ts +51 -13
  28. package/src/extension/runtime-info.ts +6 -2
  29. package/src/extension/session-cwd.ts +25 -9
  30. package/src/extension/session-manager-contract.ts +3 -0
  31. package/src/extension/session-manager.ts +20 -6
  32. package/src/index.ts +3 -2
  33. package/src/interpreters/detect.ts +34 -1
  34. package/src/kernels/AGENTS.md +2 -0
  35. package/src/kernels/jl/runner.jl +11 -22
  36. package/src/kernels/js/cell-dispatch.ts +44 -0
  37. package/src/kernels/js/cell-ownership.js +268 -0
  38. package/src/kernels/js/cell-run-context.js +89 -0
  39. package/src/kernels/js/context-manager.ts +46 -51
  40. package/src/kernels/js/control-frames.ts +24 -0
  41. package/src/kernels/js/group-signal-notice.js +117 -0
  42. package/src/kernels/js/host-entries.ts +62 -0
  43. package/src/kernels/js/inline-worker.ts +2 -0
  44. package/src/kernels/js/kernel-contract.ts +16 -1
  45. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  46. package/src/kernels/js/kernel-memory.ts +79 -0
  47. package/src/kernels/js/kernel-tools-registry.js +1 -0
  48. package/src/kernels/js/local-module-loader.ts +25 -6
  49. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  50. package/src/kernels/js/process-drain-scan.js +28 -0
  51. package/src/kernels/js/process-entry.js +466 -0
  52. package/src/kernels/js/process-memory-host.ts +83 -0
  53. package/src/kernels/js/process-tree-host.ts +4 -8
  54. package/src/kernels/js/process-tree.d.ts +11 -0
  55. package/src/kernels/js/process-tree.js +55 -10
  56. package/src/kernels/js/process-worker.ts +298 -0
  57. package/src/kernels/js/rejection-reports.js +61 -0
  58. package/src/kernels/js/run-queue.ts +7 -0
  59. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  60. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  61. package/src/kernels/js/windows-tree-kill.js +100 -0
  62. package/src/kernels/js/worker-core.js +53 -8
  63. package/src/kernels/js/worker-cwd.js +46 -12
  64. package/src/kernels/js/worker-host.ts +9 -2
  65. package/src/kernels/js/worker-indirect-eval.js +37 -13
  66. package/src/kernels/js/worker-package-resolve.js +153 -0
  67. package/src/kernels/js/worker-require.d.ts +19 -0
  68. package/src/kernels/js/worker-require.js +52 -0
  69. package/src/kernels/js/worker-runtime.js +66 -13
  70. package/src/kernels/js/worker-shadow-guard.js +2 -6
  71. package/src/kernels/js/worker-shell-capture.js +9 -0
  72. package/src/kernels/js/worker-slot.ts +8 -0
  73. package/src/kernels/js/worker-startup.ts +49 -1
  74. package/src/kernels/js/worker-webview.js +15 -1
  75. package/src/kernels/py/host-cell.ts +8 -31
  76. package/src/kernels/py/prelude.py +69 -27
  77. package/src/kernels/py/process.ts +4 -0
  78. package/src/kernels/py/transport.ts +4 -0
  79. package/src/kernels/rb/runner.rb +9 -19
  80. package/src/kernels/sandbox/sandbox-cell.ts +141 -0
  81. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  82. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  83. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  84. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  85. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  86. package/src/kernels/shared/bun-ffi.ts +24 -0
  87. package/src/kernels/shared/host-cell.ts +57 -0
  88. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  89. package/src/kernels/shared/process-cpu.ts +7 -3
  90. package/src/kernels/shared/process-group-cpu.ts +6 -3
  91. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  92. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  93. package/src/kernels/shared/subprocess-process.ts +24 -2
  94. package/src/kernels/shared/subprocess-queue.ts +1 -0
  95. package/src/prompt/eval-prompt.ts +8 -1
  96. package/src/tool/AGENTS.md +1 -0
  97. package/src/tool/cell-handler.ts +12 -11
  98. package/src/tool/cell-reserved-dispatch.ts +1 -0
  99. package/src/tool/cell-runtime.ts +4 -2
  100. package/src/tool/detached-cell-contract.ts +3 -0
  101. package/src/tool/detached-cell-manager.ts +4 -8
  102. package/src/tool/detached-cell-notification.ts +11 -63
  103. package/src/tool/detached-cell-snapshot.ts +17 -0
  104. package/src/tool/detached-eval-result.ts +13 -7
  105. package/src/tool/detached-notification-queue.ts +1 -2
  106. package/src/tool/eval-request.ts +23 -1
  107. package/src/tool/eval-tool-options.ts +3 -0
  108. package/src/tool/eval-tool.ts +19 -5
  109. package/src/tool/interrupt-note.ts +4 -5
  110. package/src/tool/live-headline.ts +60 -0
  111. package/src/tool/load-cell.ts +18 -3
  112. package/src/tool/magic-cell-host.ts +60 -2
  113. package/src/tool/magic-cells.ts +12 -2
  114. package/src/tool/managed-cell.ts +0 -4
  115. package/src/tool/render-agent.ts +110 -0
  116. package/src/tool/render-blocks.ts +252 -0
  117. package/src/tool/render-call.ts +130 -0
  118. package/src/tool/render-cell.ts +165 -0
  119. package/src/tool/render-detail.ts +152 -0
  120. package/src/tool/render-live.ts +288 -0
  121. package/src/tool/render-result.ts +202 -0
  122. package/src/tool/render-status.ts +166 -0
  123. package/src/tool/render-tail.ts +139 -0
  124. package/src/tool/render.ts +3 -1069
  125. package/src/tool/run-eval-cell.ts +53 -16
  126. package/src/tool/runtime-label.ts +2 -1
  127. package/src/tool/types.ts +34 -3
@@ -0,0 +1,100 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ // Plain JS (with windows-tree-kill.d.ts) because the worker runtime imports it. `taskkill /T`
4
+ // adopts every process whose recorded ParentProcessId equals the root's pid, and Windows never rewrites that
5
+ // field when a parent exits and reuses pids (senpi#2999). The tree is computed from one process listing instead: a process
6
+ // belongs to it only when it started at or after the parent it names, and each pid is killed by name.
7
+
8
+ const LISTING_TIMEOUT_MS = 5_000;
9
+ const LISTING_MAX_BUFFER = 16 * 1024 * 1024;
10
+
11
+ const ROWS_SCRIPT = `Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID } | ForEach-Object {
12
+ $created = if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 }
13
+ "{0} {1} {2} {3}" -f $_.ProcessId, $_.ParentProcessId, $created, $_.Name }`;
14
+
15
+ const PROTECTED_IMAGES = new Set([
16
+ "system",
17
+ "smss.exe",
18
+ "csrss.exe",
19
+ "wininit.exe",
20
+ "winlogon.exe",
21
+ "services.exe",
22
+ "lsass.exe",
23
+ "lsaiso.exe",
24
+ "svchost.exe",
25
+ ]);
26
+
27
+ export function parseWindowsRows(stdout) {
28
+ const rows = [];
29
+ for (const line of stdout.split(/\r?\n/u)) {
30
+ const [pidText, parentText, createdText, ...nameParts] = line.trim().split(/\s+/u);
31
+ const pid = Number(pidText);
32
+ const parentPid = Number(parentText);
33
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !/^\d+$/u.test(createdText ?? "")) continue;
34
+ rows.push({ pid, parentPid, createdAt: BigInt(createdText ?? "0"), name: nameParts.join(" ") });
35
+ }
36
+ return rows;
37
+ }
38
+
39
+ /** The root and its genuine descendants; any protected image or ancestor of `selfPid` in it shrinks the kill to the root. */
40
+ export function windowsTreeKillPids(rows, rootPid, selfPid) {
41
+ const byPid = new Map(rows.map((row) => [row.pid, row]));
42
+ if (!byPid.has(rootPid)) return [];
43
+ const tree = new Set([rootPid]);
44
+ let grew = true;
45
+ while (grew) {
46
+ grew = false;
47
+ for (const row of rows) {
48
+ if (tree.has(row.pid) || !tree.has(row.parentPid)) continue;
49
+ const parent = byPid.get(row.parentPid);
50
+ if (parent === undefined || row.createdAt < parent.createdAt) continue;
51
+ tree.add(row.pid);
52
+ grew = true;
53
+ }
54
+ }
55
+ const ancestors = new Set([selfPid]);
56
+ for (let current = byPid.get(selfPid); current !== undefined; ) {
57
+ const parent = byPid.get(current.parentPid);
58
+ if (parent === undefined || ancestors.has(parent.pid) || parent.createdAt > current.createdAt) break;
59
+ ancestors.add(parent.pid);
60
+ current = parent;
61
+ }
62
+ const refused = [...tree].some((pid) => PROTECTED_IMAGES.has((byPid.get(pid)?.name ?? "").toLowerCase()) || ancestors.has(pid));
63
+ if (!refused) return [...tree];
64
+ const rootRefused = PROTECTED_IMAGES.has(byPid.get(rootPid).name.toLowerCase()) || ancestors.has(rootPid);
65
+ return rootRefused ? [] : [rootPid];
66
+ }
67
+
68
+ /** `taskkill` arguments: the checked tree pid by pid, or `/T` on the root when no listing could be read. */
69
+ export function windowsTreeKillArgs(rootPid, rows, selfPid = process.pid) {
70
+ if (rows === undefined) return ["/F", "/T", "/PID", String(rootPid)];
71
+ const pids = windowsTreeKillPids(rows, rootPid, selfPid);
72
+ if (pids.length === 0) return [];
73
+ return ["/F", ...pids.flatMap((pid) => ["/PID", String(pid)])];
74
+ }
75
+
76
+ export function listWindowsRows() {
77
+ return new Promise((resolve) => {
78
+ execFile(
79
+ "powershell.exe",
80
+ ["-NoProfile", "-NonInteractive", "-Command", ROWS_SCRIPT],
81
+ { timeout: LISTING_TIMEOUT_MS, maxBuffer: LISTING_MAX_BUFFER, windowsHide: true },
82
+ (error, stdout) => {
83
+ const rows = error ? [] : parseWindowsRows(String(stdout ?? ""));
84
+ resolve(rows.length > 0 ? rows : undefined);
85
+ },
86
+ );
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Kills the checked process tree rooted at `pid`; resolves once taskkill has run (or nothing needed killing).
92
+ * A batch passes one shared `listing` so every tree is planned from the same snapshot.
93
+ */
94
+ export async function killWindowsTree(pid, listing = listWindowsRows()) {
95
+ const args = windowsTreeKillArgs(pid, await listing);
96
+ if (args.length === 0) return;
97
+ await new Promise((resolve) => {
98
+ execFile("taskkill", args, { timeout: LISTING_TIMEOUT_MS, windowsHide: true }, () => resolve());
99
+ });
100
+ }
@@ -1,9 +1,13 @@
1
+ import { installCellOwnership } from "./cell-ownership.js";
2
+ import { releaseCell, releasedCellError, runInCell } from "./cell-run-context.js";
1
3
  import { kernelToolCallContext } from "./kernel-tools-context.js";
2
4
  import { kernelToolError } from "./kernel-tools-errors.js";
3
5
  import { createKernelToolPump } from "./kernel-tools-pump.js";
4
6
  import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
5
7
  import { installSessionCwd } from "./worker-cwd.js";
8
+ import { installPackageResolver } from "./worker-package-resolve.js";
6
9
  import { createHeapProbe } from "./worker-heap.js";
10
+ import { createRejectionReports } from "./rejection-reports.js";
7
11
  import { createWorkerMemory } from "./worker-memory.js";
8
12
  import { JsWorkerRuntime } from "./worker-runtime.js";
9
13
  import { installKernelWebView } from "./worker-webview.js";
@@ -28,10 +32,17 @@ const SESSION_ENVIRONMENT_KEYS = [
28
32
  ];
29
33
 
30
34
  export function createWorkerCore(transport, options) {
35
+ const restoreOwnership = installCellOwnership();
31
36
  let runtime = null;
32
37
  let memory = null;
33
38
  let heapProbe = null;
34
39
  let activeCell = null;
40
+ const rejections = createRejectionReports({
41
+ activeCell: () => activeCell,
42
+ emitText: (data) => emit({ type: "text", stream: "stderr", data }),
43
+ });
44
+ const reportRejection = (reason) => rejections.report(reason);
45
+ process.on("unhandledRejection", reportRejection);
35
46
  const pendingTools = new Map();
36
47
  const pendingWebViewPorts = new Map();
37
48
  const nestedInvokes = new Map();
@@ -45,23 +56,38 @@ export function createWorkerCore(transport, options) {
45
56
  transport.send(message);
46
57
  }
47
58
 
59
+ function stopProblem(cell, what, error) {
60
+ const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
61
+ emit({ type: "text", stream: "stderr", data: `Stopping cell ${cell.cellId}: ${what}: ${detail}\n` });
62
+ }
63
+
48
64
  async function runCell(message) {
49
65
  if (!runtime) {
50
66
  emit({ type: "result", cellId: message.cellId, ok: false, error: { message: "JS runtime not initialized" }, durationMs: 0 });
51
67
  return;
52
68
  }
53
69
  const startedAtMs = performance.now();
54
- 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
  };
@@ -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,153 @@
1
+ import { readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { isBuiltin } from "node:module";
3
+ import { dirname, join, sep } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ // A cell's bare import runs `import()` from this worker's own module, so the runtime resolves it from Senpi's install
7
+ // tree, not the session's project or its managed package revision. This resolver walks `node_modules` from each root
8
+ // the cell names, in order, and resolves the package entry the way ESM does (the `exports` field with import
9
+ // conditions), so ESM-only packages load and the first root that has the package wins.
10
+ const RESOLVE_PACKAGE = Symbol.for("senpi.kernel.resolvePackage");
11
+ const CONDITIONS = typeof globalThis.Bun === "object" ? ["bun", "node", "import", "default"] : ["node", "import", "default"];
12
+
13
+ function isFile(path) {
14
+ try {
15
+ return statSync(path).isFile();
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ function isDirectory(path) {
22
+ try {
23
+ return statSync(path).isDirectory();
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function splitSpecifier(specifier) {
30
+ const parts = specifier.split("/");
31
+ const nameParts = specifier.startsWith("@") ? 2 : 1;
32
+ if (parts.length < nameParts || parts.slice(0, nameParts).some((part) => part === "" || part === "." || part === ".."))
33
+ return undefined;
34
+ const rest = parts.slice(nameParts).join("/");
35
+ return { name: parts.slice(0, nameParts).join("/"), subpath: rest === "" ? "." : `./${rest}` };
36
+ }
37
+
38
+ function conditionalTarget(target) {
39
+ if (typeof target === "string") return target;
40
+ if (Array.isArray(target)) {
41
+ for (const candidate of target) {
42
+ const resolved = conditionalTarget(candidate);
43
+ if (resolved !== undefined) return resolved;
44
+ }
45
+ return undefined;
46
+ }
47
+ if (target === null || typeof target !== "object") return undefined;
48
+ for (const [condition, value] of Object.entries(target)) {
49
+ if (condition !== "default" && !CONDITIONS.includes(condition)) continue;
50
+ const resolved = conditionalTarget(value);
51
+ if (resolved !== undefined) return resolved;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ function exportsTarget(exportsField, subpath) {
57
+ const subpathKeyed =
58
+ exportsField !== null &&
59
+ typeof exportsField === "object" &&
60
+ !Array.isArray(exportsField) &&
61
+ Object.keys(exportsField).some((key) => key.startsWith("."));
62
+ if (!subpathKeyed) return subpath === "." ? conditionalTarget(exportsField) : undefined;
63
+ if (Object.hasOwn(exportsField, subpath)) return conditionalTarget(exportsField[subpath]);
64
+ let best;
65
+ for (const key of Object.keys(exportsField)) {
66
+ const star = key.indexOf("*");
67
+ if (star < 0 || key.includes("*", star + 1)) continue;
68
+ const prefix = key.slice(0, star);
69
+ const suffix = key.slice(star + 1);
70
+ if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix) || subpath.length < prefix.length + suffix.length)
71
+ continue;
72
+ if (best === undefined || prefix.length > best.prefix.length) {
73
+ best = { key, prefix, match: subpath.slice(prefix.length, subpath.length - suffix.length) };
74
+ }
75
+ }
76
+ if (best === undefined || hasInvalidSegment(best.match)) return undefined;
77
+ const target = conditionalTarget(exportsField[best.key]);
78
+ return target === undefined ? undefined : target.replaceAll("*", best.match);
79
+ }
80
+
81
+ // Node refuses a pattern match or target with "." / ".." / "node_modules" segments (ERR_INVALID_MODULE_SPECIFIER).
82
+ function hasInvalidSegment(path) {
83
+ return path.split(/[\\/]/).some((segment) => segment === "." || segment === ".." || segment === "node_modules");
84
+ }
85
+
86
+ function legacyFile(path) {
87
+ for (const candidate of [path, `${path}.js`, `${path}.mjs`, `${path}.cjs`, join(path, "index.js")]) {
88
+ if (isFile(candidate)) return candidate;
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ function packageEntry(packageDir, subpath, specifier) {
94
+ let manifest;
95
+ try {
96
+ manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
97
+ } catch {
98
+ return legacyFile(join(packageDir, subpath === "." ? "index.js" : subpath));
99
+ }
100
+ if (manifest.exports !== undefined && manifest.exports !== null) {
101
+ const target = exportsTarget(manifest.exports, subpath);
102
+ if (typeof target !== "string" || !target.startsWith("./") || hasInvalidSegment(target.slice(2))) {
103
+ const error = new Error(`Package subpath '${subpath}' is not exported for import by '${specifier}'`);
104
+ error.code = "ERR_PACKAGE_PATH_NOT_EXPORTED";
105
+ throw error;
106
+ }
107
+ return join(packageDir, target);
108
+ }
109
+ if (subpath !== ".") return legacyFile(join(packageDir, subpath));
110
+ const moduleEntry = typeof globalThis.Bun === "object" && typeof manifest.module === "string" ? manifest.module : undefined;
111
+ const main = moduleEntry ?? (typeof manifest.main === "string" ? manifest.main : "index.js");
112
+ return legacyFile(join(packageDir, main));
113
+ }
114
+
115
+ function findPackageDir(name, rootUrl) {
116
+ let directory = fileURLToPath(rootUrl);
117
+ for (;;) {
118
+ const candidate = join(directory, "node_modules", name);
119
+ if (isDirectory(candidate)) return candidate;
120
+ const parent = dirname(directory);
121
+ if (parent === directory) return undefined;
122
+ directory = parent;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The file URL the bare `specifier` resolves to from the first of `rootUrls` whose `node_modules` chain has its
128
+ * package, or undefined when it names a builtin or no root has the package (the caller then imports it natively).
129
+ */
130
+ export function resolvePackage(specifier, rootUrls) {
131
+ if (specifier.includes(":") || isBuiltin(specifier)) return undefined;
132
+ const parts = splitSpecifier(specifier);
133
+ if (parts === undefined) return undefined;
134
+ for (const rootUrl of rootUrls) {
135
+ if (typeof rootUrl !== "string") continue;
136
+ const packageDir = findPackageDir(parts.name, rootUrl);
137
+ if (packageDir === undefined) continue;
138
+ const entry = packageEntry(packageDir, parts.subpath, specifier);
139
+ if (entry === undefined) continue;
140
+ let real = entry;
141
+ try {
142
+ real = realpathSync(entry);
143
+ } catch {
144
+ // A missing target fails in the import itself, with the runtime's own error.
145
+ }
146
+ return pathToFileURL(real.endsWith(sep) ? real.slice(0, -1) : real).href;
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ export function installPackageResolver() {
152
+ globalThis[RESOLVE_PACKAGE] = resolvePackage;
153
+ }
@@ -0,0 +1,19 @@
1
+ export interface CellRequireContext {
2
+ readonly cwdUrl: string;
3
+ readonly packageRootUrl?: string | null;
4
+ }
5
+
6
+ export interface CellResolve {
7
+ (specifier: string, options?: { readonly paths?: readonly string[] }): string;
8
+ paths(specifier: string): string[] | null;
9
+ }
10
+
11
+ export interface CellRequire {
12
+ (specifier: string): unknown;
13
+ readonly resolve: CellResolve;
14
+ readonly cache: Record<string, unknown>;
15
+ }
16
+
17
+ export function createCellRequire(context: () => CellRequireContext): CellRequire;
18
+
19
+ export { createRequire } from "node:module";