@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,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 };
@@ -1,10 +1,13 @@
1
1
  // allow: SIZE_OK — private runtime state and installed globals must stay in one worker module.
2
+ import { pathToFileURL } from "node:url";
3
+ import { createCellRequire, createRequire } from "./worker-require.js";
2
4
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
5
  import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
4
6
  import { inspect } from "node:util";
5
7
  import { encodeDisplayImage, resolveDisplayOps } from "./display-image.js";
6
- import { terminateProcessTrees } from "./process-tree.js";
8
+ import { terminateProcessGroups, terminateProcessTrees } from "./process-tree.js";
7
9
  import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect-eval.js";
10
+ import { INJECTED_GROUP } from "./worker-cwd.js";
8
11
  import { installShellCapture } from "./worker-shell-capture.js";
9
12
  import { bindKernelBun } from "./worker-webview.js";
10
13
  import { createWorkpool } from "./workpool.js";
@@ -31,6 +34,10 @@ export class JsWorkerRuntime {
31
34
  #hooks = null;
32
35
  #pendingDisplays = [];
33
36
  #children = new Set();
37
+ // Groups the worker created for cell children (senpi#2995). A group outlives its leader, so a grandchild re-parented
38
+ // to init is still found through it at retirement (senpi#3020).
39
+ #ownedGroups = new Map();
40
+ #childrenStopping;
34
41
  #shellWaits = new Set();
35
42
  #onChildEvent;
36
43
  #onShellWaitChange;
@@ -47,6 +54,7 @@ export class JsWorkerRuntime {
47
54
  generation: options.kernelGeneration ?? 1,
48
55
  hostToolNames: options.hostToolNames ?? [],
49
56
  foreignLanguageNames: options.foreignLanguageNames ?? [],
57
+ disabled: options.kernelToolsDisabled === true,
50
58
  });
51
59
  this.#installGlobals();
52
60
  }
@@ -73,32 +81,48 @@ export class JsWorkerRuntime {
73
81
  if (typeof prepared.sourceFile === "string") sourceName = prepared.sourceFile;
74
82
  }
75
83
  if (prelude) indirectEval(prelude, PRELUDE_SOURCE_URL);
76
- const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), sourceName));
84
+ const shadowed = [];
85
+ const source = bindKernelBun(wrapUserCode(cellCode, shadowed));
86
+ if (shadowed.length > 0) this.#emitText("stderr", shadowNote(shadowed));
87
+ const value = await awaitMaybePromise(indirectEval(source, sourceName));
77
88
  await this.#drainPendingDisplays();
78
89
  return value;
79
90
  } finally {
80
- this.#pendingDisplays = [];
81
- // A child still running here has lost its only owner: the cell that
82
- // spawned it is over, nothing will await it again, and it would be
83
- // reparented to init. Retire it the way timeout and abort cleanup
84
- // already do, unless the cell asked for a detached process.
85
- await this.#terminateChildren();
86
- this.#hooks = null;
91
+ // A released run finishing late must not clear the state of the cell running now.
92
+ if (this.#hooks === hooks) await this.release();
87
93
  }
88
94
  }
89
95
 
