@code-yeongyu/senpi-codemode 2026.8.22 → 2026.8.24
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 +46 -0
- package/README.md +5 -0
- package/package.json +5 -5
- package/src/extension/eval-notifier.ts +16 -4
- package/src/extension/runtime-factory.ts +4 -1
- package/src/extension/runtime-info.ts +41 -0
- package/src/index.ts +9 -3
- package/src/interpreters/detect.ts +17 -4
- package/src/interpreters/resolve-command.ts +68 -0
- package/src/kernels/AGENTS.md +40 -0
- package/src/timeouts/idle-timeout.ts +33 -4
- package/src/tool/AGENTS.md +40 -0
- package/src/tool/cell-runtime.ts +5 -1
- package/src/tool/detached-cell-manager.ts +1 -1
- package/src/tool/detached-cell-notification.ts +3 -8
- package/src/tool/detached-notification-queue.ts +2 -6
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/eval-tool.ts +2 -0
- package/src/tool/render.ts +60 -3
- package/src/tool/runtime-label.ts +49 -0
- package/src/tool/types.ts +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,52 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.8.24] - 2026-08-24
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Detached eval cell overflow notices now point at the absolute spill file path (`…/local/detached-eval-<id>.log`) instead of a `local://detached-eval-<id>.log` URI. `local://` is resolved only by the in-cell kernel helpers, not by the agent `read` tool, so following the old notice failed with `ENOENT …/local:/detached-eval-<id>.log`. This restores the documented contract that spill notices carry plain absolute paths.
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
|
|
29
|
+
## [2026.8.23] - 2026-08-23
|
|
30
|
+
|
|
31
|
+
### Breaking Changes
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Detached eval cell completion notices no longer enter the user-input steering queue. They were delivered via `sendUserMessage`, so hosts projecting that queue (e.g. the OmO desktop composer) rendered the raw `<system-reminder>Detached eval cell …</system-reminder>` notice under the STEERING heading as if the user had typed and queued it. Notices now deliver via `sendMessage` with `customType: "senpi-codemode:notification"` and `display: false` — model-visible, never painted as user input — matching the terminal and monitor notification contract.
|
|
40
|
+
|
|
41
|
+
### Removed
|
|
42
|
+
|
|
43
|
+
## [2026.8.22-2] - 2026-08-22
|
|
44
|
+
|
|
45
|
+
### Breaking Changes
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- Eval headers now display the kernel runtime identity, e.g. `eval py (3.14.7, ~/.venv/bin/python3)` and `eval js (node 26.7.0, /opt/…/bin/node)`; the same `runtime` info rides `EvalToolDetails` and its `cells` so RPC consumers receive it, interpreter detection resolves absolute executable paths from PATH, and the eval prompt host line names the JS runtime (`node`/`bun` with version).
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- Running eval cell headers now tick their elapsed time in real time (`eval py running · 13s`) instead of freezing between kernel update events; the renderer derives elapsed time from a render-time clock while a cell is pending/running/detached and repaints once per second, while settled cells keep their exact final duration. `EvalCellResult` gains an additive `startedAt` so RPC consumers can compute the same live value.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- A host tool call from inside an eval cell no longer suspends the cell's timeout indefinitely. The idle watchdog previously cleared its timer for the entire duration of a bridge call, so a call that never returned (e.g. an awaited `dag-wait`) left the cell pending — and the agent loop parked, queueing user messages invisibly — until the 1800s hard limit. The pause is now bounded by a max pause grace (default 600s, floored at the cell's own `timeout`): a long bridge call such as a 5-minute build still runs to completion, but a stuck one now trips the cell's `on_timeout` handling and releases the loop.
|
|
58
|
+
|
|
59
|
+
### Removed
|
|
60
|
+
|
|
15
61
|
## [2026.8.22] - 2026-08-22
|
|
16
62
|
|
|
17
63
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -30,6 +30,11 @@ task-tool names are known.
|
|
|
30
30
|
- TUI and HTML-export rendering for syntax-highlighted cells, status rows,
|
|
31
31
|
task progress, structured display values, truncation warnings, and image
|
|
32
32
|
fallbacks.
|
|
33
|
+
- Runtime identity badges in eval headers — `eval py (3.14.7, ~/.venv/bin/python3)`,
|
|
34
|
+
`eval js (node 26.7.0, /opt/…/bin/node)` — with the same `runtime` info on
|
|
35
|
+
`EvalToolDetails` and its `cells` for RPC consumers; interpreter detection
|
|
36
|
+
resolves absolute executable paths, and the eval prompt host line names the
|
|
37
|
+
JS runtime (`node`/`bun`).
|
|
33
38
|
- JavaScript import rewriting for supported local modules and package imports
|
|
34
39
|
in the persistent Node.js worker.
|
|
35
40
|
- GPT models receive a terse `eval` prompt dialect that prioritizes composing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.8.
|
|
3
|
+
"version": "2026.8.24",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@babel/parser": "8.0.4",
|
|
33
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.8.
|
|
34
|
-
"typebox": "1.3.
|
|
33
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.8.24",
|
|
34
|
+
"typebox": "1.3.18"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@code-yeongyu/senpi": "2026.8.
|
|
37
|
+
"@code-yeongyu/senpi": "2026.8.24"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.8.
|
|
40
|
+
"@code-yeongyu/senpi": "2026.8.24"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"senpi",
|
|
@@ -3,10 +3,17 @@ import type { EvalDetachedCellNotification, EvalDetachedCellNotifier } from "../
|
|
|
3
3
|
|
|
4
4
|
const NON_INTERACTIVE_MODES = new Set(["print", "json"]);
|
|
5
5
|
|
|
6
|
+
/** Provenance marker for agent-internal detached-cell notices. */
|
|
7
|
+
export const EVAL_NOTIFICATION_CUSTOM_TYPE = "senpi-codemode:notification";
|
|
8
|
+
|
|
6
9
|
export type EvalNotifyMode = "wake" | "next-turn" | "off";
|
|
7
10
|
|
|
8
11
|
export interface EvalNotifierDeps {
|
|
9
|
-
|
|
12
|
+
/** Deliver a model-visible notification without rendering synthetic user input. */
|
|
13
|
+
readonly sendMessage: (
|
|
14
|
+
message: { customType: string; content: string; display: boolean },
|
|
15
|
+
options: { triggerTurn: boolean; deliverAs: "steer" | "followUp" },
|
|
16
|
+
) => void;
|
|
10
17
|
readonly getContext: () => ExtensionContext | undefined;
|
|
11
18
|
readonly getMode: () => EvalNotifyMode;
|
|
12
19
|
}
|
|
@@ -33,8 +40,13 @@ export class EvalNotifier implements EvalDetachedCellNotifier {
|
|
|
33
40
|
const pending = cells.filter((cell) => !this.#notified.has(cell.cellId));
|
|
34
41
|
if (pending.length === 0) return;
|
|
35
42
|
for (const cell of pending) this.#notified.add(cell.cellId);
|
|
36
|
-
this.#deps.
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
this.#deps.sendMessage(
|
|
44
|
+
{
|
|
45
|
+
customType: EVAL_NOTIFICATION_CUSTOM_TYPE,
|
|
46
|
+
content: pending.map((cell) => cell.content).join("\n\n"),
|
|
47
|
+
display: false,
|
|
48
|
+
},
|
|
49
|
+
{ triggerTurn: true, deliverAs: mode === "wake" ? "steer" : "followUp" },
|
|
50
|
+
);
|
|
39
51
|
}
|
|
40
52
|
}
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
type InterpreterAvailability,
|
|
15
15
|
} from "../interpreters/detect.ts";
|
|
16
16
|
import { resolveSessionArtifactsDir } from "../output/streaming-output.ts";
|
|
17
|
-
import type { EnabledEvalLanguages, EvalLanguage } from "../tool/types.ts";
|
|
17
|
+
import type { EnabledEvalLanguages, EvalLanguage, EvalRuntimes } from "../tool/types.ts";
|
|
18
|
+
import { jsRuntimeInfo, runtimesFromAvailability } from "./runtime-info.ts";
|
|
18
19
|
import {
|
|
19
20
|
type CodemodeSessionManager,
|
|
20
21
|
type CreateCodemodeSessionManagerOptions,
|
|
@@ -39,6 +40,7 @@ export type SessionRuntime = {
|
|
|
39
40
|
readonly parallelPoolWidth: number;
|
|
40
41
|
readonly manager: CodemodeSessionManager;
|
|
41
42
|
readonly enabledLanguages: EnabledEvalLanguages;
|
|
43
|
+
readonly runtimes: EvalRuntimes;
|
|
42
44
|
readonly settings: ResolvedCodemodeSettings;
|
|
43
45
|
readonly artifactsDir: string;
|
|
44
46
|
readonly executeTool: AgentExecuteTool;
|
|
@@ -82,6 +84,7 @@ export async function createRuntime(
|
|
|
82
84
|
parallelPoolWidth,
|
|
83
85
|
manager,
|
|
84
86
|
enabledLanguages,
|
|
87
|
+
runtimes: runtimesFromAvailability(availability, jsRuntimeInfo()),
|
|
85
88
|
settings,
|
|
86
89
|
artifactsDir: artifacts.dir,
|
|
87
90
|
executeTool,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { InterpreterAvailability } from "../interpreters/detect.ts";
|
|
2
|
+
import type { EvalLanguage, EvalRuntimeInfo, EvalRuntimes } from "../tool/types.ts";
|
|
3
|
+
|
|
4
|
+
export interface JsRuntimeVersions {
|
|
5
|
+
readonly node: string;
|
|
6
|
+
readonly bun?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Identity of the in-process JS kernel host: bun when its marker exists, node otherwise. */
|
|
10
|
+
export function jsRuntimeInfo(
|
|
11
|
+
versions: JsRuntimeVersions = process.versions,
|
|
12
|
+
execPath: string = process.execPath,
|
|
13
|
+
): EvalRuntimeInfo {
|
|
14
|
+
const bun = versions.bun;
|
|
15
|
+
if (bun !== undefined && bun.length > 0) return { name: "bun", version: bun, path: execPath };
|
|
16
|
+
return { name: "node", version: versions.node, path: execPath };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Short host-line segment, e.g. "node 26.7.0" or "bun 1.4.0". */
|
|
20
|
+
export function jsRuntimeLabel(versions: JsRuntimeVersions = process.versions): string {
|
|
21
|
+
const info = jsRuntimeInfo(versions, "");
|
|
22
|
+
return `${info.name} ${info.version}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const subprocessRuntimeNames = { py: "python", rb: "ruby", jl: "julia" } as const;
|
|
26
|
+
const subprocessLanguages = ["py", "rb", "jl"] as const;
|
|
27
|
+
|
|
28
|
+
/** Maps detected interpreters to display runtimes, preferring resolved absolute paths. */
|
|
29
|
+
export function runtimesFromAvailability(availability: InterpreterAvailability, js: EvalRuntimeInfo): EvalRuntimes {
|
|
30
|
+
const runtimes: Partial<Record<EvalLanguage, EvalRuntimeInfo>> = { js };
|
|
31
|
+
for (const language of subprocessLanguages) {
|
|
32
|
+
const detected = availability[language].detected;
|
|
33
|
+
if (!detected.ok) continue;
|
|
34
|
+
runtimes[language] = {
|
|
35
|
+
name: subprocessRuntimeNames[language],
|
|
36
|
+
version: detected.version,
|
|
37
|
+
path: detected.resolvedPath ?? detected.path,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return runtimes;
|
|
41
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
enabledLanguagesFrom,
|
|
14
14
|
type SessionRuntime,
|
|
15
15
|
} from "./extension/runtime-factory.ts";
|
|
16
|
+
import { jsRuntimeInfo, jsRuntimeLabel } from "./extension/runtime-info.ts";
|
|
16
17
|
import type { CodemodeSessionManager, CreateCodemodeSessionManagerOptions } from "./extension/session-manager.ts";
|
|
17
18
|
import { SessionManagerProxy } from "./extension/session-manager-proxy.ts";
|
|
18
19
|
import { WAKE_SOURCE_STATE_EVENT, type WakeSourceState } from "./extension/wake-source-state.ts";
|
|
@@ -43,7 +44,10 @@ export interface CodemodeExtensionAPI {
|
|
|
43
44
|
executeTool: AgentExecuteTool;
|
|
44
45
|
getActiveTools(): string[];
|
|
45
46
|
getAllTools(): readonly EvalSchemaToolInfo[];
|
|
46
|
-
|
|
47
|
+
sendMessage(
|
|
48
|
+
message: { customType: string; content: string; display: boolean },
|
|
49
|
+
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
50
|
+
): void;
|
|
47
51
|
/** Optional host event bus; a host without one turns extension event emission into a harmless no-op. */
|
|
48
52
|
events?: { emit(name: string, data: unknown): void };
|
|
49
53
|
/** Optional host RPC surface for forwarding extension-owned events to connected clients. */
|
|
@@ -68,7 +72,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
68
72
|
let activeContext: ExtensionContext | undefined;
|
|
69
73
|
let activeCells: EvalDetachedCellManager | undefined;
|
|
70
74
|
const notifier = new EvalNotifier({
|
|
71
|
-
|
|
75
|
+
sendMessage: (message, notifyOptions) => pi.sendMessage(message, notifyOptions),
|
|
72
76
|
getContext: () => activeContext,
|
|
73
77
|
getMode: () => "wake",
|
|
74
78
|
});
|
|
@@ -119,6 +123,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
119
123
|
spawns: runtime.spawns,
|
|
120
124
|
spawnDefaultAgent: runtime.settings.taskTools.task,
|
|
121
125
|
hostLine: hostLine(),
|
|
126
|
+
runtimes: runtime.runtimes,
|
|
122
127
|
...(modelId === undefined ? {} : { modelId }),
|
|
123
128
|
}),
|
|
124
129
|
);
|
|
@@ -152,6 +157,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
152
157
|
executionTracker: manager,
|
|
153
158
|
renderers,
|
|
154
159
|
hostLine: hostLine(),
|
|
160
|
+
runtimes: { js: jsRuntimeInfo() },
|
|
155
161
|
}),
|
|
156
162
|
);
|
|
157
163
|
pi.registerRemovedToolHint(
|
|
@@ -206,7 +212,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
206
212
|
|
|
207
213
|
function hostLine(): string {
|
|
208
214
|
const cpu = os.cpus()[0]?.model?.trim();
|
|
209
|
-
return [`${os.platform()} ${os.arch()}`, cpu, `${os.availableParallelism()} cores
|
|
215
|
+
return [`${os.platform()} ${os.arch()}`, cpu, `${os.availableParallelism()} cores`, jsRuntimeLabel()]
|
|
210
216
|
.filter((part): part is string => !!part)
|
|
211
217
|
.join(" \u00b7 ");
|
|
212
218
|
}
|
|
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { platform as currentPlatform } from "node:os";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import type { CodemodeSettings } from "../config/settings.ts";
|
|
5
|
+
import { resolveCommandPath as defaultResolveCommandPath, type ResolveCommandPath } from "./resolve-command.ts";
|
|
5
6
|
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
const probeTimeoutMs = 3_000;
|
|
@@ -10,8 +11,11 @@ export type CodemodeLanguage = "py" | "js" | "rb" | "jl";
|
|
|
10
11
|
|
|
11
12
|
export interface InterpreterDetected {
|
|
12
13
|
readonly ok: true;
|
|
14
|
+
/** The probe command line that answered, e.g. "python3" or "py -3". */
|
|
13
15
|
readonly path: string;
|
|
14
16
|
readonly version: string;
|
|
17
|
+
/** Absolute executable path resolved from PATH, when resolution succeeded. */
|
|
18
|
+
readonly resolvedPath?: string;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export interface InterpreterUnavailable {
|
|
@@ -34,6 +38,7 @@ export interface CreateInterpreterDetectorOptions {
|
|
|
34
38
|
readonly platform?: NodeJS.Platform;
|
|
35
39
|
readonly execFile?: ExecFileProbe;
|
|
36
40
|
readonly nodeVersion?: string;
|
|
41
|
+
readonly resolveCommandPath?: ResolveCommandPath;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export interface InterpreterDetector {
|
|
@@ -55,6 +60,7 @@ export function createInterpreterDetector(options: CreateInterpreterDetectorOpti
|
|
|
55
60
|
const probe = options.execFile ?? defaultExecFileProbe;
|
|
56
61
|
const hostPlatform = options.platform ?? currentPlatform();
|
|
57
62
|
const nodeVersion = options.nodeVersion ?? process.versions.node;
|
|
63
|
+
const resolveCommand = options.resolveCommandPath ?? defaultResolveCommandPath;
|
|
58
64
|
const cache = new Map<CodemodeLanguage, Promise<InterpreterDetection>>();
|
|
59
65
|
|
|
60
66
|
return {
|
|
@@ -64,7 +70,7 @@ export function createInterpreterDetector(options: CreateInterpreterDetectorOpti
|
|
|
64
70
|
return cached;
|
|
65
71
|
}
|
|
66
72
|
|
|
67
|
-
const pending = detectUncached(language, hostPlatform, probe, nodeVersion);
|
|
73
|
+
const pending = detectUncached(language, hostPlatform, probe, nodeVersion, resolveCommand);
|
|
68
74
|
cache.set(language, pending);
|
|
69
75
|
return pending;
|
|
70
76
|
},
|
|
@@ -99,13 +105,14 @@ async function detectUncached(
|
|
|
99
105
|
hostPlatform: NodeJS.Platform,
|
|
100
106
|
probe: ExecFileProbe,
|
|
101
107
|
nodeVersion: string,
|
|
108
|
+
resolveCommand: ResolveCommandPath,
|
|
102
109
|
): Promise<InterpreterDetection> {
|
|
103
110
|
if (language === "js") {
|
|
104
111
|
return { ok: true, path: "node", version: nodeVersion };
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
for (const candidate of candidatesFor(language, hostPlatform)) {
|
|
108
|
-
const result = await probeCandidate(candidate, probe);
|
|
115
|
+
const result = await probeCandidate(candidate, probe, resolveCommand);
|
|
109
116
|
if (result.ok) {
|
|
110
117
|
return result;
|
|
111
118
|
}
|
|
@@ -114,12 +121,18 @@ async function detectUncached(
|
|
|
114
121
|
return unavailable;
|
|
115
122
|
}
|
|
116
123
|
|
|
117
|
-
async function probeCandidate(
|
|
124
|
+
async function probeCandidate(
|
|
125
|
+
candidate: string,
|
|
126
|
+
probe: ExecFileProbe,
|
|
127
|
+
resolveCommand: ResolveCommandPath,
|
|
128
|
+
): Promise<InterpreterDetection> {
|
|
118
129
|
const invocation = candidateInvocation(candidate);
|
|
119
130
|
try {
|
|
120
131
|
const result = await probe(invocation.command, [...invocation.args, "--version"], { timeoutMs: probeTimeoutMs });
|
|
121
132
|
const version = parseVersion(`${result.stdout}\n${result.stderr}`);
|
|
122
|
-
|
|
133
|
+
if (version === null) return unavailable;
|
|
134
|
+
const resolvedPath = resolveCommand(invocation.command);
|
|
135
|
+
return { ok: true, path: candidate, version, ...(resolvedPath === undefined ? {} : { resolvedPath }) };
|
|
123
136
|
} catch {
|
|
124
137
|
return unavailable;
|
|
125
138
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
2
|
+
import { delimiter, isAbsolute, resolve as resolvePath } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface ResolveCommandPathOptions {
|
|
5
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
6
|
+
readonly platform?: NodeJS.Platform;
|
|
7
|
+
readonly cwd?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ResolveCommandPath = (command: string, options?: ResolveCommandPathOptions) => string | undefined;
|
|
11
|
+
|
|
12
|
+
const defaultWindowsPathExt = ".COM;.EXE;.BAT;.CMD";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves a bare command name to the absolute executable path a spawn would
|
|
16
|
+
* use, by scanning PATH without spawning a process. Returns undefined when the
|
|
17
|
+
* command cannot be resolved; callers treat that as "no display path known".
|
|
18
|
+
*/
|
|
19
|
+
export function resolveCommandPath(command: string, options: ResolveCommandPathOptions = {}): string | undefined {
|
|
20
|
+
if (command.length === 0) return undefined;
|
|
21
|
+
const env = options.env ?? process.env;
|
|
22
|
+
const platform = options.platform ?? process.platform;
|
|
23
|
+
const isWindows = platform === "win32";
|
|
24
|
+
if (command.includes("/") || (isWindows && command.includes("\\"))) {
|
|
25
|
+
const absolute = isAbsolute(command) ? command : resolvePath(options.cwd ?? process.cwd(), command);
|
|
26
|
+
return firstExecutable(candidatesFor(absolute, isWindows, env), isWindows);
|
|
27
|
+
}
|
|
28
|
+
const pathValue = env.PATH ?? env.Path ?? "";
|
|
29
|
+
if (pathValue.length === 0) return undefined;
|
|
30
|
+
for (const directory of pathValue.split(delimiter)) {
|
|
31
|
+
if (directory.length === 0) continue;
|
|
32
|
+
const base = `${directory}${directory.endsWith("/") || directory.endsWith("\\") ? "" : pathSeparatorFor(directory, isWindows)}${command}`;
|
|
33
|
+
const found = firstExecutable(candidatesFor(base, isWindows, env), isWindows);
|
|
34
|
+
if (found !== undefined) return found;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pathSeparatorFor(directory: string, isWindows: boolean): string {
|
|
40
|
+
if (isWindows && directory.includes("\\") && !directory.includes("/")) return "\\";
|
|
41
|
+
return "/";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function candidatesFor(base: string, isWindows: boolean, env: NodeJS.ProcessEnv): readonly string[] {
|
|
45
|
+
if (!isWindows) return [base];
|
|
46
|
+
const extensions = (env.PATHEXT ?? defaultWindowsPathExt)
|
|
47
|
+
.split(";")
|
|
48
|
+
.map((extension) => extension.trim())
|
|
49
|
+
.filter((extension) => extension.startsWith("."));
|
|
50
|
+
const candidates = [base];
|
|
51
|
+
for (const extension of extensions) {
|
|
52
|
+
candidates.push(`${base}${extension.toLowerCase()}`, `${base}${extension}`);
|
|
53
|
+
}
|
|
54
|
+
return candidates;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function firstExecutable(candidates: readonly string[], isWindows: boolean): string | undefined {
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
try {
|
|
60
|
+
if (!statSync(candidate).isFile()) continue;
|
|
61
|
+
if (!isWindows) accessSync(candidate, constants.X_OK);
|
|
62
|
+
return candidate;
|
|
63
|
+
} catch {
|
|
64
|
+
// Missing or non-executable candidate: keep scanning the remaining ones.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# src/kernels
|
|
2
|
+
|
|
3
|
+
Persistent kernels for four runtimes plus shared subprocess lifecycle. Earned
|
|
4
|
+
by score 13 — distinct multi-runtime domain (31 files, TS hosts plus embedded
|
|
5
|
+
runner/prelude assets).
|
|
6
|
+
|
|
7
|
+
## WHERE TO LOOK
|
|
8
|
+
|
|
9
|
+
| Task | Path |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| JavaScript host API | `js/context-manager.ts` (`JavaScriptKernel`), `js/worker-host.ts` |
|
|
12
|
+
| JS worker runtime, entries | `js/worker-runtime.js`, `js/worker-entry.js`, `js/inline-worker-entry.js`, `js/inline-worker.ts`, `js/worker-core.js` (+ `worker-core.d.ts`) |
|
|
13
|
+
| JS import rewriting, queueing | `js/rewrite-imports.ts`, `js/run-queue.ts`, `js/prelude.ts`, `js/local-module-loader.ts` |
|
|
14
|
+
| Python kernel | `py/kernel.ts`, `py/transport.ts`, `py/process.ts`, `py/prelude.py` |
|
|
15
|
+
| Ruby kernel | `rb/kernel.ts` + `rb/prelude.rb`, `rb/runner.rb` |
|
|
16
|
+
| Julia kernel | `jl/kernel.ts` + `jl/prelude.jl`, `jl/runner.jl` |
|
|
17
|
+
| Shared subprocess layer | `shared/subprocess-kernel.ts`, `subprocess-{contract,process,queue,run}.ts`, `runtime-asset.ts` |
|
|
18
|
+
|
|
19
|
+
## CONVENTIONS
|
|
20
|
+
|
|
21
|
+
- Each language dir pairs a typed TS host/controller with an embedded runner or
|
|
22
|
+
prelude asset; `shared/runtime-asset.ts` ships them.
|
|
23
|
+
- Transport messages are discriminated by string `type` (`ready`, `result`,
|
|
24
|
+
`tool-call`, `closed`, `init-failed`, ...) exchanged as framed bridge
|
|
25
|
+
messages, one JSON line per frame.
|
|
26
|
+
- JS persistent cell bindings are rewritten onto `globalThis`; imports are
|
|
27
|
+
AST-parsed (Babel) and rewritten to bridge-compatible dynamic imports.
|
|
28
|
+
- JS runs on worker threads with an inline-worker fallback; py/rb/jl run as
|
|
29
|
+
framed subprocesses through `shared/`.
|
|
30
|
+
- Subprocess retirement/restart, worker recovery, timeout, and interrupt
|
|
31
|
+
semantics live here, never in the tool layer.
|
|
32
|
+
|
|
33
|
+
## ANTI-PATTERNS
|
|
34
|
+
|
|
35
|
+
- Never treat arbitrary objects as bridge messages without discriminant
|
|
36
|
+
validation.
|
|
37
|
+
- Never rewrite imports by filename/regex — `rewrite-imports.ts` applies
|
|
38
|
+
source-position edits from the parsed program.
|
|
39
|
+
- An optional interpreter being absent (py/rb/jl not installed) is a capability
|
|
40
|
+
gap, not an installation failure or error path.
|
|
@@ -3,9 +3,18 @@ export interface IdleTimeoutEvent {
|
|
|
3
3
|
readonly error: Error;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Upper bound on how long a single host bridge call may suspend a cell's idle watchdog.
|
|
8
|
+
* Generous enough for a long build or a slow model call, short enough that a bridge call which
|
|
9
|
+
* never returns cannot park the cell — and with it the agent loop — until the 1800s hard limit.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_MAX_PAUSE_GRACE_MS = 600_000;
|
|
12
|
+
|
|
6
13
|
export interface IdleTimeoutOptions {
|
|
7
14
|
readonly cellId: string;
|
|
8
15
|
readonly timeoutMs: number;
|
|
16
|
+
/** Defaults to {@link DEFAULT_MAX_PAUSE_GRACE_MS}; floored at `timeoutMs` so a pause never shortens the budget. */
|
|
17
|
+
readonly maxPauseGraceMs?: number;
|
|
9
18
|
readonly onTimeout: (event: IdleTimeoutEvent) => void;
|
|
10
19
|
}
|
|
11
20
|
|
|
@@ -20,7 +29,9 @@ export class IdleTimeout implements TimeoutPauseHandle {
|
|
|
20
29
|
readonly #controller = new AbortController();
|
|
21
30
|
readonly signal = this.#controller.signal;
|
|
22
31
|
readonly timeoutMs: number;
|
|
32
|
+
readonly maxPauseGraceMs: number;
|
|
23
33
|
#deadlineMs: number;
|
|
34
|
+
#pausedDeadlineMs: number | undefined;
|
|
24
35
|
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
25
36
|
#pauseDepth = 0;
|
|
26
37
|
#settled = false;
|
|
@@ -28,22 +39,32 @@ export class IdleTimeout implements TimeoutPauseHandle {
|
|
|
28
39
|
constructor(options: IdleTimeoutOptions) {
|
|
29
40
|
this.#cellId = options.cellId;
|
|
30
41
|
this.timeoutMs = Math.max(1, Math.floor(options.timeoutMs));
|
|
42
|
+
this.maxPauseGraceMs = Math.max(
|
|
43
|
+
this.timeoutMs,
|
|
44
|
+
Math.floor(options.maxPauseGraceMs ?? DEFAULT_MAX_PAUSE_GRACE_MS),
|
|
45
|
+
);
|
|
31
46
|
this.#deadlineMs = Date.now() + this.timeoutMs;
|
|
32
47
|
this.#onTimeout = options.onTimeout;
|
|
33
48
|
this.#arm(this.timeoutMs);
|
|
34
49
|
}
|
|
35
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Suspends the idle deadline for the duration of a host bridge call, but only up to the pause grace:
|
|
53
|
+
* the cell still expires if the call never returns. Nested pauses share the outermost pause's deadline.
|
|
54
|
+
*/
|
|
36
55
|
pause(): void {
|
|
37
56
|
if (this.#settled) return;
|
|
38
57
|
this.#pauseDepth++;
|
|
39
58
|
if (this.#pauseDepth !== 1) return;
|
|
40
|
-
this.#
|
|
59
|
+
this.#pausedDeadlineMs = Date.now() + this.maxPauseGraceMs;
|
|
60
|
+
this.#arm(this.maxPauseGraceMs);
|
|
41
61
|
}
|
|
42
62
|
|
|
43
63
|
resume(): void {
|
|
44
64
|
if (this.#settled || this.#pauseDepth === 0) return;
|
|
45
65
|
this.#pauseDepth--;
|
|
46
66
|
if (this.#pauseDepth > 0) return;
|
|
67
|
+
this.#pausedDeadlineMs = undefined;
|
|
47
68
|
this.#deadlineMs = Date.now() + this.timeoutMs;
|
|
48
69
|
this.#arm(this.timeoutMs);
|
|
49
70
|
}
|
|
@@ -51,6 +72,7 @@ export class IdleTimeout implements TimeoutPauseHandle {
|
|
|
51
72
|
dispose(): void {
|
|
52
73
|
if (this.#settled) return;
|
|
53
74
|
this.#settled = true;
|
|
75
|
+
this.#pausedDeadlineMs = undefined;
|
|
54
76
|
this.#clearTimer();
|
|
55
77
|
}
|
|
56
78
|
|
|
@@ -68,15 +90,22 @@ export class IdleTimeout implements TimeoutPauseHandle {
|
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
#expire(): void {
|
|
71
|
-
if (this.#settled
|
|
72
|
-
const
|
|
93
|
+
if (this.#settled) return;
|
|
94
|
+
const pausedDeadlineMs = this.#pausedDeadlineMs;
|
|
95
|
+
if (this.#pauseDepth > 0 && pausedDeadlineMs === undefined) return;
|
|
96
|
+
const deadlineMs = pausedDeadlineMs ?? this.#deadlineMs;
|
|
97
|
+
const remainingMs = deadlineMs - Date.now();
|
|
73
98
|
if (remainingMs > 0) {
|
|
74
99
|
this.#arm(remainingMs);
|
|
75
100
|
return;
|
|
76
101
|
}
|
|
77
102
|
this.#settled = true;
|
|
103
|
+
this.#pausedDeadlineMs = undefined;
|
|
78
104
|
this.#timer = undefined;
|
|
79
|
-
const error =
|
|
105
|
+
const error =
|
|
106
|
+
pausedDeadlineMs === undefined
|
|
107
|
+
? new Error(`Cell timed out after ${this.timeoutMs}ms`)
|
|
108
|
+
: new Error(`Cell timed out after ${this.maxPauseGraceMs}ms waiting on a host tool call`);
|
|
80
109
|
error.name = "TimeoutError";
|
|
81
110
|
this.#controller.abort(error);
|
|
82
111
|
this.#onTimeout({ cellId: this.#cellId, error });
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# src/tool
|
|
2
|
+
|
|
3
|
+
Eval tool core: input schema, cell execution lifecycle, detached-cell state
|
|
4
|
+
machine, status events, and all call/result rendering. Earned by score 12 —
|
|
5
|
+
highest reference density in the package (`createEvalTool` and `EvalToolDetails`
|
|
6
|
+
anchor most suites).
|
|
7
|
+
|
|
8
|
+
## WHERE TO LOOK
|
|
9
|
+
|
|
10
|
+
| Task | Path |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| Tool registration, options | `eval-tool.ts`, `eval-tool-options.ts`, `eval-request.ts` |
|
|
13
|
+
| Wire contract, TypeBox schemas | `types.ts` (`createEvalInputSchema`, `fullEvalInputSchema`) |
|
|
14
|
+
| Cell execution, settlement | `cell-handler.ts`, `cell-execution.ts`, `cell-runtime.ts` |
|
|
15
|
+
| Detached cells | `detached-cell-manager.ts` + `detached-cell-{state,snapshot,notification}.ts`, `detached-notification-queue.ts`, `detached-eval-result.ts` |
|
|
16
|
+
| Call/result rendering | `render.ts`, `runtime-label.ts`, `json-tree.ts`, `image.ts`, `tool-widgets.ts` |
|
|
17
|
+
| Status events, execution events | `status-events.ts`, `eval-execution-event.ts` |
|
|
18
|
+
| Interrupt, capture | `interrupt-note.ts`, `call-capture.ts` |
|
|
19
|
+
|
|
20
|
+
## CONVENTIONS
|
|
21
|
+
|
|
22
|
+
- Wire/schema fields are snake_case (`cell_id`, `on_timeout`); internal TS
|
|
23
|
+
fields are camelCase. Schemas and shared eval types live only in `types.ts`.
|
|
24
|
+
- Rendering is bounded by explicit line/code-point budgets with an injectable
|
|
25
|
+
render clock; nothing here depends on wall-clock luck.
|
|
26
|
+
- Detached execution is a first-class state machine — snapshot, notification
|
|
27
|
+
queue, spill-file notice, result conversion — never folded into ordinary
|
|
28
|
+
cell execution.
|
|
29
|
+
- Unicode tree glyphs and status icons are intentional UI conventions.
|
|
30
|
+
|
|
31
|
+
## ANTI-PATTERNS
|
|
32
|
+
|
|
33
|
+
- Never add unbounded output: previews, JSON tree depth/lines/scalar length,
|
|
34
|
+
widget lines, and collapsed errors all cap.
|
|
35
|
+
- Never bypass the kernel bridge message contract with ad-hoc return values;
|
|
36
|
+
kernels import `KernelInterruptHandle` from `types.ts`, so discriminants and
|
|
37
|
+
lifecycle state are cross-runtime contracts — change them only with all four
|
|
38
|
+
runtimes and the detached path in mind.
|
|
39
|
+
- `render.ts` (1,030 LOC) is the package's largest file and the highest-risk
|
|
40
|
+
hotspot for regressions; changes there need render contracts first.
|
package/src/tool/cell-runtime.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from
|
|
|
2
2
|
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
3
3
|
import type { EvalToolCallMetric } from "./call-capture.ts";
|
|
4
4
|
import { type EvalImageResizer, EvalOutputCollector, type EvalOutputResult } from "./image.ts";
|
|
5
|
-
import type { EvalStatusEvent, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
5
|
+
import type { EvalRuntimeInfo, EvalStatusEvent, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
6
6
|
|
|
7
7
|
type KernelResult = Extract<KernelToHostMessage, { type: "result" }>;
|
|
8
8
|
type DisplayMessage = Extract<KernelToHostMessage, { type: "display" }>;
|
|
@@ -10,6 +10,7 @@ type ToolCall = EvalToolDetails["toolCalls"] extends readonly (infer Item)[] ? I
|
|
|
10
10
|
|
|
11
11
|
export interface CellState {
|
|
12
12
|
readonly input: EvalToolInput;
|
|
13
|
+
readonly runtime?: EvalRuntimeInfo;
|
|
13
14
|
readonly startedAt: number;
|
|
14
15
|
readonly signal: AbortSignal;
|
|
15
16
|
readonly onUpdate: AgentToolUpdateCallback<EvalToolDetails> | undefined;
|
|
@@ -125,6 +126,7 @@ export class CellResultBuilder {
|
|
|
125
126
|
return {
|
|
126
127
|
language: this.#state.input.language,
|
|
127
128
|
languages: [this.#state.input.language],
|
|
129
|
+
...(this.#state.runtime === undefined ? {} : { runtime: this.#state.runtime }),
|
|
128
130
|
...(this.#state.input.summary === undefined ? {} : { summary: this.#state.input.summary }),
|
|
129
131
|
durationMs: this.#state.durationMs,
|
|
130
132
|
wallDurationMs: Math.max(0, Date.now() - this.#state.startedAt),
|
|
@@ -139,9 +141,11 @@ export class CellResultBuilder {
|
|
|
139
141
|
...(this.#state.input.summary === undefined ? {} : { summary: this.#state.input.summary }),
|
|
140
142
|
code: this.#state.input.code,
|
|
141
143
|
language: this.#state.input.language,
|
|
144
|
+
...(this.#state.runtime === undefined ? {} : { runtime: this.#state.runtime }),
|
|
142
145
|
output: this.#state.output,
|
|
143
146
|
status: this.#state.status,
|
|
144
147
|
durationMs: this.#state.durationMs,
|
|
148
|
+
startedAt: this.#state.startedAt,
|
|
145
149
|
...(statusEvents === undefined ? {} : { statusEvents }),
|
|
146
150
|
...(output?.hasMarkdown ? { hasMarkdown: true } : {}),
|
|
147
151
|
},
|
|
@@ -93,7 +93,7 @@ export class EvalDetachedCellManager {
|
|
|
93
93
|
this.#artifactsDir = options.artifactsDir;
|
|
94
94
|
this.#onStatusChange = options.onStatusChange;
|
|
95
95
|
this.#onWakeSourceState = options.onWakeSourceState;
|
|
96
|
-
this.#notificationQueue = new DetachedNotificationQueue(options.notifier
|
|
96
|
+
this.#notificationQueue = new DetachedNotificationQueue(options.notifier);
|
|
97
97
|
this.#now = options.now ?? Date.now;
|
|
98
98
|
this.#hardLimitSeconds = options.hardLimitSeconds ?? DEFAULT_HARD_LIMIT_SECONDS;
|
|
99
99
|
}
|
|
@@ -12,7 +12,6 @@ export function detachedNotificationSpillPath(artifactsDir: string | undefined,
|
|
|
12
12
|
export async function buildDetachedCellNotification(
|
|
13
13
|
snapshot: EvalDetachedCellSnapshot,
|
|
14
14
|
spillPath: string | undefined,
|
|
15
|
-
artifactsDir: string | undefined,
|
|
16
15
|
): Promise<EvalDetachedCellNotification> {
|
|
17
16
|
const body = notificationBody(snapshot);
|
|
18
17
|
const overflow = Buffer.byteLength(body, "utf8") > NOTIFICATION_TAIL_BYTES;
|
|
@@ -21,7 +20,9 @@ export async function buildDetachedCellNotification(
|
|
|
21
20
|
try {
|
|
22
21
|
await mkdir(dirname(spillPath), { recursive: true });
|
|
23
22
|
await writeFile(spillPath, body, "utf8");
|
|
24
|
-
|
|
23
|
+
// The agent read tool resolves plain paths only, so the notice must carry
|
|
24
|
+
// the absolute spill path, never the kernel-helper local:// scheme.
|
|
25
|
+
spillNotice = `\nBuffered output overflowed; full output: ${spillPath}`;
|
|
25
26
|
} catch (error) {
|
|
26
27
|
const message = error instanceof Error ? error.message : String(error);
|
|
27
28
|
spillNotice = `\nBuffered output overflow could not be spilled: ${message}`;
|
|
@@ -84,12 +85,6 @@ function safeCellId(cellId: string): string {
|
|
|
84
85
|
return cellId.replace(/[^a-zA-Z0-9_-]/gu, "_");
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function localUri(path: string, artifactsDir: string | undefined): string {
|
|
88
|
-
if (artifactsDir === undefined) return `local://${path}`;
|
|
89
|
-
const root = join(artifactsDir, "local");
|
|
90
|
-
return path.startsWith(`${root}/`) ? `local://${path.slice(root.length + 1)}` : `local://${path}`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
88
|
function truncateTailUtf8(text: string, maxBytes: number): string {
|
|
94
89
|
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
95
90
|
const bytes = Buffer.from(text, "utf8");
|
|
@@ -7,14 +7,12 @@ export interface PendingDetachedNotification {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export class DetachedNotificationQueue {
|
|
10
|
-
readonly #artifactsDir: string | undefined;
|
|
11
10
|
readonly #notifier: EvalDetachedCellNotifier | undefined;
|
|
12
11
|
#pending: PendingDetachedNotification[] = [];
|
|
13
12
|
#flush: Promise<void> | undefined;
|
|
14
13
|
|
|
15
|
-
constructor(notifier: EvalDetachedCellNotifier | undefined
|
|
14
|
+
constructor(notifier: EvalDetachedCellNotifier | undefined) {
|
|
16
15
|
this.#notifier = notifier;
|
|
17
|
-
this.#artifactsDir = artifactsDir;
|
|
18
16
|
}
|
|
19
17
|
|
|
20
18
|
enqueue(notification: PendingDetachedNotification): void {
|
|
@@ -32,9 +30,7 @@ export class DetachedNotificationQueue {
|
|
|
32
30
|
const flush = Promise.resolve().then(async () => {
|
|
33
31
|
const pending = this.#pending.splice(0);
|
|
34
32
|
const notifications = await Promise.all(
|
|
35
|
-
pending.map(
|
|
36
|
-
async (item) => await buildDetachedCellNotification(item.snapshot(), item.spillPath, this.#artifactsDir),
|
|
37
|
-
),
|
|
33
|
+
pending.map(async (item) => await buildDetachedCellNotification(item.snapshot(), item.spillPath)),
|
|
38
34
|
);
|
|
39
35
|
this.#notifier?.notify(notifications);
|
|
40
36
|
});
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
EnabledEvalLanguages,
|
|
12
12
|
EvalInputSchema,
|
|
13
13
|
EvalKernelManager,
|
|
14
|
+
EvalRuntimes,
|
|
14
15
|
EvalToolDetails,
|
|
15
16
|
EvalToolInput,
|
|
16
17
|
ExecuteTool,
|
|
@@ -38,6 +39,8 @@ export interface CreateEvalToolOptions {
|
|
|
38
39
|
readonly spawnDefaultAgent?: string;
|
|
39
40
|
readonly modelId?: string;
|
|
40
41
|
readonly hostLine?: string;
|
|
42
|
+
/** Display identity of each language's runtime, shown in headers and details. */
|
|
43
|
+
readonly runtimes?: EvalRuntimes;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export interface EvalCellInvocation {
|
package/src/tool/eval-tool.ts
CHANGED
|
@@ -98,8 +98,10 @@ async function runEvalCell(
|
|
|
98
98
|
const bridgeAbortController = new AbortController();
|
|
99
99
|
const cellSignal = AbortSignal.any([invocation.signal, bridgeAbortController.signal]);
|
|
100
100
|
const bridgeContext: ExtensionContext = { ...invocation.ctx, signal: cellSignal };
|
|
101
|
+
const runtime = options.runtimes?.[invocation.input.language];
|
|
101
102
|
const state: CellState = {
|
|
102
103
|
input: invocation.input,
|
|
104
|
+
...(runtime === undefined ? {} : { runtime }),
|
|
103
105
|
startedAt: Date.now(),
|
|
104
106
|
signal: cellSignal,
|
|
105
107
|
onUpdate: invocation.onUpdate,
|
package/src/tool/render.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
JSON_TREE_SCALAR_LEN_EXPANDED,
|
|
20
20
|
renderJsonTreeLines,
|
|
21
21
|
} from "./json-tree.ts";
|
|
22
|
+
import { formatRuntimeBadge } from "./runtime-label.ts";
|
|
22
23
|
import { codePointPrefix, formatDuration, renderToolCallWidget } from "./tool-widgets.ts";
|
|
23
24
|
import type {
|
|
24
25
|
EvalCellResult,
|
|
@@ -69,14 +70,38 @@ const TOOL_CALL_PREVIEW_COUNT = 5;
|
|
|
69
70
|
const TOOL_CALL_COLLAPSED_VISUAL_LINES = 4;
|
|
70
71
|
const TOOL_CALL_COLLAPSED_ERROR_CODE_POINTS = 512;
|
|
71
72
|
const TOOL_ERROR_OMISSION_MARKER = "[tool error omitted]";
|
|
73
|
+
const LIVE_ELAPSED_TICK_MS = 1_000;
|
|
72
74
|
|
|
73
75
|
class PlainTextComponent implements EvalRenderComponent {
|
|
74
76
|
#blocks: readonly RenderBlock[] = [];
|
|
77
|
+
#ticker: ReturnType<typeof setInterval> | undefined;
|
|
75
78
|
|
|
76
79
|
setBlocks(blocks: readonly RenderBlock[]): void {
|
|
77
80
|
this.#blocks = blocks;
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The host only animates tool rows for streaming args, `task`, and results carrying
|
|
85
|
+
* `details.progress`; an eval row matches none of them, so nothing repaints it between
|
|
86
|
+
* update events. While a cell is non-terminal this drives the repaint itself so the
|
|
87
|
+
* header's elapsed time advances, and it emits no tool updates or RPC traffic.
|
|
88
|
+
*/
|
|
89
|
+
syncLiveTicker(isLive: boolean, invalidate: () => void): void {
|
|
90
|
+
if (!isLive) {
|
|
91
|
+
this.stopLiveTicker();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (this.#ticker !== undefined) return;
|
|
95
|
+
this.#ticker = setInterval(invalidate, LIVE_ELAPSED_TICK_MS);
|
|
96
|
+
this.#ticker.unref?.();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
stopLiveTicker(): void {
|
|
100
|
+
if (this.#ticker === undefined) return;
|
|
101
|
+
clearInterval(this.#ticker);
|
|
102
|
+
this.#ticker = undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
80
105
|
render(width: number): string[] {
|
|
81
106
|
const lines: string[] = [];
|
|
82
107
|
for (const block of this.#blocks) {
|
|
@@ -109,6 +134,16 @@ function componentFor(context: RenderContext | ResultRenderContext): PlainTextCo
|
|
|
109
134
|
return new PlainTextComponent();
|
|
110
135
|
}
|
|
111
136
|
|
|
137
|
+
/** Render-time clock; tests inject a fixed value so elapsed output never depends on wall time. */
|
|
138
|
+
function renderNow(context: RenderContext | ResultRenderContext): number {
|
|
139
|
+
const injected: unknown = Reflect.get(context, "now");
|
|
140
|
+
return typeof injected === "number" && Number.isFinite(injected) ? injected : Date.now();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hasLiveCell(details: EvalToolDetails | undefined): boolean {
|
|
144
|
+
return (details?.cells ?? []).some((cell) => isLiveCellStatus(cell.status) && cell.startedAt !== undefined);
|
|
145
|
+
}
|
|
146
|
+
|
|
112
147
|
function style(theme: Theme | undefined, color: ThemeColor, text: string): string {
|
|
113
148
|
return theme ? theme.fg(color, text) : text;
|
|
114
149
|
}
|
|
@@ -187,6 +222,8 @@ type RenderEnvironment = {
|
|
|
187
222
|
readonly spinnerFrame: number | undefined;
|
|
188
223
|
readonly width: number;
|
|
189
224
|
readonly meta: TruncationMeta | undefined;
|
|
225
|
+
/** Render-time clock, injected so elapsed time is deterministic under test. */
|
|
226
|
+
readonly now: number;
|
|
190
227
|
};
|
|
191
228
|
type CellBadges = {
|
|
192
229
|
readonly reset: boolean;
|
|
@@ -231,6 +268,10 @@ function spinner(frame: number | undefined): string {
|
|
|
231
268
|
return SPINNER_FRAMES.at((frame ?? 0) % SPINNER_FRAMES.length) ?? SPINNER_FRAMES[0];
|
|
232
269
|
}
|
|
233
270
|
|
|
271
|
+
function isLiveCellStatus(status: CellStatus): boolean {
|
|
272
|
+
return status === "pending" || status === "running" || status === "detached";
|
|
273
|
+
}
|
|
274
|
+
|
|
234
275
|
function cellPresentation(status: CellStatus, spinnerFrame: number | undefined): StatusPresentation {
|
|
235
276
|
switch (status) {
|
|
236
277
|
case "pending":
|
|
@@ -259,12 +300,21 @@ function renderPrefixed(text: string, environment: RenderEnvironment, prefixStyl
|
|
|
259
300
|
);
|
|
260
301
|
}
|
|
261
302
|
|
|
303
|
+
// A running cell only receives updates on output/status events, so a stored duration
|
|
304
|
+
// freezes between them. Non-terminal cells therefore derive elapsed time from the
|
|
305
|
+
// render-time clock; terminal cells keep their settled duration verbatim.
|
|
306
|
+
function cellElapsedMs(cell: EvalCellResult, environment: RenderEnvironment): number | undefined {
|
|
307
|
+
if (!isLiveCellStatus(cell.status) || cell.startedAt === undefined) return cell.durationMs;
|
|
308
|
+
return Math.max(0, environment.now - cell.startedAt);
|
|
309
|
+
}
|
|
310
|
+
|
|
262
311
|
function cellHeader(cell: EvalCellResult, environment: RenderEnvironment, badges: CellBadges): string {
|
|
263
312
|
const presentation = cellPresentation(cell.status, environment.spinnerFrame);
|
|
264
|
-
|
|
313
|
+
const runtimeBadge = cell.runtime === undefined ? "" : ` (${formatRuntimeBadge(cell.language, cell.runtime)})`;
|
|
314
|
+
let header = `eval ${cell.language}${runtimeBadge} ${presentation.label} ${presentation.icon}`;
|
|
265
315
|
const throughputBadge = badges.throughput === undefined ? undefined : formatThroughputBadge(badges.throughput);
|
|
266
316
|
if (throughputBadge !== undefined) header += ` · ${throughputBadge}`;
|
|
267
|
-
const elapsedMs = badges.throughput?.wallDurationMs ?? cell
|
|
317
|
+
const elapsedMs = badges.throughput?.wallDurationMs ?? cellElapsedMs(cell, environment);
|
|
268
318
|
if (elapsedMs !== undefined) header += ` · ${formatDuration(elapsedMs)}`;
|
|
269
319
|
if (badges.reset) header += " · reset";
|
|
270
320
|
if (badges.timeout !== undefined) header += ` · timeout ${badges.timeout}s`;
|
|
@@ -780,7 +830,9 @@ function resultHeader(
|
|
|
780
830
|
color = "error";
|
|
781
831
|
break;
|
|
782
832
|
}
|
|
783
|
-
|
|
833
|
+
const runtimeBadge =
|
|
834
|
+
details?.runtime === undefined ? "" : ` (${formatRuntimeBadge(details.language, details.runtime)})`;
|
|
835
|
+
return style(theme, color, `eval ${details?.language ?? "?"}${runtimeBadge} ${status}`);
|
|
784
836
|
}
|
|
785
837
|
|
|
786
838
|
function resultMetadata(
|
|
@@ -843,6 +895,7 @@ export function renderEvalCall(
|
|
|
843
895
|
spinnerFrame: context.spinnerFrame,
|
|
844
896
|
width,
|
|
845
897
|
meta: undefined,
|
|
898
|
+
now: renderNow(context),
|
|
846
899
|
};
|
|
847
900
|
const cell: EvalCellResult = {
|
|
848
901
|
index: 0,
|
|
@@ -873,6 +926,7 @@ export function renderEvalResult(
|
|
|
873
926
|
const details = result.details;
|
|
874
927
|
const expanded = options.expanded || context.expanded;
|
|
875
928
|
const imageProtocol = context.imageProtocol ?? null;
|
|
929
|
+
component.syncLiveTicker(hasLiveCell(details), context.invalidate);
|
|
876
930
|
if (details?.cells !== undefined && details.cells.length > 0) {
|
|
877
931
|
const blocks: RenderBlock[] = [
|
|
878
932
|
{
|
|
@@ -885,6 +939,7 @@ export function renderEvalResult(
|
|
|
885
939
|
spinnerFrame: context.spinnerFrame,
|
|
886
940
|
width,
|
|
887
941
|
meta: details.meta,
|
|
942
|
+
now: renderNow(context),
|
|
888
943
|
},
|
|
889
944
|
args: context.args,
|
|
890
945
|
showImageFallback: context.showImages && imageProtocol === null,
|
|
@@ -936,6 +991,7 @@ export function renderEvalResult(
|
|
|
936
991
|
spinnerFrame: context.spinnerFrame,
|
|
937
992
|
width,
|
|
938
993
|
meta: details?.meta,
|
|
994
|
+
now: renderNow(context),
|
|
939
995
|
};
|
|
940
996
|
return [
|
|
941
997
|
...renderStatusEvents(nonAgentEvents, environment),
|
|
@@ -957,6 +1013,7 @@ export function renderEvalResult(
|
|
|
957
1013
|
spinnerFrame: context.spinnerFrame,
|
|
958
1014
|
width,
|
|
959
1015
|
meta: details?.meta,
|
|
1016
|
+
now: renderNow(context),
|
|
960
1017
|
}),
|
|
961
1018
|
},
|
|
962
1019
|
);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import type { EvalLanguage, EvalRuntimeInfo } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const MAX_BADGE_PATH_CODE_POINTS = 40;
|
|
5
|
+
const ELLIPSIS = "\u2026";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One-line runtime badge for eval headers, e.g. "3.14.7, ~/.venv/bin/python3"
|
|
9
|
+
* or "node 26.7.0, /opt/…/bin/node". The js language always carries the
|
|
10
|
+
* runtime name because node and bun are otherwise indistinguishable.
|
|
11
|
+
*/
|
|
12
|
+
export function formatRuntimeBadge(language: EvalLanguage, runtime: EvalRuntimeInfo, home: string = homedir()): string {
|
|
13
|
+
const label = language === "js" ? `${runtime.name} ${runtime.version}` : runtime.version;
|
|
14
|
+
if (runtime.path === undefined || runtime.path.length === 0) return label;
|
|
15
|
+
return `${label}, ${minifyPath(runtime.path, home)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Home-contracts and middle-truncates a path so header badges stay short. */
|
|
19
|
+
export function minifyPath(path: string, home: string = homedir()): string {
|
|
20
|
+
const contracted = contractHome(path, home);
|
|
21
|
+
if (codePointLength(contracted) <= MAX_BADGE_PATH_CODE_POINTS) return contracted;
|
|
22
|
+
const separator = contracted.includes("/") ? "/" : "\\";
|
|
23
|
+
const segments = contracted.split(separator).filter((segment) => segment.length > 0);
|
|
24
|
+
const head = contracted.startsWith(separator) ? `${separator}${segments[0] ?? ""}` : (segments[0] ?? "");
|
|
25
|
+
const tail: string[] = [];
|
|
26
|
+
for (let index = segments.length - 1; index >= 1; index -= 1) {
|
|
27
|
+
const attempt = joinTruncated(head, [segments[index] ?? "", ...tail], separator);
|
|
28
|
+
if (codePointLength(attempt) > MAX_BADGE_PATH_CODE_POINTS) break;
|
|
29
|
+
tail.unshift(segments[index] ?? "");
|
|
30
|
+
}
|
|
31
|
+
if (tail.length > 0) return joinTruncated(head, tail, separator);
|
|
32
|
+
const suffix = [...contracted].slice(-(MAX_BADGE_PATH_CODE_POINTS - 1)).join("");
|
|
33
|
+
return `${ELLIPSIS}${suffix}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function joinTruncated(head: string, tail: readonly string[], separator: string): string {
|
|
37
|
+
return `${head}${separator}${ELLIPSIS}${separator}${tail.join(separator)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function contractHome(path: string, home: string): string {
|
|
41
|
+
if (home.length === 0) return path;
|
|
42
|
+
if (path === home) return "~";
|
|
43
|
+
if (path.startsWith(`${home}/`) || path.startsWith(`${home}\\`)) return `~${path.slice(home.length)}`;
|
|
44
|
+
return path;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function codePointLength(text: string): number {
|
|
48
|
+
return [...text].length;
|
|
49
|
+
}
|
package/src/tool/types.ts
CHANGED
|
@@ -140,6 +140,15 @@ export interface EvalToolCallSummary {
|
|
|
140
140
|
|
|
141
141
|
export type EvalStatusEvent = { readonly op: string } & Readonly<Record<string, unknown>>;
|
|
142
142
|
|
|
143
|
+
/** Identity of the runtime executing a kernel: interpreter or JS host. */
|
|
144
|
+
export interface EvalRuntimeInfo {
|
|
145
|
+
readonly name: string;
|
|
146
|
+
readonly version: string;
|
|
147
|
+
readonly path?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type EvalRuntimes = Readonly<Partial<Record<EvalLanguage, EvalRuntimeInfo>>>;
|
|
151
|
+
|
|
143
152
|
export type EvalDisplayOutput =
|
|
144
153
|
| { readonly type: "json"; readonly data: unknown }
|
|
145
154
|
| { readonly type: "image"; readonly data: string; readonly mimeType: string }
|
|
@@ -152,9 +161,12 @@ export type EvalCellResult = {
|
|
|
152
161
|
readonly code: string;
|
|
153
162
|
readonly language: EvalLanguage;
|
|
154
163
|
readonly output: string;
|
|
164
|
+
readonly runtime?: EvalRuntimeInfo;
|
|
155
165
|
readonly status: "pending" | "running" | "detached" | "complete" | "error" | "cancelled";
|
|
156
166
|
readonly exitCode?: number;
|
|
157
167
|
readonly durationMs?: number;
|
|
168
|
+
/** Epoch ms when the cell started; lets renderers tick elapsed time between update events. */
|
|
169
|
+
readonly startedAt?: number;
|
|
158
170
|
readonly statusEvents?: readonly EvalStatusEvent[];
|
|
159
171
|
readonly hasMarkdown?: boolean;
|
|
160
172
|
};
|
|
@@ -162,6 +174,7 @@ export type EvalCellResult = {
|
|
|
162
174
|
export interface EvalToolDetails {
|
|
163
175
|
readonly language: EvalLanguage;
|
|
164
176
|
readonly languages?: readonly EvalLanguage[];
|
|
177
|
+
readonly runtime?: EvalRuntimeInfo;
|
|
165
178
|
readonly summary?: string;
|
|
166
179
|
readonly durationMs: number;
|
|
167
180
|
/** True wall-clock elapsed time since the cell started; `durationMs` stays kernel-reported. */
|