@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
@@ -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(
@@ -61,6 +61,7 @@ RESERVED_HANDLE_STATUS_TOOL = "__handle_status__"
61
61
  RESERVED_HANDLE_OUTPUT_TOOL = "__handle_output__"
62
62
  RESERVED_HANDLE_SEND_TOOL = "__handle_send__"
63
63
  RESERVED_HANDLE_CANCEL_TOOL = "__handle_cancel__"
64
+ RESERVED_PACKAGES_INSTALL_TOOL = "__packages_install__"
64
65
  TIMEOUT_PAUSE_OP = "timeout-pause"
65
66
  TIMEOUT_RESUME_OP = "timeout-resume"
66
67
 
@@ -660,25 +661,6 @@ class ThreadRoutedStream(io.TextIOBase):
660
661
  )
661
662
  self.cell_target: io.StringIO | None = None
662
663
 
663
- @contextlib.contextmanager
664
- def capture(self, buffer: io.StringIO, *, cell: bool) -> Iterator[None]:
665
- me = threading.get_ident()
666
- previous = self._targets.get(me)
667
- self._targets[me] = buffer
668
- inherited = self._inherited.set(buffer)
669
- if cell:
670
- self.cell_target = buffer
671
- try:
672
- yield
673
- finally:
674
- self._inherited.reset(inherited)
675
- if previous is None:
676
- self._targets.pop(me, None)
677
- else:
678
- self._targets[me] = previous
679
- if cell:
680
- self.cell_target = None
681
-
682
664
  def write(self, text: str) -> int:
683
665
  target = self._targets.get(threading.get_ident()) or self._inherited.get() or self.cell_target
684
666
  if target is None:
@@ -693,6 +675,38 @@ class ThreadRoutedStream(io.TextIOBase):
693
675
  return None
694
676
 
695
677
 
678
+ @contextlib.contextmanager
679
+ def capture_streams(
680
+ streams: tuple[ThreadRoutedStream, ThreadRoutedStream],
681
+ buffers: tuple[io.StringIO, io.StringIO],
682
+ *,
683
+ cell: bool,
684
+ ) -> Iterator[None]:
685
+ stdout, stderr = streams
686
+ out, err = buffers
687
+ me = threading.get_ident()
688
+ previous_out, previous_err = stdout._targets.get(me), stderr._targets.get(me)
689
+ stdout._targets[me], stderr._targets[me] = out, err
690
+ inherited_out, inherited_err = stdout._inherited.set(out), stderr._inherited.set(err)
691
+ if cell:
692
+ stdout.cell_target, stderr.cell_target = out, err
693
+ try:
694
+ yield
695
+ finally:
696
+ stderr._inherited.reset(inherited_err)
697
+ stdout._inherited.reset(inherited_out)
698
+ if previous_err is None:
699
+ stderr._targets.pop(me, None)
700
+ else:
701
+ stderr._targets[me] = previous_err
702
+ if previous_out is None:
703
+ stdout._targets.pop(me, None)
704
+ else:
705
+ stdout._targets[me] = previous_out
706
+ if cell:
707
+ stdout.cell_target, stderr.cell_target = None, None
708
+
709
+
696
710
  @dataclass
697
711
  class _Entry:
698
712
  name: str
@@ -704,7 +718,8 @@ class _Entry:
704
718
 
705
719
 
706
720
  class Registry:
707
- def __init__(self, on_change: Callable[[list[str]], None]) -> None:
721
+ def __init__(self, on_change: Callable[[list[str]], None], *, disabled: bool = False) -> None:
722
+ self._disabled = disabled
708
723
  self._lock = threading.Lock()
709
724
  self._entries: dict[str, _Entry] = {}
710
725
  # Revisions outlive undefine: a redefined name never reuses a revision an old descriptor carries.
@@ -713,6 +728,8 @@ class Registry:
713
728
  self.generation = 1
714
729
 
