@code-yeongyu/senpi-codemode 2026.10.10-3 → 2026.10.10-5

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.
@@ -1,3 +1,5 @@
1
+ import { installCellOwnership } from "./cell-ownership.js";
2
+ import { releaseCell, releasedCellError, runInCell } from "./cell-run-context.js";
1
3
  import { kernelToolCallContext } from "./kernel-tools-context.js";
2
4
  import { kernelToolError } from "./kernel-tools-errors.js";
3
5
  import { createKernelToolPump } from "./kernel-tools-pump.js";
@@ -5,6 +7,7 @@ import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
5
7
  import { installSessionCwd } from "./worker-cwd.js";
6
8
  import { installPackageResolver } from "./worker-package-resolve.js";
7
9
  import { createHeapProbe } from "./worker-heap.js";
10
+ import { createRejectionReports } from "./rejection-reports.js";
8
11
  import { createWorkerMemory } from "./worker-memory.js";
9
12
  import { JsWorkerRuntime } from "./worker-runtime.js";
10
13
  import { installKernelWebView } from "./worker-webview.js";
@@ -29,10 +32,17 @@ const SESSION_ENVIRONMENT_KEYS = [
29
32
  ];
30
33
 
31
34
  export function createWorkerCore(transport, options) {
35
+ const restoreOwnership = installCellOwnership();
32
36
  let runtime = null;
33
37
  let memory = null;
34
38
  let heapProbe = null;
35
39
  let activeCell = null;
40
+ const rejections = createRejectionReports({
41
+ activeCell: () => activeCell,
42
+ emitText: (data) => emit({ type: "text", stream: "stderr", data }),
43
+ });
44
+ const reportRejection = (reason) => rejections.report(reason);
45
+ process.on("unhandledRejection", reportRejection);
36
46
  const pendingTools = new Map();
37
47
  const pendingWebViewPorts = new Map();
38
48
  const nestedInvokes = new Map();
@@ -46,23 +56,38 @@ export function createWorkerCore(transport, options) {
46
56
  transport.send(message);
47
57
  }
48
58
 
59
+ function stopProblem(cell, what, error) {
60
+ const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
61
+ emit({ type: "text", stream: "stderr", data: `Stopping cell ${cell.cellId}: ${what}: ${detail}\n` });
62
+ }
63
+
49
64
  async function runCell(message) {
50
65
  if (!runtime) {
51
66
  emit({ type: "result", cellId: message.cellId, ok: false, error: { message: "JS runtime not initialized" }, durationMs: 0 });
52
67
  return;
53
68
  }
54
69
  const startedAtMs = performance.now();
55
- activeCell = { cellId: message.cellId, interruption: null };
70
+ const release = Promise.withResolvers();
71
+ const cell = { cellId: message.cellId, interruption: null, released: false, release: release.reject };
72
+ activeCell = cell;
73
+ rejections.startCell(cell);
56
74
  try {
57
- const value = await runtime.run(message.code, message.cellId, {
58
- emit,
59
- callTool: async (toolName, args) => await callTool(toolName, args),
60
- });
75
+ const run = runInCell(cell, () =>
76
+ runtime.run(message.code, message.cellId, {
77
+ emit: (event) => {
78
+ if (releasedCellError() === undefined) emit(event);
79
+ },
80
+ callTool: async (toolName, args) => await callTool(toolName, args),
81
+ }),
82
+ );
83
+ const value = await Promise.race([run, release.promise]);
84
+ rejections.finishCell(cell);
61
85
  emit({ type: "result", cellId: message.cellId, ok: true, valueRepr: valueRepr(value), durationMs: durationMs(startedAtMs), ...memoryReport() });
62
86
  } catch (error) {
87
+ rejections.finishCell(cell);
63
88
  emit({ type: "result", cellId: message.cellId, ok: false, error: bridgeError(error), durationMs: durationMs(startedAtMs), ...memoryReport() });
64
89
  } finally {
65
- activeCell = null;
90
+ if (activeCell === cell) activeCell = null;
66
91
  }
67
92
  }
68
93
 
@@ -71,6 +96,8 @@ export function createWorkerCore(transport, options) {
71
96
  }
72
97
 
73
98
  async function callTool(toolName, args) {
99
+ const released = releasedCellError();
100
+ if (released !== undefined) throw released;
74
101
  const nested = kernelToolCallContext.getStore();
75
102
  if (!nested && activeCell?.interruption) throw activeCell.interruption;
76
103
  if (nested?.signal.aborted) throw nested.signal.reason;
@@ -105,6 +132,17 @@ export function createWorkerCore(transport, options) {
105
132
  }
106
133
  kernelTools.abortAll(kernelToolError("kernel_tool_stale", interruption.message));
107
134
  runtime.interrupt();
135
+ // A free event loop with no Bun.$ wait can let the cell go and keep the VM. A Bun.$ wait keeps the
136
+ // restart (#2453): the shell cannot be cancelled, so only retiring the worker ends it.
137
+ if (runtime.shellWaitActive) return;
138
+ const cell = activeCell;
139
+ for (const error of releaseCell(cell)) stopProblem(cell, "a resource it opened would not close", error);
140
+ // The result settles after the cell's children are gone, so the next cell never overlaps them. A failed release
141
+ // is reported as such, never left to surface as an unhandled rejection blamed on the stopped cell.
142
+ void runtime
143
+ .release()
144
+ .catch((error) => stopProblem(cell, "releasing its work failed", error))
145
+ .finally(() => cell.release(interruption));
108
146
  }
109
147
 
110
148
  function acknowledgeInterrupt() {
@@ -186,6 +224,8 @@ export function createWorkerCore(transport, options) {
186
224
  return {
187
225
  dispose() {
188
226
  unsubscribe();
227
+ restoreOwnership();
228
+ process.off("unhandledRejection", reportRejection);
189
229
  globalThis.__senpi_restore_console__?.();
190
230
  },
191
231
  };
@@ -1,4 +1,13 @@
1
- import { findShadowedGlobalName } from "./worker-shadow-guard.js";
1
+ import { isKernelGlobal } from "./worker-shadow-guard.js";
2
+
3
+ // A cell may declare a name the kernel or the platform already defines (`const log = []`, `const fetch = ...`). The
4
+ // binding lives in a per-kernel user scope that later cells read through `with`, so it persists for the user's cells
5
+ // while the kernel, its prelude and imported libraries keep the original global. `delete <name>` in a cell restores it.
6
+ const USER_SCOPE_KEY = Symbol.for("senpi.kernel.userScope");
7
+ const USER_SCOPE = "__senpiUserScope";
8
+ // The persistence rewrite itself writes through `globalThis`, so that one name cannot be shadowed.
9
+ const UNSHADOWABLE = new Set(["globalThis"]);
10
+ let shadowedNames = null;
2
11
 
3
12
  export function indirectEval(source, filename) {
4
13
  const withPragma = filename ? `${source}\n//# sourceURL=${filename}` : source;
@@ -11,10 +20,31 @@ export async function awaitMaybePromise(value) {
11
20
  return await value;
12
21
  }
13
22
 
14
- export function wrapUserCode(code) {
15
- const persistentCode = persistTopLevelDeclarations(code);
16
- if (scanTopLevelStatements(persistentCode).hasTopLevelReturn) return `(async () => {\n${persistentCode}\n})()`;
17
- return `(async () => {\n${captureLastExpression(persistentCode)}\n})()`;
23
+ /** Wraps a cell for persistent evaluation; `shadowed` receives the kernel-global names the cell declares. */
24
+ export function wrapUserCode(code, shadowed = []) {
25
+ shadowedNames = shadowed;
26
+ let persistentCode;
27
+ try {
28
+ persistentCode = persistTopLevelDeclarations(code);
29
+ } finally {
30
+ shadowedNames = null;
31
+ }
32
+ const body = scanTopLevelStatements(persistentCode).hasTopLevelReturn ? persistentCode : captureLastExpression(persistentCode);
33
+ const scope = globalThis[USER_SCOPE_KEY];
34
+ if (shadowed.length === 0 && (scope === undefined || Object.keys(scope).length === 0)) return `(async () => {\n${body}\n})()`;
35
+ globalThis[USER_SCOPE_KEY] ??= Object.create(null);
36
+ return `(async (${USER_SCOPE}) => { with (${USER_SCOPE}) { return await (async () => {\n${body}\n})(); } })(globalThis[Symbol.for("senpi.kernel.userScope")])`;
37
+ }
38
+
39
+ function persistTarget(name) {
40
+ if (UNSHADOWABLE.has(name)) {
41
+ throw new Error(
42
+ `eval cell declares top-level \`${name}\`, which the JS kernel needs to keep cell variables between cells. Rename the binding (for example \`${name}Local\`).`,
43
+ );
44
+ }
45
+ if (!isKernelGlobal(name)) return `globalThis[${JSON.stringify(name)}]`;
46
+ if (shadowedNames !== null && !shadowedNames.includes(name)) shadowedNames.push(name);
47
+ return `${USER_SCOPE}[${JSON.stringify(name)}]`;
18
48
  }
19
49
 
20
50
  const IDENTIFIER_START_RE = /[$_\p{ID_Start}]/u;
@@ -267,14 +297,8 @@ function rewriteDeclaration(code, declarationStart, start, end, keyword) {
267
297
  const bindings = [];
268
298
  collectPatternNames(pattern, bindings);
269
299
  if (bindings.length === 0) return undefined;
270
- const shadowed = findShadowedGlobalName(bindings);
271
- if (shadowed !== undefined) {
272
- throw new Error(
273
- `eval cell declares top-level \`${shadowed}\`, but a global with that name already exists in the JS kernel. Persisting it would replace that global for every later cell. Rename the binding (for example \`${shadowed}Local\`), or assign globalThis.${shadowed} explicitly if replacing it is truly intended.`,
274
- );
275
- }
276
300
  if (preserveDeclaration) {
277
- for (const name of bindings) assignments.push(`globalThis[${JSON.stringify(name)}] = ${name};`);
301
+ for (const name of bindings) assignments.push(`${persistTarget(name)} = ${name};`);
278
302
  continue;
279
303
  }
280
304
  const target = rewriteBindingPattern(pattern);
@@ -296,7 +320,7 @@ function rewriteBindingPattern(source) {
296
320
  const target = rewriteBindingPattern(pattern.slice(0, defaultStart));
297
321
  return target === undefined ? undefined : `${target} = ${pattern.slice(defaultStart + 1).trim()}`;
298
322
  }
299
- if (IDENTIFIER_RE.test(pattern)) return `globalThis[${JSON.stringify(pattern)}]`;
323
+ if (IDENTIFIER_RE.test(pattern)) return persistTarget(pattern);
300
324
  if (pattern.startsWith("{") && pattern.endsWith("}")) {
301
325
  const properties = splitPatternElements(pattern.slice(1, -1)).map((property) => rewriteObjectBinding(property));
302
326
  if (properties.some((property) => property === undefined)) return undefined;
@@ -0,0 +1,19 @@
1
+ export interface CellRequireContext {
2
+ readonly cwdUrl: string;
3
+ readonly packageRootUrl?: string | null;
4
+ }
5
+
6
+ export interface CellResolve {
7
+ (specifier: string, options?: { readonly paths?: readonly string[] }): string;
8
+ paths(specifier: string): string[] | null;
9
+ }
10
+
11
+ export interface CellRequire {
12
+ (specifier: string): unknown;
13
+ readonly resolve: CellResolve;
14
+ readonly cache: Record<string, unknown>;
15
+ }
16
+
17
+ export function createCellRequire(context: () => CellRequireContext): CellRequire;
18
+
19
+ export { createRequire } from "node:module";
@@ -0,0 +1,52 @@
1
+ import { createRequire, isBuiltin } from "node:module";
2
+
3
+ // `require` in a cell resolves the way `import` does there: builtins natively, relative paths from the cell's
4
+ // directory, bare packages from the session's project first and then its managed package environment
5
+ // (%bun add / %npm add). It uses the runtime's own CommonJS loader, so JSON files and the `require` export conditions
6
+ // behave as in Node. Like Node's own `require`, it carries `resolve`, `resolve.paths` and `cache`.
7
+ export function createCellRequire(context) {
8
+ const requirers = () => {
9
+ const { cwdUrl, packageRootUrl } = context();
10
+ const fromCwd = createRequire(new URL("package.json", cwdUrl));
11
+ const fromPackages = packageRootUrl ? createRequire(new URL("package.json", packageRootUrl)) : undefined;
12
+ return { fromCwd, fromPackages };
13
+ };
14
+ const isPathLike = (name) => name.startsWith(".") || name.startsWith("/") || /^[A-Za-z]:[\\/]/.test(name);
15
+ // The one lookup both the call and `resolve` use, so they can never pick different copies of a package. Builtins
16
+ // (`fs`, `node:fs`, Bun's `bun:sqlite`) and paths resolve natively; a bare name the project does not have falls
17
+ // back to the managed environment, and a miss there reports the project's own not-found error.
18
+ const resolve = function resolve(specifier, options) {
19
+ const name = String(specifier);
20
+ const { fromCwd, fromPackages } = requirers();
21
+ if (isBuiltin(name) || isPathLike(name) || fromPackages === undefined || options?.paths !== undefined) {
22
+ return fromCwd.resolve(name, options);
23
+ }
24
+ try {
25
+ return fromCwd.resolve(name, options);
26
+ } catch (error) {
27
+ if (error?.code !== "MODULE_NOT_FOUND") throw error;
28
+ try {
29
+ return fromPackages.resolve(name, options);
30
+ } catch (fallback) {
31
+ throw fallback?.code === "MODULE_NOT_FOUND" ? error : fallback;
32
+ }
33
+ }
34
+ };
35
+ resolve.paths = (specifier) => {
36
+ const name = String(specifier);
37
+ const { fromCwd, fromPackages } = requirers();
38
+ const own = fromCwd.resolve.paths(name);
39
+ if (own === null || fromPackages === undefined || isPathLike(name)) return own;
40
+ return [...new Set([...own, ...(fromPackages.resolve.paths(name) ?? [])])];
41
+ };
42
+ // The call loads exactly what `resolve` names: a builtin id or an absolute file, through the runtime's loader.
43
+ const require = function require(specifier) {
44
+ return requirers().fromCwd(resolve(specifier));
45
+ };
46
+ require.resolve = resolve;
47
+ // One CommonJS cache per runtime: the cell's view is the loader's own, so deleting an entry forces a reload.
48
+ Object.defineProperty(require, "cache", { enumerable: true, get: () => createRequire(import.meta.url).cache });
49
+ return require;
50
+ }
51
+
52
+ export { createRequire };
@@ -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;
@@ -74,24 +77,35 @@ export class JsWorkerRuntime {
74
77
  if (typeof prepared.sourceFile === "string") sourceName = prepared.sourceFile;
75
78
  }
76
79
  if (prelude) indirectEval(prelude, PRELUDE_SOURCE_URL);
77
- const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), sourceName));
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));
78
84
  await this.#drainPendingDisplays();
79
85
  return value;
80
86
  } finally {
81
- this.#pendingDisplays = [];
82
- // A child still running here has lost its only owner: the cell that
83
- // spawned it is over, nothing will await it again, and it would be
84
- // reparented to init. Retire it the way timeout and abort cleanup
85
- // already do, unless the cell asked for a detached process.
86
- await this.#terminateChildren();
87
- 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();
88
89
  }
89
90
  }
