@code-yeongyu/senpi-codemode 2026.10.1-2 → 2026.10.1-3

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,26 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.1-3] - 2026-10-01
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ - The `eval` tool points at the bun-1-4 skill before a cell that installs a package, spawns a server or PTY, or starts a long run, instead of demanding it before the first JavaScript cell ([#2505](https://github.com/code-yeongyu/senpi/issues/2505)).
24
+
25
+ ### Fixed
26
+
27
+ - Kernel-originated tool approvals now reach the RPC client that submitted the cell, including approvals requested after a cell detaches and its original turn ends. Reusing a kernel no longer sends permission dialogs to its creation context. ([#2512](https://github.com/code-yeongyu/senpi/issues/2512))
28
+
29
+ - Python eval startup waits for kernel readiness with advancing stage events instead of a five-second total deadline. Cold Windows imports can complete normally; a hung start identifies its stalled stage. ([#2452](https://github.com/code-yeongyu/senpi/issues/2452))
30
+
31
+ - Fixed the running eval spinner freezing between output updates. ([#2503](https://github.com/code-yeongyu/senpi/issues/2503))
32
+
33
+ ### Removed
34
+
15
35
  ## [2026.10.1-2] - 2026-10-01
16
36
 
17
37
  ### Breaking Changes
package/README.md CHANGED
@@ -18,6 +18,9 @@ task-tool names are known.
18
18
  events, tool-call summaries, elapsed duration, and structured display state;
19
19
  a terminal peek preserves the exact final result.
20
20
  - Loopback, bearer-authenticated kernel bridge with bounded JSONL frames.
21
+ - Tool calls retain the submitting cell's host context, including its RPC approval
22
+ channel, even after detachment or kernel reuse. Headless sessions deny commands
23
+ that require approval rather than waiting for an absent UI.
21
24
  - Structured status events for file operations, environment access, phases,
22
25
  bridge activity, and delegated task progress.
23
26
  - One versioned `senpi.eval.execution` event at terminal cell settlement. The
@@ -56,6 +59,14 @@ task-tool names are known.
56
59
  A missing optional interpreter removes that language from the session's `eval`
57
60
  schema; it is not an installation failure.
58
61
 
62
+ Python startup waits for the interpreter's `ready` event. It reports progress
63
+ through `stdlib-imports`, `runtime-init`, and `host-init`; advancing to the next
64
+ stage resets an inactivity guard rather than consuming a total startup budget.
65
+ The default guard is 11 seconds per stage, derived from a measured 5.220-second
66
+ Windows fresh-cache bootstrap p99 (30 samples). A stalled or failed start names
67
+ the last stage and retains the interpreter's diagnostic error. The low-level
68
+ `PythonKernel.start({ startupTimeoutMs })` override applies per stage.
69
+
59
70
  ### Session environment
60
71
 
61
72
  Every kernel starts with the active session's `PI_*` environment — `PI_SESSION_ID`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.1-2",
3
+ "version": "2026.10.1-3",
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.10.1-2",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.1-3",
34
34
  "typebox": "1.3.34"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.10.1-2"
37
+ "@code-yeongyu/senpi": "2026.10.1-3"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.10.1-2"
40
+ "@code-yeongyu/senpi": "2026.10.1-3"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -3,6 +3,7 @@ import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/p
3
3
  import type { EvalKernelRunInput } from "../../tool/types.ts";
4
4
  import type { SessionEnvironment } from "../session-env.ts";
5
5
  import type { KernelSpawnProcess } from "./process.ts";
6
+ import type { PythonStartupStage } from "./startup.ts";
6
7
  import type { PythonTransportResult } from "./transport.ts";
7
8
 
8
9
  export interface PythonKernelStartOptions {
@@ -13,7 +14,10 @@ export interface PythonKernelStartOptions {
13
14
  readonly env?: NodeJS.ProcessEnv;
14
15
  /** Per-session PI_* values merged into the interpreter environment at spawn. */
15
16
  readonly sessionEnv?: SessionEnvironment;
17
+ /** Python bootstrap inactivity guard per advancing stage, not a total readiness deadline. */
16
18
  readonly startupTimeoutMs?: number;
19
+ /** Observes bootstrap control events without mixing them into cell output callbacks. */
20
+ readonly onStartupProgress?: (stage: PythonStartupStage) => void;
17
21
  readonly onMessage?: (message: KernelToHostMessage) => void;
18
22
  readonly spawnProcess?: KernelSpawnProcess;
19
23
  /** Post-cell collection, notice, and ceiling thresholds sent on `init`; absent leaves memory unmanaged. */
@@ -1,15 +1,16 @@
1
+ // allow: SIZE_OK — one persistent Python lifecycle state machine owns queue, generation, and retirement.
1
2
  import type { KernelInterruptHandle } from "../../tool/types.ts";
2
3
  import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
3
4
  import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
4
5
  import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
5
6
  import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
6
7
  import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
8
+ import { pythonStartupHangGuardMs } from "./startup.ts";
7
9
  import { failedPythonResult, PythonKernelTransport } from "./transport.ts";
8
10
 
9
11
  export type { PythonKernelRunOptions, PythonKernelStartOptions } from "./kernel-contract.ts";
10
12
  export type { KernelChild, KernelSpawnOptions, KernelSpawnProcess } from "./process.ts";
11
13
 
12
- const startupTimeoutMs = 5_000;
13
14
  const interruptEscalationMs = 5_000;
14
15
 
15
16
  export class PythonKernel {
@@ -229,7 +230,7 @@ export class PythonKernel {
229
230
  : (this.#active?.input.onMessage ?? this.#options.onMessage);
230
231
  callback?.(message);
231
232
  },
232
- startupTimeoutMs: this.#options.startupTimeoutMs ?? startupTimeoutMs,
233
+ startupTimeoutMs: this.#options.startupTimeoutMs ?? pythonStartupHangGuardMs,
233
234
  isOwned: () => !this.#closed && generation === this.#generation,
234
235
  onRetirementFailure: (transport, error) => {
235
236
  if (!this.#transport) this.#transport = transport;
@@ -1,6 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  # noqa: SIZE_OK — this dependency-free subprocess prelude must ship as one file.
4
+ import sys
5
+
6
+ # Emit before importing the stdlib graph: a cold interpreter can still be making
7
+ # progress after the former five-second total startup deadline.
8
+ sys.__stdout__.write('{"type":"status","event":{"op":"kernel-startup","stage":"stdlib-imports"}}\n')
9
+ sys.__stdout__.flush()
10
+
4
11
  import ast
5
12
  import asyncio # noqa: ANYIO_OK — stdlib-only embedded kernel runner.
6
13
  import base64
@@ -15,7 +22,6 @@ import os
15
22
  import re
16
23
  import signal
17
24
  import subprocess
18
- import sys
19
25
  import time
20
26
  import traceback
21
27
  import types
@@ -29,6 +35,9 @@ from threading import Lock, Thread
29
35
  from typing import Any, Callable, Union
30
36
  from urllib.parse import unquote
31
37
 
38
+ sys.__stdout__.write('{"type":"status","event":{"op":"kernel-startup","stage":"runtime-init"}}\n')
39
+ sys.__stdout__.flush()
40
+
32
41
  CONNECTION: dict[str, Any] = {}
33
42
  USER_NS: dict[str, Any] = {"__name__": "__main__", "__doc__": None, "__builtins__": __builtins__}
34
43
  LOOP = asyncio.new_event_loop()
@@ -1463,6 +1472,7 @@ def _start_parent_watch() -> None:
1463
1472
 
1464
1473
 
1465
1474
  def main() -> None:
1475
+ emit_status("kernel-startup", force=True, stage="host-init")
1466
1476
  signal.signal(signal.SIGINT, signal.SIG_IGN)
1467
1477
  _start_parent_watch()
1468
1478
  host_closed = False
@@ -0,0 +1,75 @@
1
+ import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
+
3
+ // Fresh-cache Windows bootstrap: p99 5,220 ms across 30 samples in Actions run
4
+ // 36882163342. Twice that observation, rounded up to a second, is a hang guard,
5
+ // not a total startup budget: only an advancing kernel stage rearms it.
6
+ export const pythonStartupHangGuardMs = 11_000;
7
+
8
+ const stages = ["interpreter-launch", "stdlib-imports", "runtime-init", "host-init"] as const;
9
+ export type PythonStartupStage = (typeof stages)[number];
10
+
11
+ export class PythonKernelStartupError extends Error {
12
+ readonly stage: PythonStartupStage;
13
+
14
+ constructor(stage: PythonStartupStage, reason: string, cause?: Error) {
15
+ super(`Python kernel startup failed at ${stage}: ${reason}`, { cause });
16
+ this.name = "PythonKernelStartupError";
17
+ this.stage = stage;
18
+ }
19
+ }
20
+
21
+ /** Owns the ready event and a per-stage inactivity watchdog, never a total deadline. */
22
+ export class PythonStartup {
23
+ readonly #timeoutMs: number;
24
+ readonly #failureDetail: () => string;
25
+ readonly #ready = Promise.withResolvers<void>();
26
+ readonly ready = this.#ready.promise;
27
+ #timer: NodeJS.Timeout | undefined;
28
+ #stageIndex = 0;
29
+ #settled = false;
30
+
31
+ constructor(timeoutMs: number, failureDetail: () => string) {
32
+ this.#timeoutMs = timeoutMs;
33
+ this.#failureDetail = failureDetail;
34
+ this.#arm();
35
+ }
36
+
37
+ progress(message: KernelToHostMessage): PythonStartupStage | undefined {
38
+ if (this.#settled || message.type !== "status" || message.event.op !== "kernel-startup") return;
39
+ const stage = stages.find((candidate) => candidate === message.event.stage);
40
+ if (stage === undefined) return;
41
+ const next = stages.indexOf(stage);
42
+ if (next <= this.#stageIndex) return;
43
+ this.#stageIndex = next;
44
+ this.#arm();
45
+ return stage;
46
+ }
47
+
48
+ settle(error?: Error): boolean {
49
+ if (this.#settled) return false;
50
+ this.#settled = true;
51
+ clearTimeout(this.#timer);
52
+ if (error) {
53
+ const stage = stages[this.#stageIndex] ?? "interpreter-launch";
54
+ this.#ready.reject(
55
+ error instanceof PythonKernelStartupError
56
+ ? error
57
+ : new PythonKernelStartupError(stage, error.message, error),
58
+ );
59
+ } else this.#ready.resolve();
60
+ return true;
61
+ }
62
+
63
+ #arm(): void {
64
+ clearTimeout(this.#timer);
65
+ this.#timer = setTimeout(() => {
66
+ const detail = this.#failureDetail().trim();
67
+ this.settle(
68
+ new PythonKernelStartupError(
69
+ stages[this.#stageIndex] ?? "interpreter-launch",
70
+ `no progress for ${this.#timeoutMs}ms${detail ? `; ${detail}` : ""}`,
71
+ ),
72
+ );
73
+ }, this.#timeoutMs);
74
+ }
75
+ }
@@ -23,8 +23,8 @@ import {
23
23
  splitCommand,
24
24
  sweepProcessGroup,
25
25
  waitForExit,
26
- withTimeout,
27
26
  } from "./process.ts";
27
+ import { PythonStartup, type PythonStartupStage } from "./startup.ts";
28
28
 
29
29
  export type PythonTransportResult = Extract<KernelToHostMessage, { type: "result" }>;
30
30
 
@@ -44,6 +44,7 @@ export interface PythonTransportOptions {
44
44
  /** Per-session PI_* values merged into the interpreter environment at spawn. */
45
45
  readonly sessionEnv?: SessionEnvironment;
46
46
  readonly startupTimeoutMs: number;
47
+ readonly onStartupProgress?: (stage: PythonStartupStage) => void;
47
48
  readonly memory?: KernelMemoryThresholds;
48
49
  readonly onMessage?: (message: KernelToHostMessage) => void;
49
50
  readonly spawnProcess?: KernelSpawnProcess;
@@ -73,7 +74,7 @@ export class PythonKernelTransport {
73
74
  readonly #child: KernelChild;
74
75
  #stdoutBuffer = "";
75
76
  #stderrTail = "";
76
- #settleReady: ((error?: Error) => void) | null = null;
77
+ #startup: PythonStartup | null = null;
77
78
  #detachChildListeners: (() => void) | null = null;
78
79
  #active = true;
79
80
  #exited = false;
@@ -173,9 +174,8 @@ export class PythonKernelTransport {
173
174
  }
174
175
 
175
176
  async #initialize(): Promise<void> {
176
- const ready = new Promise<void>((resolve, reject) => {
177
- this.#settleReady = (error) => (error ? reject(error) : resolve());
178
- });
177
+ const startup = new PythonStartup(this.#options.startupTimeoutMs, () => this.#stderrTail);
178
+ this.#startup = startup;
179
179
  const onStdout = (chunk: unknown) => this.#onStdout(String(chunk));
180
180
  const onStderr = (chunk: unknown) => this.#onStderr(String(chunk));
181
181
  const onError = (error: unknown) => this.#onError(error instanceof Error ? error : new Error(String(error)));
@@ -192,7 +192,7 @@ export class PythonKernelTransport {
192
192
  this.#child.on("exit", onExit);
193
193
  const { sessionId, connection, memory } = this.#options;
194
194
  this.#write({ type: "init", sessionId, connection, ...(memory === undefined ? {} : { memory }) });
195
- await withTimeout(ready, this.#options.startupTimeoutMs, "Python kernel did not become ready");
195
+ await startup.ready;
196
196
  }
197
197
 
198
198
  #write(message: HostToKernelMessage): void {
@@ -225,6 +225,11 @@ export class PythonKernelTransport {
225
225
  }
226
226
  if (!isKernelToHostMessage(decoded.message)) return;
227
227
  const message = decoded.message;
228
+ if (message.type === "status" && message.event.op === "kernel-startup") {
229
+ const stage = this.#startup?.progress(message);
230
+ if (stage !== undefined) this.#options.onStartupProgress?.(stage);
231
+ return;
232
+ }
228
233
  if (message.type === "ready") this.#settleStartup();
229
234
  else if (message.type === "init-failed") this.#settleStartup(new Error(message.error.message));
230
235
  else if (message.type === "result") this.#options.onResult(this, message);
@@ -258,11 +263,7 @@ export class PythonKernelTransport {
258
263
  }
259
264
 
260
265
  #settleStartup(error?: Error): boolean {
261
- const settle = this.#settleReady;
262
- if (!settle) return false;
263
- this.#settleReady = null;
264
- settle(error);
265
- return true;
266
+ return this.#startup?.settle(error) ?? false;
266
267
  }
267
268
  }
268
269
 
@@ -29,7 +29,7 @@ Host: {{hostLine}} — cells execute here. Size \`parallel(thunks)\` pools to it
29
29
  A cell that outlives the foreground window detaches and keeps running; further cells on the same language queue behind it in submission order on the same kernel (state is shared, execution is serial) while other languages run at once, up to {{maxDetachedCells}} background cells per session; each detached cell completes as one notification. Do not re-run a detached or queued cell; read, cancel or enumerate them with \`eval({ action: "peek" | "stop", cell_id })\` / \`eval({ action: "list" })\`; list shows live and recently settled cells across languages without consuming notifications. Its own execution time is capped at {{runBudgetSeconds}}s — queue time and time inside host tool calls are not charged; the hard limit stays wall-clock from submission; raise \`timeout\` only for a declared long run — and a killed js cell that cannot settle restarts its kernel with every global lost.
30
30
 
31
31
  {{#if py}}Python runs on a live event loop: use top-level \`await\`; \`asyncio.run(…)\` raises.{{/if}}
32
- {{#if js}}{{#if jsBun}}JS runs in-process on Bun {{jsVersion}}: top-level \`await\`/\`return\` work; \`Bun.*\` builtins available, including \`new Bun.WebView()\` — a headless browser (navigate/click/evaluate/screenshot) to reach for before \`curl\` or a browser CLI when a page needs JS, a login, or a screenshot. Shell out through \`Bun.$\` or \`Bun.spawn\`, never \`Bun.spawnSync\`: a synchronous child blocks the worker and cannot be interrupted.{{#if bunSkillPath}} MUST READ the bun-1-4 skill at {{bunSkillPath}} before your first js cell — its builtins replace the npm packages you would otherwise install.{{/if}}{{else}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}{{/if}}
32
+ {{#if js}}{{#if jsBun}}JS runs in-process on Bun {{jsVersion}}: top-level \`await\`/\`return\` work; \`Bun.*\` builtins available, including \`new Bun.WebView()\` — a headless browser (navigate/click/evaluate/screenshot) to reach for before \`curl\` or a browser CLI when a page needs JS, a login, or a screenshot. Shell out through \`Bun.$\` or \`Bun.spawn\`, never \`Bun.spawnSync\`: a synchronous child blocks the worker and cannot be interrupted.{{#if bunSkillPath}} Before a cell that installs a package, spawns a server or PTY, or starts a long run, read the bun-1-4 skill at {{bunSkillPath}} — its builtins replace the npm packages you would otherwise install.{{/if}}{{else}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}{{/if}}
33
33
  {{#if rb}}Ruby: synchronous; helper options are keyword args{{#if spawns}} (e.g. \`output("id", limit: 2)\`){{/if}}; the last expression auto-displays unless it is \`nil\`, an assignment, or a definition (like IRB).{{/if}}
34
34
  {{#if jl}}Julia: synchronous; helper options are standard keyword args{{#if spawns}} (e.g. \`output("id", limit=2)\`){{/if}}; the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
35
35
  On error, fix and re-run only the failing step; a normal error keeps state, while a timeout or stop message says whether the kernel restarted.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bun-1-4
3
- description: "MUST READ before your first js eval cell: this session's eval js kernel runs Bun 1.4+ (this skill is present only when it does). Also read before any bun -e, script, server, CLI, test, bundle, or package-management work. Bun 1.4 ships builtins that replace 15+ npm deps — check here BEFORE installing sharp, puppeteer/playwright (scraping), marked, node-cron, node-pty, concurrently, serve-static, tar, json5, fast-xml-parser, string-width. Triggers: eval js, bun, Bun.serve, bun test, bun build, bun install, bun run, image resize, headless browser, markdown render, cron, PTY."
3
+ description: "Read before a js eval cell that installs a package, spawns a server or PTY, or starts a long run, and before any bun -e, script, server, CLI, test, bundle, or package-management work: this session's eval js kernel runs Bun 1.4+ (this skill is present only when it does). Bun 1.4 ships builtins that replace 15+ npm deps — check here BEFORE installing sharp, puppeteer/playwright (scraping), marked, node-cron, node-pty, concurrently, serve-static, tar, json5, fast-xml-parser, string-width. Triggers: eval js, bun, Bun.serve, bun test, bun build, bun install, bun run, image resize, headless browser, markdown render, cron, PTY."
4
4
  ---
5
5
 
6
6
  # Bun 1.4 — Use the Builtins First
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import {
2
3
  type AgentToolResult,
3
4
  type ExtensionContext,
@@ -54,11 +55,18 @@ export class CellHandler {
54
55
  readonly #state: CellState;
55
56
  readonly #runtime: CellBridgeRuntime;
56
57
  readonly #resultBuilder: CellResultBuilder;
58
+ readonly #dispatchContext: ReturnType<typeof AsyncLocalStorage.snapshot>;
57
59
 
58
60
  constructor(kernel: EvalKernel, state: CellState, runtime: CellBridgeRuntime) {
59
61
  this.#kernel = kernel;
60
62
  this.#state = state;
61
63
  this.#runtime = runtime;
64
+ // Construct in the submitting cell's host context; worker callbacks cannot supply it (#2512).
65
+ // Bind this cell's capability once; undefined clears any enclosing JS grant for non-JS cells.
66
+ this.#dispatchContext =
67
+ runtime.kernelTools === undefined
68
+ ? kernelToolsStorage.exit(() => AsyncLocalStorage.snapshot())
69
+ : kernelToolsStorage.run(runtime.kernelTools, () => AsyncLocalStorage.snapshot());
62
70
  const settings = runtime.settings.outputSink;
63
71
  this.#resultBuilder = new CellResultBuilder({
64
72
  state,
@@ -89,7 +97,8 @@ export class CellHandler {
89
97
  this.#resultBuilder.display(message);
90
98
  return;
91
99
  case "tool-call": {
92
- const pending = this.#dispatchToolCall(message);
100
+ // A retained worker carries its creation context, not this cell's RPC connection.
101
+ const pending = this.#dispatchContext(() => this.#handleToolCall(message));
93
102
  this.#state.pendingBridgeCalls.push(pending);
94
103
  await pending;
95
104
  return;
@@ -122,19 +131,6 @@ export class CellHandler {
122
131
  return this.#resultBuilder.liveResult();
123
132
  }
124
133
 
125
- /**
126
- * The worker's message loop fires outside the async context `run-eval-cell.ts` enters around the
127
- * awaited run chain, so the capability has to be entered here — around the whole dispatch, including
128
- * the reserved agent()/output() bridges where a host task tool resolves the parent's kernel tools —
129
- * for exactly the duration of each host tool call this cell makes (#1754). Without a capability
130
- * (py/rb/jl) the store stays empty and `ctx.kernelTools` remains undefined.
131
- */
132
- async #dispatchToolCall(message: Extract<KernelToHostMessage, { type: "tool-call" }>): Promise<void> {
133
- const kernelTools = this.#runtime.kernelTools;
134
- if (kernelTools === undefined) return await this.#handleToolCall(message);
135
- return await kernelToolsStorage.run(kernelTools, async () => await this.#handleToolCall(message));
136
- }
137
-
138
134
  async #handleToolCall(message: Extract<KernelToHostMessage, { type: "tool-call" }>): Promise<void> {
139
135
  const startedAt = Date.now();
140
136
  const metric = createToolCallMetric(message.toolName, startedAt);
@@ -73,9 +73,9 @@ const TOOL_CALL_PREVIEW_COUNT = 5;
73
73
  const TOOL_CALL_COLLAPSED_VISUAL_LINES = 4;
74
74
  const TOOL_CALL_COLLAPSED_ERROR_CODE_POINTS = 512;
75
75
  const TOOL_ERROR_OMISSION_MARKER = "[tool error omitted]";
76
- const LIVE_ELAPSED_TICK_MS = 1_000;
76
+ const LIVE_RENDER_TICK_MS = 100;
77
77
  // A live row repaints on every tick, so this many ticks without a render means the row is gone.
78
- const LIVE_TICKER_MAX_IDLE_TICKS = 60;
78
+ const LIVE_TICKER_MAX_IDLE_TICKS = 600;
79
79
 
80
80
  class PlainTextComponent implements EvalRenderComponent {
81
81
  #blocks: readonly RenderBlock[] = [];
@@ -92,7 +92,7 @@ class PlainTextComponent implements EvalRenderComponent {
92
92
  * The host only animates tool rows for streaming args, `task`, and results carrying
93
93
  * `details.progress`; an eval row matches none of them, so nothing repaints it between
94
94
  * update events. While a cell is non-terminal this drives the repaint itself so the
95
- * header's elapsed time advances, and it emits no tool updates or RPC traffic. Detached
95
+ * header's spinner and elapsed time advance, with no tool updates or RPC traffic. Detached
96
96
  * and terminal cards never arm it, and a ticker whose row stopped rendering (transcript
97
97
  * rebuild, session switch) stops itself after LIVE_TICKER_MAX_IDLE_TICKS and rearms on
98
98
  * the next render, so dropped rows cannot accumulate intervals.
@@ -115,7 +115,7 @@ class PlainTextComponent implements EvalRenderComponent {
115
115
 
116
116
  #armTicker(): void {
117
117
  if (this.#ticker !== undefined || this.#invalidate === undefined) return;
118
- this.#ticker = setInterval(() => this.#tick(), LIVE_ELAPSED_TICK_MS);
118
+ this.#ticker = setInterval(() => this.#tick(), LIVE_RENDER_TICK_MS);
119
119
  this.#ticker.unref?.();
120
120
  }
121
121
 
@@ -336,7 +336,10 @@ function cellElapsedMs(cell: EvalCellResult, environment: RenderEnvironment): nu
336
336
  }
337
337
 
338
338
  function cellHeader(cell: EvalCellResult, environment: RenderEnvironment, badges: CellBadges): string {
339
- const presentation = cellPresentation(cell.status, environment.spinnerFrame);
339
+ const presentation = cellPresentation(
340
+ cell.status,
341
+ environment.spinnerFrame ?? Math.floor((cellElapsedMs(cell, environment) ?? 0) / LIVE_RENDER_TICK_MS),
342
+ );
340
343
  const runtimeBadge = cell.runtime === undefined ? "" : ` (${formatRuntimeBadge(cell.language, cell.runtime)})`;
341
344
  let header = `eval ${cell.language}${runtimeBadge} ${presentation.label} ${presentation.icon}`;
342
345
  if (cell.queuedBehind !== undefined && cell.queuedBehind.length > 0)
@@ -207,8 +207,8 @@ async function executeCell(
207
207
  };
208
208
  try {
209
209
  const kernel = await execution.wait(options.kernelManager.getKernel(invocation.input.language, onMessage));
210
- // Computed before the handler so the cell's capability can be entered per host tool call from the
211
- // worker's message loop, which runs outside the `kernelToolsStorage.run` context below (#1754).
210
+ // Computed before the handler so its construction-time snapshot captures this cell's capability.
211
+ // Worker messages later restore that snapshot before calling host tools (#1754, #2512).
212
212
  const kernelTools = jsKernelTools(kernel, invocation.input.language);
213
213
  const runBound = async (): Promise<AgentToolResult<EvalToolDetails>> => {
214
214
  const queue = kernel.queueSnapshot();