@code-yeongyu/senpi-codemode 2026.8.21 → 2026.8.22-2

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 CHANGED
@@ -12,6 +12,64 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.8.22-2] - 2026-08-22
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - 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).
22
+
23
+ ### Changed
24
+
25
+ - 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.
26
+
27
+ ### Fixed
28
+
29
+ - 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.
30
+
31
+ ### Removed
32
+
33
+ ## [2026.8.22] - 2026-08-22
34
+
35
+ ### Breaking Changes
36
+
37
+ ### Added
38
+
39
+ ### Changed
40
+
41
+ ### Fixed
42
+
43
+ - Ruby and Julia eval cells now wait for the subprocess `ready` signal before execution timeouts begin, so interpreter startup under load cannot time out a state-setting cell and silently restart the kernel before the next cell runs.
44
+
45
+ ### Removed
46
+
47
+ ## [2026.8.21-3] - 2026-08-21
48
+
49
+ ### Breaking Changes
50
+
51
+ ### Added
52
+
53
+ ### Changed
54
+
55
+ ### Fixed
56
+
57
+ ### Removed
58
+
59
+ ## [2026.8.21-2] - 2026-08-21
60
+
61
+ ### Breaking Changes
62
+
63
+ ### Added
64
+
65
+ ### Changed
66
+
67
+ ### Fixed
68
+
69
+ - `js` eval cells now accept `local://` paths in `read()` and `write()` like every other kernel. The session manager computed the session local root only after its `language === "js"` early return, so the JavaScript kernel was constructed without `localRoots` or `artifactsDir` and every `local://` helper call failed with `Protocol paths are not supported by write()`, even though the JavaScript prelude documents `local://` as the session local root. `py`/`rb`/`jl` behavior is unchanged.
70
+
71
+ ### Removed
72
+
15
73
  ## [2026.8.21] - 2026-08-21
16
74
 
17
75
  ### 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.21",
3
+ "version": "2026.8.22-2",
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.21",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.8.22-2",
34
34
  "typebox": "1.3.16"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.8.21"
37
+ "@code-yeongyu/senpi": "2026.8.22-2"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.8.21"
40
+ "@code-yeongyu/senpi": "2026.8.22-2"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -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
+ }
@@ -196,19 +196,24 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
196
196
  if (!bridge) throw new Error("codemode bridge server is not running");
197
197
  const configuredPoolWidth = this.#options.settings.parallelPoolWidth;
198
198
  const parallelPoolWidth = Number.isFinite(configuredPoolWidth) ? Math.max(1, Math.trunc(configuredPoolWidth)) : 1;
199
+ // localRoots must be computed BEFORE the js branch: the JS kernel resolves local://
200
+ // from its worker init connection exactly like the subprocess kernels resolve it from
201
+ // theirs. Computing it after the early return left js cells with no local root at all.
202
+ const localRoots =
203
+ this.#options.localRoots ??
204
+ (this.#options.artifactsDir ? { local: join(this.#options.artifactsDir, "local") } : undefined);
199
205
  if (language === "js") {
200
206
  return new JavaScriptKernel({
201
207
  sessionId: this.#options.sessionId,
202
208
  cwd: this.#options.cwd,
203
209
  parallelPoolWidth,
204
210
  onMessage,
211
+ ...(localRoots ? { localRoots: { ...localRoots } } : {}),
212
+ ...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
205
213
  });
206
214
  }
207
215
  const detected = this.#options.availability[language].detected;
208
216
  if (!detected.ok) throw new Error(`No ${language} interpreter is available`);
209
- const localRoots =
210
- this.#options.localRoots ??
211
- (this.#options.artifactsDir ? { local: join(this.#options.artifactsDir, "local") } : undefined);
212
217
  const connection = {
213
218
  port: bridge.port,
214
219
  token: bridge.token,
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";
@@ -119,6 +120,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
119
120
  spawns: runtime.spawns,
120
121
  spawnDefaultAgent: runtime.settings.taskTools.task,
121
122
  hostLine: hostLine(),
123
+ runtimes: runtime.runtimes,
122
124
  ...(modelId === undefined ? {} : { modelId }),
123
125
  }),
124
126
  );