96
+ /** Ends the current run's ownership now: its displays, its hooks, and (unless detached) its child processes. */
97
+ async release() {
98
+ const hooks = this.#hooks;
99
+ this.#pendingDisplays = [];
100
+ // A child still running here has lost its only owner: the cell that
101
+ // spawned it is over, nothing will await it again, and it would be
102
+ // reparented to init. Retire it the way timeout and abort cleanup
103
+ // already do, unless the cell asked for a detached process.
104
+ await Promise.all([this.#childrenStopping, this.#terminateChildren()]);
105
+ this.#childrenStopping = undefined;
106
+ if (this.#hooks === hooks) this.#hooks = null;
107
+ }
108
+
90
109
  interrupt() {
91
110
  // The tree snapshot, SIGTERM, and SIGKILL escalation run on their own so
92
111
  // the caller's interrupt latency stays that of the acknowledgement.
93
- void this.#terminateChildren();
112
+ this.#childrenStopping = this.#terminateChildren();
94
113
  }
95
114
 
96
115
  #trackChild(child, spawnOptions) {
97
116
  if (child === null || typeof child !== "object" || typeof child.kill !== "function") return;
98
117
  // `detached: true` is the cell saying it wants the process to outlive it.
99
- if (isPlainObject(spawnOptions) && spawnOptions.detached === true) return;
118
+ const groupInjected = (globalThis[INJECTED_GROUP] ?? 0) > 0;
119
+ if (isPlainObject(spawnOptions) && spawnOptions.detached === true && !groupInjected) return;
100
120
  this.#children.add(child);
101
121
  const pid = Number.isInteger(child.pid) && child.pid > 0 ? child.pid : null;
122
+ // Bun.spawn children get their group from worker-cwd.js without the injection marker, so every tracked child is
123
+ // recorded; a child that is not a group leader names no group, and signalling it later finds nothing.
124
+ const ownGroupLeader = !(isPlainObject(spawnOptions) && spawnOptions.detached === false);
125
+ if (pid !== null && process.platform !== "win32" && ownGroupLeader) this.#ownedGroups.set(pid, child);
102
126
  // The host keeps its own copy of live pids: if this worker is terminated
103
127
  // while blocked, only the host can still retire them.
104
128
  if (pid !== null) this.#onChildEvent?.({ pid, state: "spawned" });
@@ -112,10 +136,20 @@ export class JsWorkerRuntime {
112
136
  #terminateChildren() {
113
137
  const children = [...this.#children].filter(child => child.exitCode === null && child.signalCode === null);
114
138
  this.#children.clear();
115
- if (children.length === 0) return undefined;
139
+ // A leader that already exited leaves its group id reserved only while the group has members; if that pid now
140
+ // names a live process, it was reused by someone else and the group is not ours to signal (senpi#3020 review).
141
+ const groups = [...this.#ownedGroups].map(([pgid, child]) => ({
142
+ pgid,
143
+ leaderExited: child.exitCode !== null || child.signalCode !== null,
144
+ }));
145
+ this.#ownedGroups.clear();
146
+ if (children.length === 0 && groups.length === 0) return undefined;
116
147
  const roots = children.map(child => child.pid).filter(pid => Number.isInteger(pid) && pid > 0);
117
148
  const settled = Promise.allSettled(children.map(child => (child.exited instanceof Promise ? child.exited : Promise.resolve())));
118
- return terminateProcessTrees(roots, { graceMs: CHILD_TERMINATION_GRACE_MS, settled });
149
+ return Promise.all([
150
+ roots.length > 0 ? terminateProcessTrees(roots, { graceMs: CHILD_TERMINATION_GRACE_MS, settled }) : undefined,
151
+ terminateProcessGroups(groups, { graceMs: CHILD_TERMINATION_GRACE_MS }),
152
+ ]);
119
153
  }
120
154
 
121
155
  async #drainPendingDisplays() {
@@ -147,6 +181,17 @@ export class JsWorkerRuntime {
147
181
  return isPlainObject(opts) && opts.handle === true ? handles.handle(value) : value;
148
182
  };
149
183
  globalThis.wait = async (list, options) => await handles.wait(list, options);
184
+ // The `%bun add` / `%npm add` installer as a call: same environment, receipt, cancellation and codes (senpi row 38).
185
+ globalThis.packages = Object.freeze({
186
+ install: async (manager, requirements, options = {}) => {
187
+ if (!isPlainObject(options)) throw new TypeError("packages.install(): options must be an object, e.g. { timeout: 120 }");
188
+ return await this.#callTool("__packages_install__", {
189
+ manager,
190
+ requirements,
191
+ ...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
192
+ });
193
+ },
194
+ });
150
195
  globalThis.handle = value => handles.handle(value);
