@code-yeongyu/senpi-codemode 2026.10.8 → 2026.10.10-10

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 (106) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +212 -14
  3. package/package.json +6 -5
  4. package/src/bridge/http-server.ts +9 -1
  5. package/src/bridge/protocol.ts +12 -3
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/bridges/eval-environment-schemas.ts +48 -0
  8. package/src/bridges/eval-virtual-schemas.ts +12 -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/js-environments.ts +208 -0
  14. package/src/environments/js-installer.ts +268 -0
  15. package/src/environments/js-revision-files.ts +91 -0
  16. package/src/environments/no-links.ts +132 -0
  17. package/src/environments/packages-install.ts +98 -0
  18. package/src/environments/project-resolves.ts +54 -0
  19. package/src/environments/py-environment.ts +7 -3
  20. package/src/environments/py-installer.ts +68 -4
  21. package/src/environments/python-environments.ts +85 -0
  22. package/src/environments/revision-store.ts +38 -7
  23. package/src/extension/bridge-tool-call.ts +7 -1
  24. package/src/extension/cell-kernel-tools.ts +28 -0
  25. package/src/extension/eval-notifier.ts +13 -2
  26. package/src/extension/kernel-registration.ts +7 -2
  27. package/src/extension/kernel-replacement.ts +20 -0
  28. package/src/extension/runtime-factory.ts +53 -3
  29. package/src/extension/runtime-info.ts +6 -2
  30. package/src/extension/session-manager-contract.ts +3 -0
  31. package/src/extension/session-manager-proxy.ts +6 -0
  32. package/src/extension/session-manager.ts +26 -4
  33. package/src/index.ts +4 -2
  34. package/src/interpreters/detect.ts +34 -1
  35. package/src/kernels/AGENTS.md +2 -0
  36. package/src/kernels/jl/prelude.jl +6 -1
  37. package/src/kernels/js/cell-dispatch.ts +44 -0
  38. package/src/kernels/js/cell-ownership.js +268 -0
  39. package/src/kernels/js/cell-run-context.js +89 -0
  40. package/src/kernels/js/context-manager.ts +48 -39
  41. package/src/kernels/js/control-frames.ts +24 -0
  42. package/src/kernels/js/host-entries.ts +62 -0
  43. package/src/kernels/js/kernel-contract.ts +16 -1
  44. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  45. package/src/kernels/js/kernel-memory.ts +79 -0
  46. package/src/kernels/js/kernel-tools-registry.js +1 -0
  47. package/src/kernels/js/local-module-loader.ts +21 -5
  48. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  49. package/src/kernels/js/process-drain-scan.js +28 -0
  50. package/src/kernels/js/process-entry.js +466 -0
  51. package/src/kernels/js/process-memory-host.ts +83 -0
  52. package/src/kernels/js/process-worker.ts +298 -0
  53. package/src/kernels/js/rejection-reports.js +61 -0
  54. package/src/kernels/js/run-queue.ts +7 -0
  55. package/src/kernels/js/worker-core.js +53 -8
  56. package/src/kernels/js/worker-cwd.js +5 -2
  57. package/src/kernels/js/worker-host.ts +9 -2
  58. package/src/kernels/js/worker-indirect-eval.js +37 -13
  59. package/src/kernels/js/worker-package-resolve.js +153 -0
  60. package/src/kernels/js/worker-require.d.ts +19 -0
  61. package/src/kernels/js/worker-require.js +52 -0
  62. package/src/kernels/js/worker-runtime.js +45 -9
  63. package/src/kernels/js/worker-shadow-guard.js +2 -6
  64. package/src/kernels/js/worker-shell-capture.js +5 -0
  65. package/src/kernels/js/worker-slot.ts +9 -0
  66. package/src/kernels/js/worker-startup.ts +49 -1
  67. package/src/kernels/js/worker-webview.js +15 -1
  68. package/src/kernels/js/workpool.js +5 -2
  69. package/src/kernels/py/host-cell.ts +8 -31
  70. package/src/kernels/py/kernel.ts +8 -2
  71. package/src/kernels/py/prelude.py +145 -11
  72. package/src/kernels/py/process.ts +4 -0
  73. package/src/kernels/py/transport.ts +13 -1
  74. package/src/kernels/rb/workpool.rb +7 -1
  75. package/src/kernels/sandbox/sandbox-cell.ts +141 -0
  76. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  77. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  78. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  79. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  80. package/src/kernels/shared/cell-source-at-start.ts +18 -0
  81. package/src/kernels/shared/host-cell.ts +57 -0
  82. package/src/kernels/shared/subprocess-process.ts +14 -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 +13 -12
  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 +4 -8
  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 +13 -7
  93. package/src/tool/detached-notification-queue.ts +1 -2
  94. package/src/tool/eval-request.ts +23 -1
  95. package/src/tool/eval-tool-options.ts +6 -0
  96. package/src/tool/eval-tool.ts +17 -4
  97. package/src/tool/interrupt-note.ts +4 -5
  98. package/src/tool/live-headline.ts +60 -0
  99. package/src/tool/load-cell.ts +88 -0
  100. package/src/tool/magic-cell-host.ts +123 -0
  101. package/src/tool/magic-cells.ts +75 -0
  102. package/src/tool/managed-cell.ts +0 -4
  103. package/src/tool/render.ts +74 -15
  104. package/src/tool/run-eval-cell.ts +73 -1
  105. package/src/tool/runtime-label.ts +2 -1
  106. package/src/tool/types.ts +50 -1