715
730
  def define(self, fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
731
+ if self._disabled:
732
+ raise KernelToolError("tools_unavailable", "kernel tools are turned off for this project (kernelTools.enabled is false)")
716
733
  inferred = infer_tool(fn, name=name, description=description, schema=schema)
717
734
  key = tool_key(inferred["name"])
718
735
  with self._lock:
@@ -901,8 +918,7 @@ class KernelToolRunner:
901
918
  if invocation.cancelled.is_set():
902
919
  raise Cancelled(CANCELLED_BEFORE_START)
903
920
  CURRENT.set(invocation)
904
- stdout, stderr = self._streams
905
- with stdout.capture(output, cell=False), stderr.capture(output, cell=False):
921
+ with capture_streams(self._streams, (output, output), cell=False):
906
922
  value = self._call(invocation, entry.fn, args)
907
923
  finally:
908
924
  CURRENT.set(None)
@@ -983,7 +999,12 @@ async def _awaited(awaitable: Any) -> Any:
983
999
 
984
1000
  KERNEL_TOOL_TOKEN = OwnerToken()
985
1001
  KERNEL_TOOL_STREAMS = (ThreadRoutedStream(stderr=False), ThreadRoutedStream(stderr=True))
986
- KERNEL_TOOL_REGISTRY = Registry(lambda names: emit({"type": "kernel-tools-defined", "names": names}))
1002
+ # kernelTools.enabled is read once, while the prelude loads and before any cell runs, then removed from the
1003
+ # environment: rewriting os.environ cannot turn kernel tools back on, and cell subprocesses never see the switch.
1004
+ KERNEL_TOOL_REGISTRY = Registry(
1005
+ lambda names: emit({"type": "kernel-tools-defined", "names": names}),
1006
+ disabled=os.environ.pop("SENPI_CODEMODE_KERNEL_TOOLS", None) == "0",
1007
+ )
987
1008
  KERNEL_TOOL_RUNNER = KernelToolRunner(emit, KERNEL_TOOL_TOKEN, KERNEL_TOOL_REGISTRY, KERNEL_TOOL_STREAMS)
988
1009
 
989
1010
 
@@ -1154,6 +1175,27 @@ def tool_schema(name: str | None = None) -> Any:
1154
1175
  )
1155
1176
 
1156
1177
 