151
196
  globalThis.tool = createToolNamespace(
152
197
  (fn, metadata) => this.#tools.define(fn, metadata),
@@ -154,6 +199,8 @@ export class JsWorkerRuntime {
154
199
  { defined: () => this.#tools.defined(), undefine: (name) => this.#tools.undefine(name) },
155
200
  );
156
201
  globalThis.tools = globalThis.tool;
202
+ globalThis.require = createCellRequire(() => globalThis.__senpi_module_context__ ?? { cwdUrl: pathToFileURL(`${process.cwd()}/`).href });
203
+ globalThis.createRequire = createRequire;
157
204
  const originalLog = console.log.bind(console);
158
205
  const originalError = console.error.bind(console);
159
206
  const originalStdoutWrite = process.stdout.write;
@@ -422,6 +469,12 @@ export class JsWorkerRuntime {
422
469
  }
423
470
  }
424
471
 
472
+ function shadowNote(names) {
473
+ const list = names.map((name) => `\`${name}\``).join(", ");
474
+ const restore = names.map((name) => `delete ${name}`).join("; ");
475
+ return `Note: ${list} ${names.length === 1 ? "shadows a kernel or platform global" : "shadow kernel or platform globals"} in your later cells; the kernel and imported libraries keep the original. \`${restore}\` restores ${names.length === 1 ? "it" : "them"}.\n`;
476
+ }
477
+
425
478
  function isPlainObject(value) {
426
479
  return value !== null && typeof value === "object" && !Array.isArray(value);
427
480
  }
@@ -12,10 +12,6 @@ function protectedGlobals() {
12
12
  return protectedGlobalNames;
13
13
  }
14
14
 
15
- export function findShadowedGlobalName(bindings) {
16
- const names = protectedGlobals();
17
- for (const name of bindings) {
18
- if (names.has(name)) return name;
19
- }
20
- return undefined;
15
+ export function isKernelGlobal(name) {
16
+ return protectedGlobals().has(name);
21
17
  }
@@ -1,3 +1,6 @@
1
+ import { assertCellLive } from "./cell-run-context.js";
2
+ import { groupSignalNotice, noticeChildProcessGroupSignals, shellCommandText, signalsProcessGroup } from "./group-signal-notice.js";
3
+
1
4
  const SHELL_CONFIG_METHODS = ["env", "cwd", "nothrow", "throws"];
2
5
  const SHELL_READ_METHODS = ["text", "json", "lines", "arrayBuffer", "bytes", "blob"];
3
6
  // `true | ( … )` hands every command in the template an empty pipe as stdin. The worker thread shares
@@ -26,7 +29,9 @@ export function installShellCapture(options) {
26
29
  bun.$ = capturedShell(originalShell, options);
27
30
  bun.spawn = capturedSpawn(originalSpawn, options, pinEnv);
28
31
  if (originalSpawnSync !== null) bun.spawnSync = capturedSpawnSync(originalSpawnSync, pinEnv);
32
+ const restoreChildProcessNotice = noticeChildProcessGroupSignals(options.emitText, options.isActive);
29
33
  return () => {
34
+ restoreChildProcessNotice();
30
35
  bun.$ = originalShell;
31
36
  bun.spawn = originalSpawn;
32
37
  if (originalSpawnSync !== null) bun.spawnSync = originalSpawnSync;
@@ -39,7 +44,9 @@ function isBunRuntime(bun) {
39
44
 
40
45
  function capturedShell(originalShell, options) {
41
46
  const shell = (strings, ...expressions) => {
47
+ assertCellLive();
42
48
  if (!options.isActive()) return originalShell(strings, ...expressions);
49
+ if (signalsProcessGroup(shellCommandText(strings, expressions))) options.emitText("stderr", groupSignalNotice("Bun.$"));
43
50
  const promise = originalShell(isolateStdin(strings), ...expressions);
44
51
  return captureShellPromise(promise, options);
45
52
  };
@@ -132,6 +139,7 @@ function outputText(value) {
132
139
  // without an explicit env must get the worker's view pinned too (measured on Bun 1.4.0).
133
140
  function capturedSpawnSync(originalSpawnSync, pinEnv) {
134
141
  return (...args) => {
142
+ assertCellLive();
135
143
  if (!pinEnv) return originalSpawnSync(...args);
136
144
  const [first, second] = args;
137
145
  if (Array.isArray(first)) {
@@ -148,6 +156,7 @@ function capturedSpawnSync(originalSpawnSync, pinEnv) {
148
156
 
149
157
  function capturedSpawn(originalSpawn, options, pinEnv) {
150
158
  return (...args) => {
159
+ assertCellLive();
151
160
  if (!options.isActive()) return originalSpawn(...args);
152
161
  const [first, second] = args;
153
162
  let child;
@@ -19,6 +19,7 @@ export class WorkerSlot {
19
19
  readonly #options: JavaScriptKernelOptions;
20
20
  readonly #listeners: WorkerSlotListeners;
21
21
  #worker: WorkerLike | null = null;
22
+ #lastProcessPid: number | undefined;
22
23
  #webViews: KernelWebViewClients | null = null;
23
24
  #mode: JavaScriptKernelMode = "worker";
24
25
  #generation = 0;
@@ -34,6 +35,12 @@ export class WorkerSlot {
34
35
  return this.#mode;
35
36
  }
36
37
 
38
+ get processPid(): number | undefined {
39
+ const worker = this.#worker;
40
+ if (worker?.mode === "process") return worker.pid;
41
+ return this.#lastProcessPid;
42
+ }
43
+
37
44
  get present(): boolean {
38
45
  return this.#worker !== null;
39
46
  }
@@ -105,6 +112,7 @@ export class WorkerSlot {
105
112
  #publish(worker: WorkerLike, generation: number): void {
106
113
  if (!this.#listeners.isOpen() || generation !== this.#generation) throw new WorkerStartupCancelledError();
107
114
  this.#worker = worker;
115
+ if (worker.mode === "process") this.#lastProcessPid = worker.pid;
108
116
  const webViews = new KernelWebViewClients((message, transfer) => worker.postMessage(message, transfer));
109
117
  this.#webViews = webViews;
110
118
  worker.onMessage((message) => {
@@ -2,8 +2,10 @@ import { dirname, join } from "node:path";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
3
  import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
4
  import { createInlineWorker, type WorkerLike } from "./inline-worker.ts";
5
+ import { retireWorker } from "./interrupt-bounds.ts";
5
6
  import { resolveKernelToolNameSource } from "./kernel-contract.ts";
6
7
  import { type JavaScriptKernelOptions, localBridgeConnection } from "./local-module-loader.ts";
8
+ import { resolveJsProcessEntryUrl, spawnProcessWorker } from "./process-worker.ts";
7
9
  import { spawnNodeWorker, WorkerStartupCancelledError, waitForReady } from "./worker-host.ts";
8
10
 
9
11
  export interface JavaScriptWorkerEntryUrlOptions extends CodemodeRuntimeAssetEnvironment {
@@ -15,6 +17,21 @@ export function resolveJsWorkerEntryUrl(options: JavaScriptWorkerEntryUrlOptions
15
17
  return pathToFileURL(requireCodemodeRuntimeAsset(localPath, join("kernels", "js", "worker-entry.js"), options));
16
18
  }
17
19
 
20
+ /**
21
+ * How long a process-mode kernel child gets to report ready. A child that a starved host never schedules would
22
+ * otherwise hold every queued cell forever: no cell's own timeout is armed until the kernel is up.
23
+ */
24
+ export const PROCESS_STARTUP_DEADLINE_MS = 30_000;
25
+
26
+ export class ProcessKernelStartupTimeoutError extends Error {
27
+ constructor(deadlineMs: number) {
28
+ super(
29
+ `JavaScript kernel process did not become ready within ${deadlineMs / 1000}s; it was stopped, and the next cell starts a fresh one`,
30
+ );
31
+ this.name = "ProcessKernelStartupTimeoutError";
32
+ }
33
+ }
34
+
18
35
  export interface WorkerStartupHooks {
19
36
  readonly options: JavaScriptKernelOptions;
20
37
  readonly kernelGeneration: number;
@@ -36,6 +53,13 @@ export async function startWorkerWithInlineFallback(hooks: WorkerStartupHooks, s
36
53
  await worker.terminate();
37
54
  throw new WorkerStartupCancelledError();
38
55
  }
56
+ // Process mode never falls back: the user asked for isolation, so a failed start stays visible. The child that
57
+ // failed to start is stopped (bounded), so a stuck one is not left behind.
58
+ if (worker.mode === "process") {
59
+ hooks.retire(worker);
60
+ await retireWorker(worker);
61
+ throw error;
62
+ }
39
63
  if (worker.mode === "inline") throw error;
40
64
  hooks.retire(worker);
41
65
  await worker.terminate();
@@ -47,6 +71,15 @@ export async function startWorkerWithInlineFallback(hooks: WorkerStartupHooks, s
47
71
  }
48
72
 
49
73
  function spawnWorker(options: JavaScriptKernelOptions): WorkerLike {
74
+ if (options.isolation === "process") {
75
+ const url = resolveJsProcessEntryUrl();
76
+ return spawnProcessWorker(url, {
77
+ cwd: options.cwd,
78
+ parallelPoolWidth: options.parallelPoolWidth,
79
+ ...(options.processCommandPath === undefined ? {} : { searchPath: options.processCommandPath }),
80
+ ...(options.processExecPath === undefined ? {} : { execPath: options.processExecPath }),
81
+ });
82
+ }
50
83
  try {
51
84
  const url = options.workerEntryUrl ?? resolveJsWorkerEntryUrl();
52
85
  return spawnNodeWorker(url, options.cwd, options.parallelPoolWidth);
@@ -57,7 +90,10 @@ function spawnWorker(options: JavaScriptKernelOptions): WorkerLike {
57
90
  }
58
91
 
59
92
  async function initializeWorker(worker: WorkerLike, hooks: WorkerStartupHooks, signal: AbortSignal): Promise<void> {
60
- const ready = waitForReady(worker, signal);
93
+ const ready =
94
+ worker.mode === "process"
95
+ ? withStartupDeadline(waitForReady(worker, signal), hooks.options)
96
+ : waitForReady(worker, signal);
61
97
  const options = hooks.options;
62
98
  worker.postMessage({
63
99
  type: "init",
@@ -68,6 +104,18 @@ async function initializeWorker(worker: WorkerLike, hooks: WorkerStartupHooks, s
68
104
  foreignLanguageNames: resolveKernelToolNameSource(options.foreignLanguageNames),
69
105
  ...(options.sessionEnv === undefined ? {} : { sessionEnv: options.sessionEnv }),
70
106
  ...(options.memory === undefined ? {} : { memory: options.memory }),
107
+ ...(options.kernelToolsEnabled === false ? { kernelToolsDisabled: true } : {}),
71
108
  });
72
109
  await ready;
73
110
  }
111
+
112
+ function withStartupDeadline(ready: Promise<void>, options: JavaScriptKernelOptions): Promise<void> {
113
+ const deadlineMs = options.processStartupDeadlineMs ?? PROCESS_STARTUP_DEADLINE_MS;
114
+ let timer: NodeJS.Timeout | undefined;
115
+ const expired = new Promise<never>((_resolve, reject) => {
116
+ timer = setTimeout(() => reject(new ProcessKernelStartupTimeoutError(deadlineMs)), deadlineMs);
117
+ });
118
+ // When the deadline wins, the stopped child's readiness promise rejects later; that outcome is already reported.
119
+ ready.catch(() => {});
120
+ return Promise.race([ready, expired]).finally(() => clearTimeout(timer));
121
+ }
@@ -1,3 +1,4 @@
1
+ import { onCellRelease } from "./cell-run-context.js";
1
2
  import { DELIVER_EVENT, WebViewPortClient, webViewError } from "./worker-webview-client.js";
2
3
 
3
4
  // Bun constructs "chrome"-backend WebViews only on the process main thread, and this kernel runs in
@@ -5,6 +6,7 @@ import { DELIVER_EVENT, WebViewPortClient, webViewError } from "./worker-webview
5
6
  // `Bun` parameter bound to a stand-in whose `WebView` forwards Chrome-backed views to the
6
7
  // main-thread service over a private port; every other `Bun.*` member is the real one.
7
8
  const KERNEL_BUN = Symbol.for("senpi.kernel.bun");
9
+ const KERNEL_PROCESS_MODE = Symbol.for("senpi.kernel.processMode");
8
10
 
9
11
  export function bindKernelBun(source) {
10
12
  if (!globalThis[KERNEL_BUN]) return source;
@@ -17,6 +19,10 @@ export function installKernelWebView(requestPort) {
17
19
  globalThis[KERNEL_BUN] = shadowBun(bun, createKernelWebView(bun.WebView, requestPort));
18
20
  }
19
21
 
22
+ export function markKernelProcessMode() {
23
+ globalThis[KERNEL_PROCESS_MODE] = true;
24
+ }
25
+
20
26
  function shadowBun(real, WebView) {
21
27
  return new Proxy(
22
28
  {},
@@ -35,7 +41,10 @@ function shadowBun(real, WebView) {
35
41
  }
36
42
 
37
43
  // The macOS default (WebKit) stays a native worker view: its host process serves any thread.
44
+ // A process-mode kernel already runs its cells on the child's main thread, so the native
45
+ // WebView constructs there directly and no MessagePort proxy is ever needed.
38
46
  function needsMainThread(options) {
47
+ if (globalThis[KERNEL_PROCESS_MODE]) return false;
39
48
  const backend = options?.backend;
40
49
  if (backend === undefined) return process.platform !== "darwin";
41
50
  return backend === "chrome" || (typeof backend === "object" && backend !== null && backend.type === "chrome");
@@ -82,13 +91,16 @@ function createKernelWebView(NativeWebView, requestPort) {
82
91
  #console;
83
92
  #ready;
84
93
  #closed = false;
94
+ #forgetRelease;
85
95
  onNavigated = null;
86
96
  onNavigationFailed = null;
87
97
 
88
98
  constructor(options = {}) {
89
99
  if (!needsMainThread(options)) {
90
100
  const view = new NativeWebView(options);
91
- natives.add(new WeakRef(view));
101
+ const ref = new WeakRef(view);
102
+ natives.add(ref);
103
+ onCellRelease(() => ref.deref()?.close());
92
104
  return view;
93
105
  }
94
106
  super();
@@ -96,6 +108,7 @@ function createKernelWebView(NativeWebView, requestPort) {
96
108
  this.#console = capture;
97
109
  proxied.add(this);
98
110
  live.add(this);
111
+ this.#forgetRelease = onCellRelease(() => this.close());
99
112
  const viewId = this.#viewId;
100
113
  this.#ready = connect().then(async client => {
101
114
  client.register(viewId, this);
@@ -149,6 +162,7 @@ function createKernelWebView(NativeWebView, requestPort) {
149
162
  close() {
150
163
  if (this.#closed) return;
151
164
  this.#closed = true;
165
+ this.#forgetRelease?.();
152
166
  live.delete(this);
153
167
  const viewId = this.#viewId;
154
168
  this.#ready.then(
@@ -1,42 +1,19 @@
1
1
  import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
2
  import type { HostCellExecutor } from "../../tool/types.ts";
3
-
4
- type HostCellOutcome = Awaited<ReturnType<HostCellExecutor>>;
5
-
3
+ import { runHostCell as runSharedHostCell } from "../shared/host-cell.ts";
6
4
  import type { PendingRun, ResultMessage } from "./kernel-contract.ts";
7
- import { failedPythonResult } from "./transport.ts";
8
5
 
9
6
  export function runHostCell(
10
7
  pending: PendingRun,
11
8
  host: HostCellExecutor,
12
9
  io: { readonly emit: (message: KernelToHostMessage) => void; readonly settle: (result: ResultMessage) => void },
13
10
  ): void {
14
- const abort = new AbortController();
15
- pending.hostAbort = abort;
16
- const cellId = pending.input.cellId;
17
- let finished = false;
18
- // Output from an executor that was aborted, or after its outcome, belongs to no cell.
19
- const emit = (message: KernelToHostMessage): void => {
20
- if (!finished && !abort.signal.aborted) io.emit(message);
21
- };
22
- // Calling the executor inside a promise keeps a synchronous throw in this cell instead of failing the queue.
23
- const done = new Promise<HostCellOutcome>((resolve) => resolve(host({ signal: abort.signal, emit }))).then(
24
- (outcome): ResultMessage =>
25
- outcome.ok
26
- ? {
27
- type: "result",
28
- cellId,
29
- ok: true,
30
- durationMs: 0,
31
- ...(outcome.valueRepr === undefined ? {} : { valueRepr: outcome.valueRepr }),
32
- }
33
- : { type: "result", cellId, ok: false, error: outcome.error, durationMs: 0 },
34
- (error: unknown): ResultMessage =>
35
- failedPythonResult(cellId, error instanceof Error ? error.message : String(error)),
36
- );
37
- pending.hostDone = done;
38
- void done.then((result) => {
39
- finished = true;
40
- if (!abort.signal.aborted) io.settle(result);
11
+ const run = runSharedHostCell(pending.input.cellId, host, {
12
+ emit: io.emit,
13
+ settle: io.settle,
14
+ durationMs: () => 0,
15
+ settleAfterAbort: false,
41
16
  });
17
+ pending.hostAbort = run.abort;
18
+ pending.hostDone = run.done;
42
19
  }