@@ -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";
@@ -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,4 +1,6 @@
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";
@@ -31,6 +33,7 @@ export class JsWorkerRuntime {
31
33
  #hooks = null;
32
34
  #pendingDisplays = [];
33
35
  #children = new Set();
36
+ #childrenStopping;
34
37
  #shellWaits = new Set();
35
38
  #onChildEvent;
36
39
  #onShellWaitChange;
@@ -47,6 +50,7 @@ export class JsWorkerRuntime {
47
50
  generation: options.kernelGeneration ?? 1,
48
51
  hostToolNames: options.hostToolNames ?? [],
49
52
  foreignLanguageNames: options.foreignLanguageNames ?? [],
53
+ disabled: options.kernelToolsDisabled === true,
50
54
  });
51
55
  this.#installGlobals();
52
56
  }
@@ -65,30 +69,43 @@ export class JsWorkerRuntime {
65
69
  try {
66
70
  let prelude = "";
67
71
  let cellCode = code;
72
+ let sourceName = cellId;
68
73
  if (code.startsWith(PREPARED_CELL_PREFIX)) {
69
74
  const prepared = JSON.parse(code.slice(PREPARED_CELL_PREFIX.length));
70
75
  if (!isPlainObject(prepared) || typeof prepared.prelude !== "string" || typeof prepared.code !== "string") throw new Error("Invalid prepared JavaScript cell payload");
71
76
  ({ prelude, code: cellCode } = prepared);
77
+ if (typeof prepared.sourceFile === "string") sourceName = prepared.sourceFile;
72
78
  }
73
79
  if (prelude) indirectEval(prelude, PRELUDE_SOURCE_URL);
74
- const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), cellId));
80
+ const shadowed = [];
81
+ const source = bindKernelBun(wrapUserCode(cellCode, shadowed));
82
+ if (shadowed.length > 0) this.#emitText("stderr", shadowNote(shadowed));
83
+ const value = await awaitMaybePromise(indirectEval(source, sourceName));
75
84
  await this.#drainPendingDisplays();
76
85
  return value;
77
86
  } finally {
78
- this.#pendingDisplays = [];
79
- // A child still running here has lost its only owner: the cell that
80
- // spawned it is over, nothing will await it again, and it would be
81
- // reparented to init. Retire it the way timeout and abort cleanup
82
- // already do, unless the cell asked for a detached process.
83
- await this.#terminateChildren();
84
- this.#hooks = null;
87
+ // A released run finishing late must not clear the state of the cell running now.
88
+ if (this.#hooks === hooks) await this.release();
85
89
  }
86
90
  }
87
91
 
