@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
package/CHANGELOG.md
CHANGED
|
@@ -6,10 +6,54 @@
|
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
8
|
|
|
9
|
+
- `tool_schema("eval:environments")` and `tool_schema("eval:isolation")` document the package magics (`%pip`, `%bun`/`%npm`, `%environment`, `%load`) and isolated cells on demand, with the error codes each one raises. Nothing is added to the eval prompt or its input schema.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
### Removed
|
|
16
|
+
|
|
17
|
+
## [2026.10.10-6] - 2026-10-07
|
|
18
|
+
|
|
19
|
+
### Breaking Changes
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
|
|
29
|
+
## [2026.10.10-5] - 2026-10-07
|
|
30
|
+
|
|
31
|
+
### Breaking Changes
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
9
35
|
### Changed
|
|
10
36
|
|
|
37
|
+
- A running, queued or detached eval row now leads with the cell's summary (or its first code line), then the language, state and elapsed time, on one line; the code is shown on expand. A call still streaming its arguments shows that row instead of the raw `eval code="..."` fallback. Completed rows are unchanged ([#2802](https://github.com/code-yeongyu/senpi/issues/2802)).
|
|
38
|
+
|
|
39
|
+
- A JavaScript cell may declare a name the kernel or platform already defines (`log`, `fetch`, `print`, `URL`, ...): the value persists for your later cells while the kernel and imported libraries keep the original, `delete <name>` restores it, and the cell notes the shadowing ([#2793](https://github.com/code-yeongyu/senpi/issues/2793)).
|
|
40
|
+
|
|
11
41
|
### Fixed
|
|
12
42
|
|
|
43
|
+
- A live eval row whose cell has no summary skips a first code line that holds only escape or control characters and leads with the next line that has content, instead of showing only an ellipsis ([#2850](https://github.com/code-yeongyu/senpi/issues/2850)).
|
|
44
|
+
|
|
45
|
+
- The `require` in a JavaScript cell carries `require.resolve`, `require.resolve.paths` and `require.cache` like Node's own `require`, and the call and `resolve` share one lookup, so they always name the same copy: builtins natively (including Bun's, such as `bun:sqlite`), then the project, then the managed package environment ([#2832](https://github.com/code-yeongyu/senpi/issues/2832)).
|
|
46
|
+
|
|
47
|
+
- A live eval row whose cell has no summary sanitizes its first code line before measuring it, so escape and control characters in that line never reach the one-line row ([#2839](https://github.com/code-yeongyu/senpi/issues/2839)).
|
|
48
|
+
|
|
49
|
+
- A live eval row stays one line in every terminal: its headline is measured and cut in screen cells, so a summary with wide characters (Korean, Chinese, Japanese, emoji) no longer wraps a narrow terminal, and a `peek`/`stop` call still streaming in renders `eval peek` instead of `eval peek undefined` ([#2831](https://github.com/code-yeongyu/senpi/issues/2831)).
|
|
50
|
+
|
|
51
|
+
- Stopping or timing out a JavaScript cell that awaits something that never settles (a promise, a `fetch` whose server never answers, a polling loop on timers, `Bun.sleep` or `node:timers/promises`, a loop of short `Bun.spawn` children) now keeps the worker and every global instead of restarting it. Stop ends the cell and everything it started: its timers are cleared, pending sleeps and `fetch` requests reject, and the sockets, servers, WebSockets, WebViews, nested workers and child processes it opened are closed; the result arrives once its children are gone. A stopped cell's own `catch`/`finally` can no longer print, call tools, start processes, schedule timers or open connections. An unhandled promise rejection no longer crashes the JavaScript kernel: it is reported on the running or next cell, naming the cell it came from, with bursts folded into one line; an uncaught exception still restarts the worker. A cell stopped during a `Bun.$` command still restarts the worker, as before ([#2788](https://github.com/code-yeongyu/senpi/issues/2788)).
|
|
52
|
+
|
|
53
|
+
- JavaScript cells can call `require(...)` and `createRequire(...)`: builtins, relative CommonJS and JSON files, and packages from the project or the managed package environment resolve as they do for `import` ([#2792](https://github.com/code-yeongyu/senpi/issues/2792)).
|
|
54
|
+
|
|
55
|
+
- A detached eval cell's completion notification now carries the same output its result would have shown in the foreground (head, tail, elision marker and full-output notice) instead of a 512-byte tail, and images the cell displayed are delivered with the notification ([#2789](https://github.com/code-yeongyu/senpi/issues/2789)).
|
|
56
|
+
|
|
13
57
|
### Removed
|
|
14
58
|
|
|
15
59
|
## [2026.10.10-4] - 2026-10-06
|
package/README.md
CHANGED
|
@@ -363,7 +363,8 @@ options object and asynchronous helpers are `await`-able.
|
|
|
363
363
|
| `write(path, content)` | Creates parent directories and writes text. `local://` paths persist in the session artifact root. |
|
|
364
364
|
| `env(key?, value?)` | Reads all kernel environment values, one value, or sets one value. Includes the session's `PI_*` values (see [Session environment](#session-environment)). |
|
|
365
365
|
| `tool.<name>(args)` | Invokes an active Senpi tool through the normal `pi.executeTool` pipeline and returns `{ text, images?, details?, hasError? }` in every kernel; image blocks arrive as `images[i] = { mimeType, dataBase64 }`. |
|
|
366
|
-
| `tool_schema(name?)` | Returns a tool's parameter schema without calling it; omit `name` to list tool names. |
|
|
366
|
+
| `tool_schema(name?)` | Returns a tool's parameter schema without calling it; omit `name` to list tool names. Five virtual entries document codemode itself on demand and are never listed: `eval:wait`, `eval:helpers`, `eval:kernel-tools`, `eval:environments` (the `%pip`, `%bun`/`%npm`, `%environment` and `%load` magics, their results and error codes) and `eval:isolation` (`isolate: true` cells, their limits and error codes). |
|
|
367
|
+
| `require(specifier)`, `createRequire(path)` (js) | CommonJS loading inside a cell, resolved like `import`: builtins (with or without `node:`), relative files and JSON from the cell's directory, bare packages from the session's project and then its managed package environment (`%bun add`/`%npm add`). A missing module raises the runtime's own `MODULE_NOT_FOUND` error. Like Node's `require`, it carries `require.resolve` (the same lookup the call uses, so both name the same copy; builtins resolve to the runtime's own id), `require.resolve.paths` and `require.cache` (the runtime's CommonJS cache). |
|
|
367
368
|
| `tool(fn, metadata?)` (js) | Registers a named function as a kernel tool for in-process children. `metadata.name` registers it under that name instead of the function's; arguments are still passed in the function's parameter order. |
|
|
368
369
|
| `tool.defined()` / `tool.undefine(name)` (js) | List the kernel tools this kernel defines (sorted), and remove one (`true` if it existed). A descriptor taken before `undefine` can no longer be invoked. Both names are reserved: a kernel tool can't be registered as `defined` or `undefine` (`reserved_tool_name`), and a host tool with either name is shadowed in the `tool` namespace, so it can't be called from a JavaScript cell. |
|
|
369
370
|
| `@tool` / `@tool(name=, description=, schema=)` (py) | Registers a Python function as a kernel tool for in-process children; the schema is inferred from its type hints (`tool_schema("eval:kernel-tools")` lists the rules). Callbacks run while the kernel is idle or its cell is parked in a host call, never in the middle of a running computation. `tool.defined()` and `tool.undefine(name)` work as in JavaScript. Ruby and Julia kernels answer `tools_unavailable`. |
|
|
@@ -465,6 +466,10 @@ free; otherwise it settles `cancelled` with `eval_background_capacity_reached`,
|
|
|
465
466
|
listing the live cells and a stop-or-wait remedy. Cells that complete inside the
|
|
466
467
|
window return normally. Cancelling a queued cell never interrupts its predecessor.
|
|
467
468
|
Do not re-run a detached or queued cell; each detached cell completes as one notification.
|
|
469
|
+
The notification carries the same text the cell's result would have shown in the
|
|
470
|
+
foreground (the output sink's configured head, its tail, the middle-elision marker and
|
|
471
|
+
the full-output artifact notice), framed by the outcome line and the kernel-state note,
|
|
472
|
+
plus any images the cell displayed.
|
|
468
473
|
|
|
469
474
|
Queued steering also detaches an eligible interactive foreground call, including
|
|
470
475
|
one paused in a host tool bridge, without cancelling its computation. If the
|
|
@@ -505,11 +510,29 @@ Use `eval({ action: "peek", cell_id })` for its state and buffered output, or
|
|
|
505
510
|
`eval({ action: "stop", cell_id })` to cancel it. Stopping a queued cell removes it
|
|
506
511
|
without interrupting the active cell; kernel state is retained. Python running-cell stop interrupts the
|
|
507
512
|
existing kernel and preserves variables. JavaScript stop is cooperative first:
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
+
Stop ends the cell and everything it started, not the kernel's variables. The
|
|
514
|
+
worker rejects the cell's pending bridge `tool.*` calls and releases the cell:
|
|
515
|
+
its timers are cleared (the globals and the `node:timers` module alike), its
|
|
516
|
+
pending `Bun.sleep`, `node:timers/promises` waits, `fetch` requests and file
|
|
517
|
+
reads reject with the interruption, and the sockets, servers, file streams,
|
|
518
|
+
`readline` interfaces, WebSockets, WebViews, nested workers and child processes
|
|
519
|
+
it opened are closed or terminated, whether made with a factory or a
|
|
520
|
+
constructor. A resource that will not close is reported on the cell output. The stop result arrives once those children are gone, and the
|
|
521
|
+
worker and every global from earlier cells survive. If the stopped cell's own
|
|
522
|
+
`catch`/`finally` still runs, it cannot print, call tools, start processes,
|
|
523
|
+
schedule timers or open connections. One boundary remains: code that resumes
|
|
524
|
+
because a later cell resolves a promise the stopped cell was awaiting runs
|
|
525
|
+
until it reaches an operation a stopped cell may no longer start (output, tool
|
|
526
|
+
calls, processes, timers, network, files, message channels, workers).
|
|
527
|
+
|
|
528
|
+
An unhandled promise rejection never crashes the JavaScript kernel; its
|
|
529
|
+
variables are kept, as in the Node REPL and Jupyter. The rejection is reported
|
|
530
|
+
on the cell that is running, or on the next cell, naming the cell that started
|
|
531
|
+
the work when it is known ("from cell <id>, after it was stopped"), with the
|
|
532
|
+
error message and the top of its stack. A burst becomes one report plus
|
|
533
|
+
"... and K more unhandled promise rejections". A fatal error (an uncaught
|
|
534
|
+
exception) still restarts the worker, and the result says variables are lost. Only a cell stopped during a `Bun.$` command costs
|
|
535
|
+
the worker VM. A worker blocked in a synchronous call (`Bun.spawnSync`,
|
|
513
536
|
`child_process.spawnSync`) cannot be stopped at all; after a 3 s termination
|
|
514
537
|
deadline a fresh worker replaces it, the cell output gains a stderr line naming
|
|
515
538
|
the blocked synchronous call, and the blocked call keeps running until it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.10.10-
|
|
3
|
+
"version": "2026.10.10-6",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@babel/parser": "8.0.4",
|
|
36
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-
|
|
36
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-6",
|
|
37
37
|
"quickjs-wasi": "3.6.2",
|
|
38
38
|
"typebox": "1.3.34"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@code-yeongyu/senpi": "2026.10.10-
|
|
41
|
+
"@code-yeongyu/senpi": "2026.10.10-6"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@code-yeongyu/senpi": "2026.10.10-
|
|
44
|
+
"@code-yeongyu/senpi": "2026.10.10-6"
|
|
45
45
|
},
|
|
46
46
|
"keywords": [
|
|
47
47
|
"senpi",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EvalSchemaResult } from "./schema-bridge.ts";
|
|
2
|
+
|
|
3
|
+
export type NamedEvalSchema = Extract<EvalSchemaResult, { readonly name: string }>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The on-demand documentation for package environments and isolated cells (`tool_schema("eval:environments")` and
|
|
7
|
+
* `tool_schema("eval:isolation")`). Like the other virtual entries they are never listed, never run anything, and add
|
|
8
|
+
* nothing to the eval prompt or input schema. Every magic, code and message named here is the one the source emits.
|
|
9
|
+
*/
|
|
10
|
+
export const environmentsEntry: NamedEvalSchema = {
|
|
11
|
+
name: "eval:environments",
|
|
12
|
+
description: [
|
|
13
|
+
"Host magic cells: a cell whose first code line (blank and comment lines skipped) is one of these runs on the host, in the language kernel's queue, so it orders with ordinary cells and a queued one is removed by stop like any queued cell. A magic followed by more code is refused: put it on its own cell, then run the code that uses it in the next cell.",
|
|
14
|
+
"py: %pip install <requirements ...> installs into the session's environment (a trailing backslash continues the line). Result: installed <packages> into <mode> (revision <n>); already-imported modules stay cached until reset.",
|
|
15
|
+
"js: %bun add <packages ...> or %npm add <packages ...> (install is accepted as a synonym of add). Result: added <packages> with <installer> into <mode>[ (revision <n>)]; already-imported modules stay cached until reset. A package the project's own node_modules still shadows is named with environment_resolution_conflict in the result.",
|
|
16
|
+
"py and js: %environment managed | project selects where installs go. managed (the default) uses per-session revisions under the session's artifacts directory (or environments.managedRoot) and never touches project files; project installs into the session directory (py: .senpi/python-packages; js: the project root).",
|
|
17
|
+
"py and js: %load <file> runs a local file as a cell, read when its turn comes in the queue, with the file's name in tracebacks; definitions persist like any cell. It accepts a path relative to the session directory, local:// or file://, reads files up to 8 MiB and never fetches remote URLs.",
|
|
18
|
+
"Installs pause the run budget like a bridge call, stream the installer's output into the cell, detach past the foreground window like any cell, and are stopped by stop.",
|
|
19
|
+
"Install failures are cell errors that start with their code: environment_install_failed (a bad argument or an installer failure, with the installer's stderr tail), environment_install_cancelled (stopped before or during the install), environment_installer_unavailable (no interpreter or no pip; no JavaScript package environment in this session; neither bun nor npm on PATH, or the chosen one missing; the installer failed to start; or installs turned off with environments.autoProvision: false), environment_resolution_conflict (py: pip reported a dependency conflict). A %load failure, and a magic refused because more code follows it, are plain cell errors without a code.",
|
|
20
|
+
].join("\n"),
|
|
21
|
+
parameters: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
"%pip": { description: "py: %pip install <requirements ...>" },
|
|
25
|
+
"%bun": { description: "js: %bun add <packages ...>" },
|
|
26
|
+
"%npm": { description: "js: %npm add <packages ...>" },
|
|
27
|
+
"%environment": { enum: ["managed", "project"] },
|
|
28
|
+
"%load": { description: "py and js: %load <path | local://... | file://...>" },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const isolationEntry: NamedEvalSchema = {
|
|
34
|
+
name: "eval:isolation",
|
|
35
|
+
description: [
|
|
36
|
+
"isolate: true runs one JavaScript cell in a fresh QuickJS sandbox instead of the persistent kernel. It is available only when sandbox.enabled is on in the codemode settings; only then does the eval input schema carry the isolate field. An older senpi drops the field and would run the code as an ordinary cell, so check this entry before relying on it.",
|
|
37
|
+
'Requests are refused with eval_isolate_invalid when sandbox cells are turned off, when language is not "js", when isolate is not a boolean, with reset: true (a sandbox cell always starts fresh), or when eval runs through a proxy that cannot isolate a cell.',
|
|
38
|
+
"An isolated cell shares nothing with the kernel. Its globals are print, display, text, image, console, exit, tool.<name>(args) (also reachable as tools), ALL_TOOLS (the names and descriptions of the tools it may call), load and store. Its tool calls go through the host, so permission hooks still apply, and tool calls still pending when the script settles are aborted. The store is rejected: load returns nothing and store fails with eval_isolate_no_state.",
|
|
39
|
+
"Limits: sandbox.timeoutSeconds (default 300) and sandbox.memoryMb (default 64; SENPI_CODEMODE_SANDBOX_MEMORY_MB overrides it). The result's runtime names quickjs and its version, with isolation sandbox.",
|
|
40
|
+
"Failures start with their code: eval_isolate_timeout, eval_isolate_aborted, eval_isolate_memory_limit, eval_isolate_unresolved_promise (the script ended with a promise still pending), eval_isolate_no_state, eval_isolate_unavailable (the sandbox itself failed: it could not load, or its worker crashed, exited or lost its output; if that happened after the script started, tool calls the cell made may already have run, so check their effects before retrying).",
|
|
41
|
+
].join("\n"),
|
|
42
|
+
parameters: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
isolate: { type: "boolean", description: 'true with language: "js" runs the cell in a fresh sandbox' },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { environmentsEntry, isolationEntry, type NamedEvalSchema } from "./eval-environment-schemas.ts";
|
|
1
2
|
import type { EvalSchemaResult } from "./schema-bridge.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -22,7 +23,7 @@ const handleRefSchema = {
|
|
|
22
23
|
required: ["kind", "id", "run_epoch"],
|
|
23
24
|
} as const;
|
|
24
25
|
|
|
25
|
-
const waitEntry:
|
|
26
|
+
const waitEntry: NamedEvalSchema = {
|
|
26
27
|
name: "eval:wait",
|
|
27
28
|
description: [
|
|
28
29
|
"wait(handles, {timeout?, mode?}) blocks the cell until the given handles settle (js: await wait(...); py/rb: wait(...); jl: wait(handle(node)) or wait([handle(a), handle(b)])).",
|
|
@@ -44,7 +45,7 @@ const waitEntry: EvalSchemaResult = {
|
|
|
44
45
|
},
|
|
45
46
|
};
|
|
46
47
|
|
|
47
|
-
const helpersEntry:
|
|
48
|
+
const helpersEntry: NamedEvalSchema = {
|
|
48
49
|
name: "eval:helpers",
|
|
49
50
|
description: [
|
|
50
51
|
"Advanced cell helpers (all four languages; js helpers are awaited).",
|
|
@@ -70,7 +71,7 @@ const helpersEntry: EvalSchemaResult = {
|
|
|
70
71
|
},
|
|
71
72
|
};
|
|
72
73
|
|
|
73
|
-
const kernelToolsEntry:
|
|
74
|
+
const kernelToolsEntry: NamedEvalSchema = {
|
|
74
75
|
name: "eval:kernel-tools",
|
|
75
76
|
description: [
|
|
76
77
|
"Kernel tools: functions a cell defines that in-process children can call (js and py; rb/jl answer tools_unavailable).",
|
|
@@ -90,12 +91,18 @@ const kernelToolsEntry: EvalSchemaResult = {
|
|
|
90
91
|
},
|
|
91
92
|
};
|
|
92
93
|
|
|
93
|
-
const VIRTUAL_ENTRIES: ReadonlyMap<string, EvalSchemaResult> = new Map([
|
|
94
|
+
const VIRTUAL_ENTRIES: ReadonlyMap<string, EvalSchemaResult> = new Map<string, EvalSchemaResult>([
|
|
94
95
|
[waitEntry.name, waitEntry],
|
|
95
96
|
[helpersEntry.name, helpersEntry],
|
|
96
97
|
[kernelToolsEntry.name, kernelToolsEntry],
|
|
98
|
+
[environmentsEntry.name, environmentsEntry],
|
|
99
|
+
[isolationEntry.name, isolationEntry],
|
|
97
100
|
]);
|
|
98
101
|
|
|
99
102
|
export function virtualEvalSchema(name: string): EvalSchemaResult | undefined {
|
|
100
103
|
return VIRTUAL_ENTRIES.get(name);
|
|
101
104
|
}
|
|
105
|
+
|
|
106
|
+
export function virtualEvalSchemaNames(): readonly string[] {
|
|
107
|
+
return [...VIRTUAL_ENTRIES.keys()];
|
|
108
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
2
|
+
import type { EvalImageContent } from "../tool/image-resize.ts";
|
|
3
|
+
|
|
4
|
+
/** A notification's message content: plain text, or text and the cells' images in one message. */
|
|
5
|
+
export type EvalNotificationContent = string | ({ readonly type: "text"; readonly text: string } | EvalImageContent)[];
|
|
6
|
+
|
|
2
7
|
import type { EvalDetachedCellNotification, EvalDetachedCellNotifier } from "../tool/detached-cell-manager.ts";
|
|
3
8
|
|
|
4
9
|
const NON_INTERACTIVE_MODES = new Set(["print", "json"]);
|
|
@@ -11,7 +16,7 @@ export type EvalNotifyMode = "wake" | "next-turn" | "off";
|
|
|
11
16
|
export interface EvalNotifierDeps {
|
|
12
17
|
/** Deliver a model-visible notification without rendering synthetic user input. */
|
|
13
18
|
readonly sendMessage: (
|
|
14
|
-
message: { customType: string; content:
|
|
19
|
+
message: { customType: string; content: EvalNotificationContent; display: boolean },
|
|
15
20
|
options: { triggerTurn: boolean; deliverAs: "steer" | "followUp" },
|
|
16
21
|
) => void;
|
|
17
22
|
readonly getContext: () => ExtensionContext | undefined;
|
|
@@ -43,10 +48,16 @@ export class EvalNotifier implements EvalDetachedCellNotifier {
|
|
|
43
48
|
this.#deps.sendMessage(
|
|
44
49
|
{
|
|
45
50
|
customType: EVAL_NOTIFICATION_CUSTOM_TYPE,
|
|
46
|
-
content: pending
|
|
51
|
+
content: notificationContent(pending),
|
|
47
52
|
display: false,
|
|
48
53
|
},
|
|
49
54
|
{ triggerTurn: true, deliverAs: mode === "wake" ? "steer" : "followUp" },
|
|
50
55
|
);
|
|
51
56
|
}
|
|
52
57
|
}
|
|
58
|
+
|
|
59
|
+
function notificationContent(cells: readonly EvalDetachedCellNotification[]): EvalNotificationContent {
|
|
60
|
+
const text = cells.map((cell) => cell.content).join("\n\n");
|
|
61
|
+
const images = cells.flatMap((cell) => cell.images ?? []);
|
|
62
|
+
return images.length === 0 ? text : [{ type: "text", text }, ...images];
|
|
63
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
resolveMaxDetachedCells,
|
|
11
11
|
resolveRunBudgetSeconds,
|
|
12
12
|
} from "./config/settings.ts";
|
|
13
|
-
import { EvalNotifier } from "./extension/eval-notifier.ts";
|
|
13
|
+
import { type EvalNotificationContent, EvalNotifier } from "./extension/eval-notifier.ts";
|
|
14
14
|
import { EVAL_CELLS_STATUS_KEY } from "./extension/eval-status.ts";
|
|
15
15
|
import { EvalStatusTicker } from "./extension/eval-status-ticker.ts";
|
|
16
16
|
import { hostLine, modelIdFrom } from "./extension/host-facts.ts";
|
|
@@ -57,7 +57,7 @@ export interface CodemodeExtensionAPI {
|
|
|
57
57
|
getActiveTools(): string[];
|
|
58
58
|
getAllTools(): readonly EvalSchemaToolInfo[];
|
|
59
59
|
sendMessage(
|
|
60
|
-
message: { customType: string; content:
|
|
60
|
+
message: { customType: string; content: EvalNotificationContent; display: boolean },
|
|
61
61
|
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
62
62
|
): void;
|
|
63
63
|
/** Optional host event bus; a host without one turns extension event emission into a harmless no-op. */
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { currentCell, onReleaseOf, settleWithCell } from "./cell-run-context.js";
|
|
2
|
+
|
|
3
|
+
// What a cell owns, and how each kind ends when the cell is stopped. Every patch is a no-op outside a cell, refuses
|
|
4
|
+
// (or never starts) new work for a released cell, and registers what a live cell starts so release can end it.
|
|
5
|
+
const RESOURCE_CLOSERS = ["terminate", "destroy", "stop", "close", "end"];
|
|
6
|
+
|
|
7
|
+
// Some host bindings are read-only (Bun.fetch); those keep their own behaviour and are not owned by cells.
|
|
8
|
+
function replace(owner, name, replacement, patched) {
|
|
9
|
+
const original = owner[name];
|
|
10
|
+
try {
|
|
11
|
+
owner[name] = replacement;
|
|
12
|
+
} catch {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (owner[name] !== replacement) return;
|
|
16
|
+
patched.push(() => {
|
|
17
|
+
owner[name] = original;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function builtin(name) {
|
|
22
|
+
return process.getBuiltinModule?.(name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function closeResource(resource) {
|
|
26
|
+
for (const method of RESOURCE_CLOSERS) {
|
|
27
|
+
if (typeof resource?.[method] !== "function") continue;
|
|
28
|
+
if (method === "stop") resource.stop(true);
|
|
29
|
+
else resource[method]();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ownResultsOf(owner, names, patched) {
|
|
35
|
+
for (const name of names) {
|
|
36
|
+
const original = owner?.[name];
|
|
37
|
+
if (typeof original !== "function") continue;
|
|
38
|
+
const own = function (...args) {
|
|
39
|
+
const cell = currentCell();
|
|
40
|
+
if (cell === undefined) return original.apply(this, args);
|
|
41
|
+
if (cell.released) throw cell.interruption;
|
|
42
|
+
const result = original.apply(this, args);
|
|
43
|
+
const register = (resource) => {
|
|
44
|
+
onReleaseOf(cell, () => closeResource(resource));
|
|
45
|
+
return resource;
|
|
46
|
+
};
|
|
47
|
+
return result instanceof Promise ? result.then(register) : register(result);
|
|
48
|
+
};
|
|
49
|
+
Object.assign(own, original);
|
|
50
|
+
replace(owner, name, own, patched);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// `globals` is the global scope already patched: a module binding that is the same owned function is left alone.
|
|
55
|
+
function patchTimers(scope, patched, globals = undefined) {
|
|
56
|
+
for (const [name, clearName] of [
|
|
57
|
+
["setTimeout", "clearTimeout"],
|
|
58
|
+
["setInterval", "clearInterval"],
|
|
59
|
+
["setImmediate", "clearImmediate"],
|
|
60
|
+
]) {
|
|
61
|
+
const original = scope?.[name];
|
|
62
|
+
const clear = scope?.[clearName];
|
|
63
|
+
if (typeof original !== "function" || typeof clear !== "function") continue;
|
|
64
|
+
if (globals !== undefined && original === globals[name]) continue;
|
|
65
|
+
const owned = function (callback, ...rest) {
|
|
66
|
+
const cell = currentCell();
|
|
67
|
+
if (cell === undefined || typeof callback !== "function") return original.call(this, callback, ...rest);
|
|
68
|
+
if (cell.released) {
|
|
69
|
+
const never = original.call(this, () => {}, ...rest);
|
|
70
|
+
clear.call(scope, never);
|
|
71
|
+
return never;
|
|
72
|
+
}
|
|
73
|
+
let forget = () => {};
|
|
74
|
+
const handle = original.call(
|
|
75
|
+
this,
|
|
76
|
+
function (...args) {
|
|
77
|
+
if (name !== "setInterval") forget();
|
|
78
|
+
return callback.apply(this, args);
|
|
79
|
+
},
|
|
80
|
+
...rest,
|
|
81
|
+
);
|
|
82
|
+
forget = onReleaseOf(cell, () => clear.call(scope, handle));
|
|
83
|
+
return handle;
|
|
84
|
+
};
|
|
85
|
+
Object.assign(owned, original);
|
|
86
|
+
replace(scope, name, owned, patched);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function patchPromiseApi(owner, name, patched) {
|
|
91
|
+
const original = owner?.[name];
|
|
92
|
+
if (typeof original !== "function") return;
|
|
93
|
+
const bound = function (...args) {
|
|
94
|
+
const cell = currentCell();
|
|
95
|
+
if (cell === undefined) return original.apply(this, args);
|
|
96
|
+
if (cell.released) return Promise.reject(cell.interruption);
|
|
97
|
+
return settleWithCell(cell, original.apply(this, args));
|
|
98
|
+
};
|
|
99
|
+
Object.assign(bound, original);
|
|
100
|
+
replace(owner, name, bound, patched);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function patchTimerPromises(patched) {
|
|
104
|
+
const timers = builtin("node:timers/promises");
|
|
105
|
+
if (timers === undefined) return;
|
|
106
|
+
patchPromiseApi(timers, "setTimeout", patched);
|
|
107
|
+
patchPromiseApi(timers, "setImmediate", patched);
|
|
108
|
+
const original = timers.setInterval;
|
|
109
|
+
if (typeof original !== "function") return;
|
|
110
|
+
const owned = function (...args) {
|
|
111
|
+
const cell = currentCell();
|
|
112
|
+
if (cell?.released) throw cell.interruption;
|
|
113
|
+
const iterator = original.apply(this, args);
|
|
114
|
+
if (cell === undefined) return iterator;
|
|
115
|
+
onReleaseOf(cell, () => void iterator.return?.());
|
|
116
|
+
return iterator;
|
|
117
|
+
};
|
|
118
|
+
replace(timers, "setInterval", owned, patched);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function patchFetch(scope, patched, name = "fetch") {
|
|
122
|
+
const original = scope[name];
|
|
123
|
+
if (typeof original !== "function") return;
|
|
124
|
+
const owned = function (input, init) {
|
|
125
|
+
const cell = currentCell();
|
|
126
|
+
if (cell === undefined) return original.call(this, input, init);
|
|
127
|
+
if (cell.released) return Promise.reject(cell.interruption);
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const own = init?.signal ?? (input instanceof Request ? input.signal : undefined);
|
|
130
|
+
const signal = own === undefined ? controller.signal : AbortSignal.any([own, controller.signal]);
|
|
131
|
+
const forget = onReleaseOf(cell, () => controller.abort(cell.interruption));
|
|
132
|
+
return original.call(this, input, { ...init, signal }).finally(forget);
|
|
133
|
+
};
|
|
134
|
+
Object.assign(owned, original);
|
|
135
|
+
replace(scope, name, owned, patched);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A released cell's code may still be resumed by a short I/O completion; refusing new I/O at that point makes such a
|
|
139
|
+
// loop throw the interruption on its next operation instead of running on in the kept worker.
|
|
140
|
+
function refuseWhenReleased(owner, names, patched) {
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
const original = owner?.[name];
|
|
143
|
+
if (typeof original !== "function") continue;
|
|
144
|
+
const guarded = function (...args) {
|
|
145
|
+
const cell = currentCell();
|
|
146
|
+
if (cell?.released) throw cell.interruption;
|
|
147
|
+
const result = original.apply(this, args);
|
|
148
|
+
return cell === undefined ? result : settleWithCell(cell, result);
|
|
149
|
+
};
|
|
150
|
+
Object.assign(guarded, original);
|
|
151
|
+
replace(owner, name, guarded, patched);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The callback form: a released cell's operation is never started, so its callback never runs, the same way a timer a
|
|
156
|
+
// released cell sets never fires.
|
|
157
|
+
function neverStartWhenReleased(owner, names, patched) {
|
|
158
|
+
for (const name of names) {
|
|
159
|
+
const original = owner?.[name];
|
|
160
|
+
if (typeof original !== "function") continue;
|
|
161
|
+
const guarded = function (...args) {
|
|
162
|
+
if (currentCell()?.released) return undefined;
|
|
163
|
+
return original.apply(this, args);
|
|
164
|
+
};
|
|
165
|
+
Object.assign(guarded, original);
|
|
166
|
+
replace(owner, name, guarded, patched);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const FS_OPERATIONS = ["readFile", "writeFile", "appendFile", "readdir", "stat", "lstat", "open", "access", "mkdir", "rm", "unlink", "rename", "copyFile"];
|
|
171
|
+
|
|
172
|
+
// For ES classes only (Worker, WebSocket, MessageChannel): `instanceof` keeps accepting instances the runtime creates
|
|
173
|
+
// internally from the original class. Node's function-style constructors are owned on activation instead.
|
|
174
|
+
function patchClass(scope, name, close, patched) {
|
|
175
|
+
const Original = scope?.[name];
|
|
176
|
+
if (typeof Original !== "function") return;
|
|
177
|
+
const Owned = class extends Original {
|
|
178
|
+
constructor(...args) {
|
|
179
|
+
const cell = currentCell();
|
|
180
|
+
if (cell?.released) throw cell.interruption;
|
|
181
|
+
super(...args);
|
|
182
|
+
if (cell !== undefined) onReleaseOf(cell, () => close(this));
|
|
183
|
+
}
|
|
184
|
+
static [Symbol.hasInstance](value) {
|
|
185
|
+
return value instanceof Original;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
Object.defineProperty(Owned, "name", { value: Original.name });
|
|
189
|
+
replace(scope, name, Owned, patched);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const NODE_RESOURCE_FACTORIES = {
|
|
193
|
+
"node:net": ["createConnection", "connect", "createServer"],
|
|
194
|
+
"node:tls": ["connect", "createServer"],
|
|
195
|
+
"node:http": ["request", "get", "createServer"],
|
|
196
|
+
"node:https": ["request", "get", "createServer"],
|
|
197
|
+
"node:dgram": ["createSocket"],
|
|
198
|
+
"node:fs": ["createReadStream", "createWriteStream", "watch"],
|
|
199
|
+
"node:readline": ["createInterface"],
|
|
200
|
+
"node:readline/promises": ["createInterface"],
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Node's socket and server types are function-style constructors that Node's own subclasses call with `.call(this)`
|
|
204
|
+
// (http.Server calls net.Server), so they cannot be swapped for a class. A socket or server only does anything once it
|
|
205
|
+
// connects, listens or binds, so that call is where a cell takes ownership of it; http, https and tls inherit these.
|
|
206
|
+
const NODE_ACTIVATIONS = {
|
|
207
|
+
"node:net": { Socket: ["connect"], Server: ["listen"] },
|
|
208
|
+
"node:dgram": { Socket: ["bind"] },
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
function ownOnActivation(proto, names, patched) {
|
|
212
|
+
for (const name of names) {
|
|
213
|
+
const original = proto?.[name];
|
|
214
|
+
if (typeof original !== "function") continue;
|
|
215
|
+
const owned = new WeakSet();
|
|
216
|
+
const activate = function (...args) {
|
|
217
|
+
const cell = currentCell();
|
|
218
|
+
if (cell === undefined) return original.apply(this, args);
|
|
219
|
+
if (cell.released) throw cell.interruption;
|
|
220
|
+
if (!owned.has(this)) {
|
|
221
|
+
owned.add(this);
|
|
222
|
+
onReleaseOf(cell, () => closeResource(this));
|
|
223
|
+
}
|
|
224
|
+
return original.apply(this, args);
|
|
225
|
+
};
|
|
226
|
+
replace(proto, name, activate, patched);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function installCellOwnership(scope = globalThis) {
|
|
231
|
+
const patched = [];
|
|
232
|
+
patchTimers(scope, patched);
|
|
233
|
+
patchTimers(builtin("node:timers"), patched, scope);
|
|
234
|
+
patchTimerPromises(patched);
|
|
235
|
+
patchFetch(scope, patched);
|
|
236
|
+
patchClass(scope, "WebSocket", (socket) => socket.close(), patched);
|
|
237
|
+
patchClass(scope, "Worker", (worker) => void worker.terminate(), patched);
|
|
238
|
+
refuseWhenReleased(builtin("node:fs/promises"), FS_OPERATIONS, patched);
|
|
239
|
+
neverStartWhenReleased(builtin("node:fs"), FS_OPERATIONS, patched);
|
|
240
|
+
patchClass(scope, "MessageChannel", (channel) => {
|
|
241
|
+
channel.port1.close();
|
|
242
|
+
channel.port2.close();
|
|
243
|
+
}, patched);
|
|
244
|
+
patchClass(scope, "BroadcastChannel", (channel) => channel.close(), patched);
|
|
245
|
+
const bun = scope.Bun;
|
|
246
|
+
if (bun !== undefined) {
|
|
247
|
+
patchPromiseApi(bun, "sleep", patched);
|
|
248
|
+
if (bun.fetch !== scope.fetch) patchFetch(bun, patched, "fetch");
|
|
249
|
+
refuseWhenReleased(bun, ["file", "write"], patched);
|
|
250
|
+
ownResultsOf(bun, ["connect", "listen", "serve", "udpSocket"], patched);
|
|
251
|
+
}
|
|
252
|
+
for (const [module, factories] of Object.entries(NODE_RESOURCE_FACTORIES)) ownResultsOf(builtin(module), factories, patched);
|
|
253
|
+
patchClass(builtin("node:worker_threads"), "Worker", (worker) => void worker.terminate(), patched);
|
|
254
|
+
for (const [module, types] of Object.entries(NODE_ACTIVATIONS)) {
|
|
255
|
+
for (const [name, methods] of Object.entries(types)) ownOnActivation(builtin(module)?.[name]?.prototype, methods, patched);
|
|
256
|
+
}
|
|
257
|
+
// Node hands `import("node:…")` its own ESM namespace; without this sync a cell importing the module gets the
|
|
258
|
+
// unowned originals.
|
|
259
|
+
syncBuiltinExports();
|
|
260
|
+
return () => {
|
|
261
|
+
for (const undo of patched.reverse()) undo();
|
|
262
|
+
syncBuiltinExports();
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function syncBuiltinExports() {
|
|
267
|
+
builtin("node:module")?.syncBuiltinESMExports?.();
|
|
268
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
// Every continuation a cell creates (awaits, timers, I/O callbacks) runs inside its cell record. Stopping a cell
|
|
4
|
+
// releases it: everything it owns is cleared, rejected or closed (`cell-ownership.js` decides what a cell owns), and
|
|
5
|
+
// anything its code tries to start afterwards is refused, while the kernel and its variables stay.
|
|
6
|
+
const cellRuns = new AsyncLocalStorage();
|
|
7
|
+
const releasedInterruptions = new WeakMap();
|
|
8
|
+
const MAX_CAUSE_DEPTH = 8;
|
|
9
|
+
|
|
10
|
+
export function runInCell(cell, run) {
|
|
11
|
+
return cellRuns.run(cell, run);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function currentCell() {
|
|
15
|
+
return cellRuns.getStore();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function releasedCellError() {
|
|
19
|
+
const cell = cellRuns.getStore();
|
|
20
|
+
return cell?.released === true ? cell.interruption : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function assertCellLive() {
|
|
24
|
+
const error = releasedCellError();
|
|
25
|
+
if (error !== undefined) throw error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function onCellRelease(close) {
|
|
29
|
+
const cell = cellRuns.getStore();
|
|
30
|
+
return cell === undefined ? () => {} : onReleaseOf(cell, close);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function onReleaseOf(cell, close) {
|
|
34
|
+
cell.onRelease ??= new Set();
|
|
35
|
+
cell.onRelease.add(close);
|
|
36
|
+
return () => cell.onRelease.delete(close);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Releases the cell: every closer runs even when an earlier one throws, and the failures are returned so the caller
|
|
41
|
+
* can report them; a resource that would not close is a fact the user should see, not something to swallow.
|
|
42
|
+
*/
|
|
43
|
+
export function releaseCell(cell) {
|
|
44
|
+
cell.released = true;
|
|
45
|
+
releasedInterruptions.set(cell.interruption, cell);
|
|
46
|
+
const failures = [];
|
|
47
|
+
for (const close of cell.onRelease ?? []) {
|
|
48
|
+
try {
|
|
49
|
+
close();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
failures.push(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
cell.onRelease?.clear();
|
|
55
|
+
return failures;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The cell an unhandled rejection came from: a released cell whose interruption is the reason or sits in its `cause`
|
|
60
|
+
* chain, or the cell whose async context the rejection runs in (Node exposes it to the handler; Bun does not).
|
|
61
|
+
*/
|
|
62
|
+
export function cellOfRejection(reason) {
|
|
63
|
+
let current = reason;
|
|
64
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof current === "object" && current !== null; depth++) {
|
|
65
|
+
const cell = releasedInterruptions.get(current);
|
|
66
|
+
if (cell !== undefined) return cell;
|
|
67
|
+
current = current.cause;
|
|
68
|
+
}
|
|
69
|
+
return cellRuns.getStore();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// An operation already in flight when its cell is released rejects with the interruption at once, so the stopped
|
|
73
|
+
// cell's continuation never runs again; a non-promise result is returned as it is.
|
|
74
|
+
export function settleWithCell(cell, result) {
|
|
75
|
+
if (result === null || typeof result !== "object" || typeof result.then !== "function") return result;
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const forget = onReleaseOf(cell, () => reject(cell.interruption));
|
|
78
|
+
result.then(
|
|
79
|
+
(value) => {
|
|
80
|
+
forget();
|
|
81
|
+
resolve(value);
|
|
82
|
+
},
|
|
83
|
+
(error) => {
|
|
84
|
+
forget();
|
|
85
|
+
reject(error);
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
}
|