1178
+ class _Packages:
1179
+ """packages.install(manager, requirements, *, timeout=600): the %pip installer as a call; returns its receipt."""
1180
+
1181
+ __slots__ = ()
1182
+
1183
+ def install(self, manager: str, requirements: Any, *, timeout: float | None = None) -> Any:
1184
+ args: dict[str, Any] = {"manager": manager, "requirements": requirements}
1185
+ if timeout is not None:
1186
+ args["timeout"] = timeout
1187
+ # A long install must not hit the 60 s socket cap; the host bounds it by the timeout (600 s by default).
1188
+ socket_timeout = (600 if timeout is None else float(timeout)) + _WAIT_SOCKET_GRACE_SECONDS
1189
+ return bridge_post(
1190
+ "/call",
1191
+ {"callId": f"py-{uuid.uuid4()}", "toolName": RESERVED_PACKAGES_INSTALL_TOOL, "args": args},
1192
+ socket_timeout=socket_timeout,
1193
+ )
1194
+
1195
+
1196
+ packages = _Packages()
1197
+
1198
+
1157
1199
  def output(
1158
1200
  *ids: str,
1159
1201
  format: str = "raw",
@@ -1781,6 +1823,7 @@ USER_NS.update(
1781
1823
  "workpool": workpool,
1782
1824
  "output": output,
1783
1825
  "tool_schema": tool_schema,
1826
+ "packages": packages,
1784
1827
  "wait": wait,
1785
1828
  "handle": handle,
1786
1829
  "__senpi_magic": _magic,
@@ -2187,8 +2230,7 @@ def run_cell(
2187
2230
  cell_scope = CURRENT_CELL_TOKEN.set(cell_token)
2188
2231
  try:
2189
2232
  KERNEL_TOOL_TOKEN.acquire()
2190
- cell_stdout, cell_stderr = KERNEL_TOOL_STREAMS
2191
- with cell_stdout.capture(stdout, cell=True), cell_stderr.capture(stderr, cell=True):
2233
+ with capture_streams(KERNEL_TOOL_STREAMS, (stdout, stderr), cell=True):
2192
2234
  apply_preludes(preludes)
2193
2235
  leave_source_file = _enter_source_file(source_file)
2194
2236
  body, expression = compile_cell(code, source_file or "<cell>")
@@ -2342,7 +2384,7 @@ def _start_parent_watch() -> None:
2342
2384
  ).start()
2343
2385
 
2344
2386
 
2345
- def _read_control(commands: queue.Queue[tuple[str, Any]]) -> None:
2387
+ def _read_control(commands: queue.SimpleQueue[tuple[str, Any]]) -> None:
2346
2388
  # Kernel-tool frames are served here, so a callback can be admitted while the main thread is inside a cell.
2347
2389
  for raw in sys.stdin:
2348
2390
  try:
@@ -2361,7 +2403,7 @@ def main() -> None:
2361
2403
  signal.signal(signal.SIGINT, signal.SIG_IGN)
2362
2404
  _start_parent_watch()
2363
2405
  sys.stdout, sys.stderr = KERNEL_TOOL_STREAMS
2364
- commands: queue.Queue[tuple[str, Any]] = queue.Queue()
2406
+ commands: queue.SimpleQueue[tuple[str, Any]] = queue.SimpleQueue()
2365
2407
  threading.Thread(target=_read_control, args=(commands,), name="senpi-control-reader", daemon=True).start()
2366
2408
  host_closed = False
2367
2409
  while True:
@@ -1,4 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { isAbsolute } from "node:path";
2
4
  import type { Readable, Writable } from "node:stream";
3
5
 
4
6
  export interface KernelChild {
@@ -53,6 +55,8 @@ export function defaultSpawn(options: KernelSpawnOptions): KernelChild {
53
55
  }
54
56
 
55
57
  export function splitCommand(commandLine: string): { readonly command: string; readonly args: readonly string[] } {
58
+ // A configured interpreter path is one executable even when it contains spaces.
59
+ if (isAbsolute(commandLine) && existsSync(commandLine)) return { command: commandLine, args: [] };
56
60
  const [command, ...args] = commandLine.split(" ").filter(Boolean);
57
61
  if (!command) throw new Error("Python interpreter path is empty");
58
62
  return { command, args };
@@ -244,6 +244,10 @@ export class PythonKernelTransport {
244
244
  this.#child.stderr.on("data", onStderr);
245
245
  this.#child.on("error", onError);
246
246
  this.#child.on("exit", onExit);
247
+ // senpi#3016: a frame written while the interpreter is dying fails on the stdin stream (EPIPE), not on the child.
248
+ // #onError already ignores it once the child has exited or the transport is inactive, and the listener is never
249
+ // detached, so a late write failure is never unhandled.
250
+ this.#child.stdin.on("error", onError);
247
251
  const { sessionId, connection, memory, kernelGeneration } = this.#options;
248
252
  this.#write({
249
253
  type: "init",
@@ -9,6 +9,7 @@ $stdout.flush
9
9
  $__senpi_binding = TOPLEVEL_BINDING
10
10
  $__senpi_frame_mutex = Mutex.new
11
11
  $__senpi_current_cell = nil
12
+ $__senpi_memory_cell = nil
12
13
  $__senpi_capture_cell = nil
13
14
  $__senpi_connection = nil
14
15
  $__senpi_frame_io = STDOUT.dup
@@ -123,8 +124,6 @@ rescue JSON::GeneratorError
123
124
  value.inspect
124
125
  end
125
126
 
126
- $__senpi_memory = nil
127
-
128
127
  SENPI_SIZER_SAMPLE = 1_000
129
128
  SENPI_SIZER_NODE_BUDGET = 5_000
130
129
  SENPI_SIZER_MAX_DEPTH = 64
@@ -239,19 +238,6 @@ rescue StandardError
239
238
  []
240
239
  end
241
240
 
242
- # The host reads the interpreter's footprint and decides on the notice; the runner only names its
243
- # largest globals. Nothing here spawns a process, so the user's `$?` is left alone.
244
- def __senpi_memory_report
245
- return nil if $__senpi_memory.nil?
246
- named = __senpi_largest_globals(5)
247
- # The host replaces liveBytes with the interpreter footprint it reads itself; only globals is used.
248
- report = { "liveBytes" => 0, "measure" => "footprint" }
249
- report["globals"] = named unless named.empty?
250
- report
251
- rescue StandardError
252
- nil
253
- end
254
-
255
241
  SENPI_NON_DISPLAY_NODES = %i[
256
242
  LASGN IASGN GASGN CVASGN DASGN OP_ASGN OP_CDECL CDECL MASGN CASGN
257
243
  DEFN DEFS CLASS MODULE SCLASS ALIAS UNDEF
@@ -305,8 +291,6 @@ def __senpi_run_cell(message)
305
291
  "durationMs" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
306
292
  }
307
293
  frame["valueRepr"] = __senpi_value_repr(value) if !value.nil? && __senpi_should_display_result?(source)
308
- memory = __senpi_memory_report
309
- frame["memory"] = memory unless memory.nil?
310
294
  __senpi_emit(frame)
311
295
  rescue Exception => error
312
296
  __senpi_emit({
@@ -319,6 +303,7 @@ def __senpi_run_cell(message)
319
303
  ensure
320
304
  $__senpi_capture_cell = nil
321
305
  $__senpi_current_cell = nil
306
+ $__senpi_memory_cell = cell_id
322
307
  end
323
308
  end
324
309
 
@@ -332,10 +317,15 @@ $__senpi_protocol_stdin.each_line do |line|
332
317
  when "init"
333
318
  __senpi_emit({ "type" => "status", "event" => { "op" => "kernel-startup", "stage" => "host-init" } })
334
319
  $__senpi_connection = message["connection"]
335
- $__senpi_memory = message["memory"]
336
- __senpi_emit({ "type" => "ready" })
320
+ __senpi_emit({ "type" => "ready", "memoryGlobals" => true })
337
321
  when "run"
322
+ $__senpi_memory_cell = nil
338
323
  __senpi_run_cell(message)
324
+ when "memory-globals"
325
+ if message["cellId"] == $__senpi_memory_cell && $__senpi_current_cell.nil?
326
+ __senpi_emit({ "type" => "memory-globals-result", "cellId" => message["cellId"], "globals" => __senpi_largest_globals(5) })
327
+ $__senpi_memory_cell = nil
328
+ end
339
329
  when "close"
340
330
  __senpi_emit({ "type" => "closed" })
341
331
  exit 0