@code-yeongyu/senpi-codemode 2026.10.10-4 → 2026.10.10-6
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.
- package/CHANGELOG.md +44 -0
- package/README.md +29 -6
- package/package.json +4 -4
- package/src/bridges/eval-environment-schemas.ts +48 -0
- package/src/bridges/eval-virtual-schemas.ts +11 -4
- package/src/extension/eval-notifier.ts +13 -2
- package/src/index.ts +2 -2
- package/src/kernels/js/cell-ownership.js +268 -0
- package/src/kernels/js/cell-run-context.js +89 -0
- package/src/kernels/js/process-entry.js +5 -1
- package/src/kernels/js/rejection-reports.js +61 -0
- package/src/kernels/js/worker-core.js +46 -6
- package/src/kernels/js/worker-indirect-eval.js +37 -13
- package/src/kernels/js/worker-require.d.ts +19 -0
- package/src/kernels/js/worker-require.js +52 -0
- package/src/kernels/js/worker-runtime.js +31 -9
- package/src/kernels/js/worker-shadow-guard.js +2 -6
- package/src/kernels/js/worker-shell-capture.js +5 -0
- package/src/kernels/js/worker-webview.js +7 -1
- package/src/tool/detached-cell-contract.ts +3 -0
- package/src/tool/detached-cell-manager.ts +0 -4
- package/src/tool/detached-cell-notification.ts +11 -63
- package/src/tool/detached-notification-queue.ts +1 -2
- package/src/tool/live-headline.ts +60 -0
- package/src/tool/managed-cell.ts +0 -4
- package/src/tool/render.ts +72 -15
|
@@ -441,7 +441,11 @@ function reportCrash(error) {
|
|
|
441
441
|
}
|
|
442
442
|
}
|
|
443
443
|
process.on("uncaughtException", reportCrash);
|
|
444
|
-
|
|
444
|
+
// Once the worker core is up it reports unhandled rejections to the cells (the kernel keeps running); before that, one
|
|
445
|
+
// is a startup crash.
|
|
446
|
+
process.on("unhandledRejection", (reason) => {
|
|
447
|
+
if (process.listenerCount("unhandledRejection") === 1) reportCrash(reason);
|
|
448
|
+
});
|
|
445
449
|
|
|
446
450
|
const cwd = process.env.SENPI_CODEMODE_PROCESS_CWD ?? process.cwd();
|
|
447
451
|
const poolWidth = Number.parseInt(process.env.SENPI_CODEMODE_PROCESS_POOL_WIDTH ?? "1", 10) || 1;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { cellOfRejection } from "./cell-run-context.js";
|
|
2
|
+
|
|
3
|
+
const STACK_FRAMES = 3;
|
|
4
|
+
|
|
5
|
+
// An unhandled rejection is reported, never fatal: the kernel keeps its variables (the Node REPL and Jupyter norm). The
|
|
6
|
+
// report names the cell that started the work when it is known and lands on the cell running now, or on the next one.
|
|
7
|
+
// A burst is folded into one report plus a count, so a stopped loop cannot flood a cell's output.
|
|
8
|
+
export function createRejectionReports({ activeCell, emitText }) {
|
|
9
|
+
let waiting = null;
|
|
10
|
+
const shownInCell = new WeakMap();
|
|
11
|
+
|
|
12
|
+
function report(reason) {
|
|
13
|
+
const text = describe(reason, cellOfRejection(reason), activeCell());
|
|
14
|
+
const cell = activeCell();
|
|
15
|
+
if (cell === null || cell.released) {
|
|
16
|
+
if (waiting === null) waiting = { text, more: 0 };
|
|
17
|
+
else waiting.more++;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const shown = shownInCell.get(cell) ?? 0;
|
|
21
|
+
shownInCell.set(cell, shown + 1);
|
|
22
|
+
if (shown === 0) emitText(text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
report,
|
|
27
|
+
startCell(cell) {
|
|
28
|
+
if (waiting === null) return;
|
|
29
|
+
emitText(`${waiting.text}${moreLine(waiting.more)}`);
|
|
30
|
+
shownInCell.set(cell, 1);
|
|
31
|
+
waiting = null;
|
|
32
|
+
},
|
|
33
|
+
finishCell(cell) {
|
|
34
|
+
const shown = shownInCell.get(cell) ?? 0;
|
|
35
|
+
if (shown > 1) emitText(moreLine(shown - 1));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function moreLine(more) {
|
|
41
|
+
return more === 0 ? "" : `... and ${more} more unhandled promise ${more === 1 ? "rejection" : "rejections"}\n`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function describe(reason, origin, running) {
|
|
45
|
+
const where =
|
|
46
|
+
origin === undefined
|
|
47
|
+
? "(the cell that started it is unknown)"
|
|
48
|
+
: origin === running && !origin.released
|
|
49
|
+
? "in this cell"
|
|
50
|
+
: `from cell ${origin.cellId}${origin.released ? ", after it was stopped" : ""}`;
|
|
51
|
+
return `Unhandled promise rejection ${where}: ${summary(reason)}\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function summary(reason) {
|
|
55
|
+
if (!(reason instanceof Error)) return String(reason);
|
|
56
|
+
const frames = (reason.stack ?? "")
|
|
57
|
+
.split("\n")
|
|
58
|
+
.filter((line) => line.trim().startsWith("at "))
|
|
59
|
+
.slice(0, STACK_FRAMES);
|
|
60
|
+
return [`${reason.name}: ${reason.message}`, ...frames.map((line) => ` ${line.trim()}`)].join("\n");
|
|
61
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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 {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
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,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 {
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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}) ${
|
|
56
|
-
"
|
|
57
|
-
|
|
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
|
-
}
|
|
@@ -3,7 +3,6 @@ import { buildDetachedCellNotification } from "./detached-cell-notification.ts";
|
|
|
3
3
|
|
|
4
4
|
export interface PendingDetachedNotification {
|
|
5
5
|
readonly snapshot: () => EvalDetachedCellSnapshot | Promise<EvalDetachedCellSnapshot>;
|
|
6
|
-
readonly spillPath: string | undefined;
|
|
7
6
|
}
|
|
8
7
|
|
|
9
8
|
export class DetachedNotificationQueue {
|
|
@@ -30,7 +29,7 @@ export class DetachedNotificationQueue {
|
|
|
30
29
|
const flush = Promise.resolve().then(async () => {
|
|
31
30
|
const pending = this.#pending.splice(0);
|
|
32
31
|
const notifications = await Promise.all(
|
|
33
|
-
pending.map(async (item) =>
|
|
32
|
+
pending.map(async (item) => buildDetachedCellNotification(await item.snapshot())),
|
|
34
33
|
);
|
|
35
34
|
this.#notifier?.notify(notifications);
|
|
36
35
|
});
|