92
+ /** Ends the current run's ownership now: its displays, its hooks, and (unless detached) its child processes. */
93
+ async release() {
94
+ const hooks = this.#hooks;
95
+ this.#pendingDisplays = [];
96
+ // A child still running here has lost its only owner: the cell that
97
+ // spawned it is over, nothing will await it again, and it would be
98
+ // reparented to init. Retire it the way timeout and abort cleanup
99
+ // already do, unless the cell asked for a detached process.
100
+ await Promise.all([this.#childrenStopping, this.#terminateChildren()]);
101
+ this.#childrenStopping = undefined;
102
+ if (this.#hooks === hooks) this.#hooks = null;
103
+ }
104
+
88
105
  interrupt() {
89
106
  // The tree snapshot, SIGTERM, and SIGKILL escalation run on their own so
90
107
  // the caller's interrupt latency stays that of the acknowledgement.
91
- void this.#terminateChildren();
108
+ this.#childrenStopping = this.#terminateChildren();
92
109
  }
93
110
 
94
111
  #trackChild(child, spawnOptions) {
@@ -145,6 +162,17 @@ export class JsWorkerRuntime {
145
162
  return isPlainObject(opts) && opts.handle === true ? handles.handle(value) : value;
146
163
  };
147
164
  globalThis.wait = async (list, options) => await handles.wait(list, options);
165
+ // The `%bun add` / `%npm add` installer as a call: same environment, receipt, cancellation and codes (senpi row 38).
166
+ globalThis.packages = Object.freeze({
167
+ install: async (manager, requirements, options = {}) => {
168
+ if (!isPlainObject(options)) throw new TypeError("packages.install(): options must be an object, e.g. { timeout: 120 }");
169
+ return await this.#callTool("__packages_install__", {
170
+ manager,
171
+ requirements,
172
+ ...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
173
+ });
174
+ },
175
+ });
148
176
  globalThis.handle = value => handles.handle(value);
149
177
  globalThis.tool = createToolNamespace(
150
178
  (fn, metadata) => this.#tools.define(fn, metadata),
@@ -152,6 +180,8 @@ export class JsWorkerRuntime {
152
180
  { defined: () => this.#tools.defined(), undefine: (name) => this.#tools.undefine(name) },
153
181
  );
154
182
  globalThis.tools = globalThis.tool;
183
+ globalThis.require = createCellRequire(() => globalThis.__senpi_module_context__ ?? { cwdUrl: pathToFileURL(`${process.cwd()}/`).href });
184
+ globalThis.createRequire = createRequire;
155
185
  const originalLog = console.log.bind(console);
156
186
  const originalError = console.error.bind(console);
157
187
  const originalStdoutWrite = process.stdout.write;
@@ -420,6 +450,12 @@ export class JsWorkerRuntime {
420
450
  }
421
451
  }
422
452
 
453
+ function shadowNote(names) {
454
+ const list = names.map((name) => `\`${name}\``).join(", ");
455
+ const restore = names.map((name) => `delete ${name}`).join("; ");
456
+ 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`;
457
+ }
458
+
423
459
  function isPlainObject(value) {
424
460
  return value !== null && typeof value === "object" && !Array.isArray(value);
425
461
  }
@@ -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,5 @@
1
+ import { assertCellLive } from "./cell-run-context.js";
2
+
1
3
  const SHELL_CONFIG_METHODS = ["env", "cwd", "nothrow", "throws"];
2
4
  const SHELL_READ_METHODS = ["text", "json", "lines", "arrayBuffer", "bytes", "blob"];
3
5
  // `true | ( … )` hands every command in the template an empty pipe as stdin. The worker thread shares
@@ -39,6 +41,7 @@ function isBunRuntime(bun) {
39
41
 
40
42
  function capturedShell(originalShell, options) {
41
43
  const shell = (strings, ...expressions) => {
44
+ assertCellLive();
42
45
  if (!options.isActive()) return originalShell(strings, ...expressions);
43
46
  const promise = originalShell(isolateStdin(strings), ...expressions);
44
47
  return captureShellPromise(promise, options);
@@ -132,6 +135,7 @@ function outputText(value) {
132
135
  // without an explicit env must get the worker's view pinned too (measured on Bun 1.4.0).
133
136
  function capturedSpawnSync(originalSpawnSync, pinEnv) {
134
137
  return (...args) => {
138
+ assertCellLive();
135
139
  if (!pinEnv) return originalSpawnSync(...args);
136
140
  const [first, second] = args;
137
141
  if (Array.isArray(first)) {
@@ -148,6 +152,7 @@ function capturedSpawnSync(originalSpawnSync, pinEnv) {
148
152
 
149
153
  function capturedSpawn(originalSpawn, options, pinEnv) {
150
154
  return (...args) => {
155
+ assertCellLive();
151
156
  if (!options.isActive()) return originalSpawn(...args);
152
157
  const [first, second] = args;
153
158
  let child;
@@ -4,6 +4,7 @@ import type { WorkerLike } from "./inline-worker.ts";
4
4
  import { retireWorker, type WorkerRetirement } from "./interrupt-bounds.ts";
5
5
  import type { JavaScriptKernelMode } from "./kernel-contract.ts";
6
6
  import type { JavaScriptKernelOptions } from "./local-module-loader.ts";
7
+ import type { JavaScriptProcessWorker } from "./process-worker.ts";
7
8
  import { KernelWebViewClients } from "./webview-host.ts";
8
9
  import { WorkerStartupCancelledError } from "./worker-host.ts";
9
10
  import { startWorkerWithInlineFallback } from "./worker-startup.ts";
@@ -19,6 +20,7 @@ export class WorkerSlot {
19
20
  readonly #options: JavaScriptKernelOptions;
20
21
  readonly #listeners: WorkerSlotListeners;
21
22
  #worker: WorkerLike | null = null;
23
+ #lastProcessPid: number | undefined;
22
24
  #webViews: KernelWebViewClients | null = null;
23
25
  #mode: JavaScriptKernelMode = "worker";
24
26
  #generation = 0;
@@ -34,6 +36,12 @@ export class WorkerSlot {
34
36
  return this.#mode;
35
37
  }
36
38
 
39
+ get processPid(): number | undefined {
40
+ const worker = this.#worker;
41
+ if (worker?.mode === "process") return (worker as JavaScriptProcessWorker).pid;
42
+ return this.#lastProcessPid;
43
+ }
44
+
37
45
  get present(): boolean {
38
46
  return this.#worker !== null;
39
47
  }
@@ -105,6 +113,7 @@ export class WorkerSlot {
105
113
  #publish(worker: WorkerLike, generation: number): void {
106
114
  if (!this.#listeners.isOpen() || generation !== this.#generation) throw new WorkerStartupCancelledError();
107
115
  this.#worker = worker;
116
+ if (worker.mode === "process") this.#lastProcessPid = (worker as JavaScriptProcessWorker).pid;
108
117
  const webViews = new KernelWebViewClients((message, transfer) => worker.postMessage(message, transfer));
109
118
  this.#webViews = webViews;
110
119
  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(
@@ -4,8 +4,11 @@ import { kernelToolError } from "./kernel-tools-errors.js";
4
4
  // Only host-tool sugar: no admission, worker, or queue state belongs to the kernel.
5
5
  export async function createWorkpool(callTool, agent, name, options = {}) {
6
6
  if (inKernelToolInvoke()) throw kernelToolError("kernel_tool_recursion", "kernel tools may not invoke workpool()");
7
- if (options === null || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some(key => key !== "mode")) {
8
- throw new TypeError("workpool() options only accept mode");
7
+ if (options === null || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some(key => key !== "mode" && key !== "tools")) {
8
+ throw new TypeError("workpool() options only accept mode and tools");
9
+ }
10
+ if (options.tools !== undefined && (!Array.isArray(options.tools) || !options.tools.every(name => typeof name === "string"))) {
11
+ throw workpoolError("invalid_tools", `workpool() tools takes an array of tool names; got ${Array.isArray(options.tools) ? "an array with a non-string" : typeof options.tools}`);
9
12
  }
10
13
  const call = async args => {
11
14
  try {
@@ -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
  }
@@ -5,6 +5,7 @@ import type {
5
5
  KernelToolsInvokeOptions,
6
6
  KernelToolsInvokeRequest,
7
7
  } from "../js/kernel-tools-types.ts";
8
+ import { inputAtStart } from "../shared/cell-source-at-start.ts";
8
9
  import { describeExit } from "../shared/kernel-death.ts";
9
10
  import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
10
11
  import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
@@ -243,10 +244,15 @@ export class PythonKernel {
243
244
  });
244
245
  return;
245
246
  }
247
+ const input = inputAtStart(pending.input);
248
+ if ("refused" in input) {
249
+ this.#settleRun(pending, failedPythonResult(pending.input.cellId, input.refused));
250
+ return;
251
+ }
246
252
  try {
247
253
  this.#transport?.run({
248
- ...pending.input,
249
- preludePlan: this.#preludes.plan(pending.input.kernelPreludes ?? []),
254
+ ...input,
255
+ preludePlan: this.#preludes.plan(input.kernelPreludes ?? []),
250
256
  });
251
257
  } catch (error) {
252
258
  const failure = error instanceof Error ? error : new Error(String(error));