90
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
+
91
105
  interrupt() {
92
106
  // The tree snapshot, SIGTERM, and SIGKILL escalation run on their own so
93
107
  // the caller's interrupt latency stays that of the acknowledgement.
94
- void this.#terminateChildren();
108
+ this.#childrenStopping = this.#terminateChildren();
95
109
  }
96
110
 
97
111
  #trackChild(child, spawnOptions) {
@@ -155,6 +169,8 @@ export class JsWorkerRuntime {
155
169
  { defined: () => this.#tools.defined(), undefine: (name) => this.#tools.undefine(name) },
156
170
  );
157
171
  globalThis.tools = globalThis.tool;
172
+ globalThis.require = createCellRequire(() => globalThis.__senpi_module_context__ ?? { cwdUrl: pathToFileURL(`${process.cwd()}/`).href });
173
+ globalThis.createRequire = createRequire;
158
174
  const originalLog = console.log.bind(console);
159
175
  const originalError = console.error.bind(console);
160
176
  const originalStdoutWrite = process.stdout.write;
@@ -423,6 +439,12 @@ export class JsWorkerRuntime {
423
439
  }
424
440
  }
425
441
 
442
+ function shadowNote(names) {
443
+ const list = names.map((name) => `\`${name}\``).join(", ");
444
+ const restore = names.map((name) => `delete ${name}`).join("; ");
445
+ 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`;
446
+ }
447
+
426
448
  function isPlainObject(value) {
427
449
  return value !== null && typeof value === "object" && !Array.isArray(value);
428
450
  }
@@ -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;
@@ -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
@@ -90,13 +91,16 @@ function createKernelWebView(NativeWebView, requestPort) {
90
91
  #console;
91
92
  #ready;
92
93
  #closed = false;
94
+ #forgetRelease;
93
95
  onNavigated = null;
94
96
  onNavigationFailed = null;
95
97
 
96
98
  constructor(options = {}) {
97
99
  if (!needsMainThread(options)) {
98
100
  const view = new NativeWebView(options);
99
- natives.add(new WeakRef(view));
101
+ const ref = new WeakRef(view);
102
+ natives.add(ref);
103
+ onCellRelease(() => ref.deref()?.close());
100
104
  return view;
101
105
  }
102
106
  super();
@@ -104,6 +108,7 @@ function createKernelWebView(NativeWebView, requestPort) {
104
108
  this.#console = capture;
105
109
  proxied.add(this);
106
110
  live.add(this);
111
+ this.#forgetRelease = onCellRelease(() => this.close());
107
112
  const viewId = this.#viewId;
108
113
  this.#ready = connect().then(async client => {
109
114
  client.register(viewId, this);
@@ -157,6 +162,7 @@ function createKernelWebView(NativeWebView, requestPort) {
157
162
  close() {
158
163
  if (this.#closed) return;
159
164
  this.#closed = true;
165
+ this.#forgetRelease?.();
160
166
  live.delete(this);
161
167
  const viewId = this.#viewId;
162
168
  this.#ready.then(
@@ -1,6 +1,6 @@
1
1
  import type { ResolvedSandbox } from "../../config/feature-settings.ts";
2
2
  import { marshalToolResult } from "../../tool/image.ts";
3
- import type { ExecuteTool, HostCellExecutor } from "../../tool/types.ts";
3
+ import type { EvalRuntimeInfo, ExecuteTool, HostCellExecutor } from "../../tool/types.ts";
4
4
  import type { CodemodeSandbox } from "./vendor/pi-codemode/runtime/host.ts";
5
5
  import type { CodemodeError, CodemodeOutputFrame, CodemodeTool } from "./vendor/pi-codemode/types.ts";
6
6
 
@@ -13,6 +13,31 @@ export interface SandboxCellOptions {
13
13
  readonly wasmPath?: string;
14
14
  }
15
15
 
16
+ /**
17
+ * The runtime an isolated cell's result reports: the QuickJS build it runs on, never the persistent kernel's (senpi#2811).
18
+ * Read from the installed quickjs-wasi package the wasm loads from, only when an isolated cell runs; when that package
19
+ * cannot be resolved the cell itself fails with eval_isolate_unavailable, and the version says so.
20
+ */
21
+ export function sandboxRuntimeInfo(): EvalRuntimeInfo {
22
+ let version = "unavailable";
23
+ try {
24
+ // getBuiltinModule, not a static import: node:module would otherwise load with the extension.
25
+ const manifest: unknown = process.getBuiltinModule("node:module").createRequire(import.meta.url)(
26
+ "quickjs-wasi/package.json",
27
+ );
28
+ if (
29
+ typeof manifest === "object" &&
30
+ manifest !== null &&
31
+ "version" in manifest &&
32
+ typeof manifest.version === "string"
33
+ )
34
+ version = manifest.version;
35
+ } catch {
36
+ // Reported as "unavailable": the same missing package makes the cell fail with eval_isolate_unavailable.
37
+ }
38
+ return { name: "quickjs", version, isolation: "sandbox" };
39
+ }
40
+
16
41
  // On the script's first line so reported line numbers still match the cell; globals, so a cell's own
17
42
  // `const print` shadows them instead of failing as a redeclaration.
18
43
  const OUTPUT_ALIASES =
@@ -219,8 +219,10 @@ export class CellResultBuilder {
219
219
  }
220
220
 
221
221
  #liveUpdateText(): string {
222
- if (this.#state.status === "queued" && this.#state.queuedBehind?.length) {
223
- return `queued behind ${this.#state.queuedBehind.join(", ")} in the ${this.#state.input.language} kernel`;
222
+ if (this.#state.status === "queued" && this.#state.queuedBehind !== undefined) {
223
+ return this.#state.queuedBehind.length === 0
224
+ ? `waiting for the ${this.#state.input.language} kernel to be ready`
225
+ : `queued behind ${this.#state.queuedBehind.join(", ")} in the ${this.#state.input.language} kernel`;
224
226
  }
225
227
  const summary = this.#state.input.summary === undefined ? "" : ` ${this.#state.input.summary}`;
226
228
  const output = this.#liveLines.text();
@@ -1,5 +1,6 @@
1
1
  import type { AgentToolResult } from "@code-yeongyu/senpi";
2
2
  import type { WakeSourceState } from "../extension/wake-source-state.ts";
3
+ import type { EvalImageContent } from "./image-resize.ts";
3
4
  import type { EvalLanguage, EvalToolDetails } from "./types.ts";
4
5
 
5
6
  export type EvalDetachedCellState = "queued" | "running" | "detached" | "completed" | "failed" | "cancelled";
@@ -24,6 +25,8 @@ export interface EvalDetachedCellSnapshot {
24
25
  export interface EvalDetachedCellNotification {
25
26
  readonly cellId: string;
26
27
  readonly content: string;
28
+ /** Images the cell displayed, delivered with the notification the way a foreground result delivers them. */
29
+ readonly images?: readonly EvalImageContent[];
27
30
  }
28
31
 
29
32
  export interface EvalDetachedCellNotifier {
@@ -13,7 +13,7 @@ import type {
13
13
  EvalDetachedCellSnapshot,
14
14
  EvalDetachedCellStatusEntry,
15
15
  } from "./detached-cell-contract.ts";
16
- import { currentDetachedResult, detachedErrorResult, snapshotDetachedCell } from "./detached-cell-snapshot.ts";
16
+ import { cancelledDetachedResult, detachedErrorResult, snapshotDetachedCell } from "./detached-cell-snapshot.ts";
17
17
  import {
18
18
  activeDetachedCellReuseError,
19
19
  allowsDetachedCellTransition,
@@ -36,7 +36,6 @@ export type {
36
36
  } from "./detached-cell-contract.ts";
37
37
 
38
38
  export class EvalDetachedCellManager {
39
- readonly #artifactsDir: string | undefined;
40
39
  readonly #onStatusChange: ((entries: readonly EvalDetachedCellStatusEntry[]) => void) | undefined;
41
40
  readonly #onWakeSourceState: ((state: WakeSourceState) => void) | undefined;
42
41
  readonly #cells = new Map<string, ManagedCell>();
@@ -49,7 +48,6 @@ export class EvalDetachedCellManager {
49
48
  readonly #maxDetachedCells: number;
50
49
 
51
50
  constructor(options: EvalDetachedCellManagerOptions = {}) {
52
- this.#artifactsDir = options.artifactsDir;
53
51
  this.#onStatusChange = options.onStatusChange;
54
52
  this.#onWakeSourceState = options.onWakeSourceState;
55
53
  this.#notificationQueue = new DetachedNotificationQueue(options.notifier);
@@ -85,7 +83,6 @@ export class EvalDetachedCellManager {
85
83
  const cell = createManagedCell({
86
84
  cellId,
87
85
  input,
88
- artifactsDir: this.#artifactsDir,
89
86
  now: this.#now,
90
87
  defaultHardLimitSeconds: this.#hardLimitSeconds,
91
88
  defaultRunBudgetSeconds: this.#runBudgetSeconds,
@@ -239,7 +236,6 @@ export class EvalDetachedCellManager {
239
236
  await cell.interruptOutcome?.promise;
240
237
  return this.#snapshot(cell);
241
238
  },
242
- spillPath: cell.spillPath,
243
239
  });
244
240
  }
245
241
  }
@@ -260,7 +256,7 @@ export class EvalDetachedCellManager {
260
256
  return;
261
257
  }
262
258
  if (foreground) {
263
- if (this.#settle(cell, "cancelled", currentDetachedResult(cell))) cell.onKill?.(expiry.error);
259
+ if (this.#settle(cell, "cancelled", cancelledDetachedResult(cell))) cell.onKill?.(expiry.error);
264
260
  return;
265
261
  }
266
262
  await this.#cancel(cell, expiry.error.message);
@@ -269,7 +265,7 @@ export class EvalDetachedCellManager {
269
265
  #cancelQueued(cell: ManagedCell, reason: string, error = new Error(reason)): void {
270
266
  const dequeued = cell.kernel?.cancelQueued(cell.cellId, reason) ?? false;
271
267
  cell.stateRetained = true;
272
- this.#settle(cell, "cancelled", currentDetachedResult(cell));
268
+ this.#settle(cell, "cancelled", cancelledDetachedResult(cell));
273
269
  // Acquisition/reset has no queue promise yet; its execution has no interrupt target either.
274
270
  if (!dequeued) cell.onKill?.(error);
275
271
  }
@@ -278,7 +274,7 @@ export class EvalDetachedCellManager {
278
274
  const outcome = Promise.withResolvers<void>();
279
275
  cell.interruptOutcome = outcome;
280
276
  try {
281
- if (!this.#settle(cell, "cancelled", currentDetachedResult(cell)) || cell.kernel === undefined) return;
277
+ if (!this.#settle(cell, "cancelled", cancelledDetachedResult(cell)) || cell.kernel === undefined) return;
282
278
  const handle = await cell.kernel.interrupt(reason, cell.cellId);
283
279
  cell.interruptNote = handle.note;
284
280
  cell.stateRetained = await handle.stateRetained;
@@ -1,61 +1,21 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
1
  import type { EvalDetachedCellNotification, EvalDetachedCellSnapshot } from "./detached-cell-manager.ts";
4
2
  import { interruptionStateNote, unknownInterruptionStateNote } from "./interrupt-note.ts";
5
3
  import type { EvalKernelState, EvalMemoryDetails } from "./types.ts";
6
4
 
7
- const NOTIFICATION_TAIL_BYTES = 512;
8
-
9
- export function detachedNotificationSpillPath(artifactsDir: string | undefined, cellId: string): string | undefined {
10
- if (artifactsDir === undefined) return undefined;
11
- return join(artifactsDir, "local", `detached-eval-${safeCellId(cellId)}.log`);
12
- }
13
-
14
- export async function buildDetachedCellNotification(
15
- snapshot: EvalDetachedCellSnapshot,
16
- spillPath: string | undefined,
17
- ): Promise<EvalDetachedCellNotification> {
18
- const body = notificationBody(snapshot);
19
- const overflow = Buffer.byteLength(body, "utf8") > NOTIFICATION_TAIL_BYTES;
20
- let spillNotice = "";
21
- if (overflow && spillPath !== undefined) {
22
- try {
23
- await mkdir(dirname(spillPath), { recursive: true });
24
- await writeFile(spillPath, body, "utf8");
25
- // The agent read tool resolves plain paths only, so the notice must carry
26
- // the absolute spill path, never the kernel-helper local:// scheme.
27
- spillNotice = `\nBuffered output overflowed; full output: ${spillPath}`;
28
- } catch (error) {
29
- const message = error instanceof Error ? error.message : String(error);
30
- spillNotice = `\nBuffered output overflow could not be spilled: ${message}`;
31
- }
32
- }
33
- return {
34
- cellId: snapshot.cellId,
35
- content: `${overflow ? notificationPreview(snapshot) : body}${overflow ? "\n[…notification tail capped…]" : ""}${spillNotice}`,
36
- };
37
- }
38
-
39
- function notificationBody(cell: EvalDetachedCellSnapshot): string {
40
- const outcome = outcomeOf(cell);
41
- const resultText = textContent(cell);
42
- const stateNote = stateNoteOf(cell);
43
- return [
44
- `<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
45
- resultText.length === 0 ? "(no output)" : resultText,
46
- `${stateNote}</system-reminder>`,
47
- ].join("\n");
5
+ /**
6
+ * A detached cell's completion carries the same text its result would have shown in the foreground: the output sink
7
+ * already bounded it (the configured head, the tail, the middle marker and the artifact notice), plus its images.
8
+ */
9
+ export function buildDetachedCellNotification(snapshot: EvalDetachedCellSnapshot): EvalDetachedCellNotification {
10
+ const images = snapshot.result.content.filter((part) => part.type === "image");
11
+ return { cellId: snapshot.cellId, content: notificationText(snapshot, textContent(snapshot)), images };
48
12
  }
49
13
 
50
- function notificationPreview(cell: EvalDetachedCellSnapshot): string {
51
- const outcome = outcomeOf(cell);
52
- const resultText = textContent(cell);
53
- const stateNote = stateNoteOf(cell);
14
+ function notificationText(cell: EvalDetachedCellSnapshot, output: string): string {
54
15
  return [
55
- `<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
56
- "Buffered output tail:",
57
- truncateTailUtf8(resultText, NOTIFICATION_TAIL_BYTES),
58
- `${stateNote}</system-reminder>`,
16
+ `<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcomeOf(cell)}.`,
17
+ output.length === 0 ? "(no output)" : output,
18
+ `${stateNoteOf(cell)}</system-reminder>`,
59
19
  ].join("\n");
60
20
  }
61
21
 
@@ -101,15 +61,3 @@ export function memoryStateNote(memory: EvalMemoryDetails | undefined): string {
101
61
  return "The kernel was restarted before this cell ran - globals from earlier cells are gone; this cell's variables are available to the next eval cell.";
102
62
  return "Kernel state updated - variables are available to the next eval cell.";
103
63
  }
104
-
105
- function safeCellId(cellId: string): string {
106
- return cellId.replace(/[^a-zA-Z0-9_-]/gu, "_");
107
- }
108
-
109
- function truncateTailUtf8(text: string, maxBytes: number): string {
110
- if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
111
- const bytes = Buffer.from(text, "utf8");
112
- let start = bytes.length - maxBytes;
113
- while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
114
- return bytes.subarray(start).toString("utf8");
115
- }