@@ -152,6 +154,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
152
154
  executionTracker: manager,
153
155
  renderers,
154
156
  hostLine: hostLine(),
157
+ runtimes: { js: jsRuntimeInfo() },
155
158
  }),
156
159
  );
157
160
  pi.registerRemovedToolHint(
@@ -206,7 +209,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
206
209
 
207
210
  function hostLine(): string {
208
211
  const cpu = os.cpus()[0]?.model?.trim();
209
- return [`${os.platform()} ${os.arch()}`, cpu, `${os.availableParallelism()} cores`]
212
+ return [`${os.platform()} ${os.arch()}`, cpu, `${os.availableParallelism()} cores`, jsRuntimeLabel()]
210
213
  .filter((part): part is string => !!part)
211
214
  .join(" \u00b7 ");
212
215
  }
@@ -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(candidate: string, probe: ExecFileProbe): Promise<InterpreterDetection> {
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
- return version === null ? unavailable : { ok: true, path: candidate, version };
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
+ }
@@ -26,6 +26,7 @@ export class SubprocessKernel {
26
26
  private readonly onMessage?: (message: KernelToHostMessage) => void;
27
27
  private readonly runs = new SubprocessRunQueue();
28
28
  private process: SubprocessProcess | null = null;
29
+ private processReady = false;
29
30
  private retirementPromise: Promise<void> | null = null;
30
31
  private retirementProcess: SubprocessProcess | null = null;
31
32
  private retirementFailure: Error | null = null;
@@ -109,7 +110,7 @@ export class SubprocessKernel {
109
110
 
110
111
  private pumpRuns(): void {
111
112
  const process = this.process;
112
- if (this.closed || this.runs.active || !process || process.isRetiring) return;
113
+ if (this.closed || this.runs.active || !process || process.isRetiring || !this.processReady) return;
113
114
  const run = this.runs.startNext(performance.now());
114
115
  if (!run) return;
115
116
  const timeoutMs = run.input.timeoutMs;
@@ -142,6 +143,7 @@ export class SubprocessKernel {
142
143
  },
143
144
  });
144
145
  this.process = process;
146
+ this.processReady = false;
145
147
  try {
146
148
  process.send(
147
149
  encodeBridgeFrame({ type: "init", sessionId: this.options.sessionId, connection: this.options.connection }),
@@ -165,6 +167,17 @@ export class SubprocessKernel {
165
167
 
166
168
  private handleMessage(process: SubprocessProcess, message: KernelToHostMessage): void {
167
169
  if (!this.accepts(process)) return;
170
+ if (message.type === "ready") {
171
+ this.processReady = true;
172
+ this.runs.handleMessage(message, this.onMessage);
173
+ this.pumpRuns();
174
+ return;
175
+ }
176
+ if (message.type === "init-failed") {
177
+ this.runs.handleMessage(message, this.onMessage);
178
+ this.failClosed(new KernelStartupError(message.error.message));
179
+ return;
180
+ }
168
181
  if (this.runs.handleMessage(message, this.onMessage)) this.pumpRuns();
169
182
  }
170
183
 
@@ -176,6 +189,7 @@ export class SubprocessKernel {
176
189
  return;
177
190
  }
178
191
  this.process = null;
192
+ this.processReady = false;
179
193
  this.failClosed(new KernelExitedError(signal ?? code ?? "unknown"));
180
194
  }
181
195
 
@@ -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.#clearTimer();
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 || this.#pauseDepth > 0) return;
72
- const remainingMs = this.#deadlineMs - Date.now();
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 = new Error(`Cell timed out after ${this.timeoutMs}ms`);
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 });
@@ -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
  },
@@ -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 {
@@ -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,
@@ -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
- let header = `eval ${cell.language} ${presentation.label} ${presentation.icon}`;
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.durationMs;
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
- return style(theme, color, `eval ${details?.language ?? "?"} ${status}`);
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. */