pi-crew 0.11.8 → 0.11.10

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/docs/usage.md CHANGED
@@ -45,7 +45,7 @@ Supported fields:
45
45
  "widgetPlacement": "aboveEditor",
46
46
  "widgetMaxLines": 8,
47
47
  "powerbar": true,
48
- "dashboardPlacement": "center",
48
+ "dashboardPlacement": "right",
49
49
  "dashboardWidth": 72,
50
50
  "dashboardLiveRefreshMs": 1000,
51
51
  "autoOpenDashboard": false,
package/install.mjs CHANGED
@@ -54,7 +54,10 @@ export const defaultConfig = {
54
54
  widgetPlacement: "bottom",
55
55
  widgetMaxLines: 8,
56
56
  powerbar: true,
57
- dashboardPlacement: "center",
57
+ // L3 (2026-10-07 real-test review): tracks DEFAULT_UI.dashboardPlacement —
58
+ // enforced by test/unit/install-defaults-sync.test.ts (mutation: edit
59
+ // DEFAULT_UI without this file and that test goes red).
60
+ dashboardPlacement: "right",
58
61
  dashboardWidth: 72,
59
62
  dashboardLiveRefreshMs: 1000,
60
63
  autoOpenDashboard: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.8",
3
+ "version": "0.11.10",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -8,6 +8,10 @@ description: >
8
8
 
9
9
  origin: pi-crew
10
10
  triggers:
11
+ - "pi crashed"
12
+ - "tmux server died"
13
+ - "crashes.json"
14
+ - "completion popup"
11
15
  - "test the change"
12
16
  - "verify it works"
13
17
  - "is it really working"
@@ -105,6 +109,7 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
105
109
  | Đổi… | Tier bắt buộc (thứ tự) |
106
110
  |---|---|
107
111
  | `src/runtime/broker/` | T1 → T7 → T9b-W |
112
+ | `src/extension/command-completions.ts` / `src/extension/registration/commands/*.ts` (autocomplete providers + slash-command handlers) | T1 + pinned `test/unit/extension/core/command-completions*.test.ts` → **T5 completion-popup probe**: trong tmux session thật, gõ `/team-transcript <runId> ` + 1 ký tự nữa cho popup fire — assert (a) pi CÒN SỐNG, (b) gợi ý task id hiện với runId hợp lệ, (c) KHÔNG crash entry mới trong `~/.pi/agent/crashes.json`. Completion callbacks chạy trong `CombinedAutocompleteProvider.getSuggestions` của pi core — nơi KHÔNG catch lỗi extension: một throw = chết cả pi process (P0 live-caught 2026-10-08: `Invalid runId: team_x/team-transcript`, crashes.json có full stack) |
108
113
  | `src/ui/` | T1 → (T5|T6) → T13 |
109
114
  | `src/config/` (kể cả migration-validator) | T1 → T11c |
110
115
  | `src/runtime/child-pi/` (spawn/kill/steer) | T7 → T9g |
@@ -146,6 +151,7 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
146
151
 
147
152
  > **2026-10-01 update (SDD-2 W-B — G2 MCP role-gate + G3 spill-always + Phase D bump 0.99.1, commits `66f37875`→`f0b343ed`, execution record §13 `pi-crew-sdd-2026-09-30-buoi1.md`)**: (1) **G2 flips full-loadout sanity semantics** — MCP presence in workers is now ROLE- and PATH-dependent: the live-session SDK path strips MCP (`DefaultResourceLoader({ extensionsOverride: stripMcpExtensions })`) cho read-only roles (`mcpPermittedForRole` — explorer/reviewer/security-reviewer/analyst/critic/planner + unknown default-deny); a read-only worker WITHOUT MCP is CORRECT, a write-role worker without MCP is a regression; nếu strip không arm được (SDK hết export `DefaultResourceLoader`…) → fail-open + warning event `task.mcp_enforcement_degraded` (registry 168 → 169, `check:event-types --enforce` clean). CLI child-process path CHƯA cover (WI-4 defer: `~/.pi/agent/mcp.json` trống + `-ne` giết provider `oc-go` — read-only roles vẫn discover adapter MCP trên đường đó). (2) **G3 spill-always**: MỌI task text qua `task.md` 0600 — argv worker KHÔNG bao giờ chứa task text; probe đọc argv phải expect `@<temp>/task.md`, task ngắn mất prefix `Task: `; `TASK_ARG_LIMIT` đã xóa. (3) Phase D bump `@earendil-works/*` → 0.99.x: SDK bỏ option `enableMCP`, tool seam cần `ExtensionToolContext` (export mới `src/extension/pi-api.ts`). Decision table thêm row `mcp-proxy.ts`/`model/pi-args.ts`; D5 loadout line refs dời `:283-330` → `:280-336`.
148
153
  > **2026-10-07 update (0.11.6 → 0.11.7/0.11.8-pre stack, GH#61/#62 + pi 1.0.4 adoption + RPC hardening, HEAD `11e717f5`)**: (1) **GH#61 (0.11.6)** — directory `pi.extensions` entries được normalize sang entry-file identity (`resolveExtensionDirEntry`): child worker không còn double-load directory extension (`Tool "…" conflicts … exit 1` — CHANGELOG 0.11.6 block, `CHANGELOG.md:207`). (2) **pi 1.0.4 adoption** — devDeps `@earendil-works/*` ^1.0.4 (`package.json:153-156`; peerDeps `:136-139` giữ `*`), host pi 1.0.4. (3) **U2-lite MCP parity cut** — parity spawns fold `mcp__*` vào disallow list trên MỌI host (`pi-args.ts:414`) + `--no-mcp` khi host ≥ `PI_NO_MCP_FLOOR="1.0.4"` (`pi-version.ts:76`, gate `pi-args.ts:430`); hermetic spawns MCP-clean qua `--no-extensions` — probe loadout: worker KHÔNG thấy `mcp__*` là ĐÚNG trên cả parity lẫn hermetic path (verdict `docs/reviews/pi-1.0.4-upgrade-notes-2026-10-06.md`). (4) **RPC transport wired (W7)** — `PI_CREW_WORKER_TRANSPORT=rpc` chọn RPC transport tại run-worker seam (`runWorker`, `src/runtime/run-worker.ts:138`; env names `src/runtime/rpc/rpc-worker.ts:43-49`), precedence **env > `runtime.workerTransport` config > stdio**, invalid → stdio (`config-schema.ts:157`); probe recipe = T7 smoke với `PI_CREW_WORKER_TRANSPORT=rpc`; DR1/DR5 exit-race hardening + `PI_CREW_RPC_TURN_TIMEOUT_MS` (default 600_000; dialog policy `PI_CREW_RPC_DIALOG_ANSWER` default `cancel`) — suite `run-worker-rpc-seam.test.ts` (8) có 3 scenario MỚI: DR1 exit-race `:223`, DR5 started-then-fail KHÔNG fallback `:266`, DR1 turn-timeout `:298` (+ truth table `:328`); pin kèm `test/unit/runtime/rpc/rpc-worker.test.ts` (10). RPC vẫn EXPERIMENTAL — fail trước agent output → stdio fallback (structured warn). (5) **Classifier seam DORMANT (P2-1)** — default OFF (`config-schema.ts:95`; `resolveClassifierEnabled`: env `PI_CREW_CLASSIFIER_ENABLED` > config > FALSE, `classifier-service.ts:158`); battery KHÔNG dính trừ khi set `PI_CREW_CLASSIFIER_ENABLED=1` (+ `PI_CREW_CLASSIFIER_MODEL`, default `opencode/jev-1.13-free`) — unit-only (`classifier-service.test.ts` 14 + `child-executor-retry-triage.test.ts` 6), KHÔNG live tier. (6) **Pre-push hook (D3)** — `.githooks/pre-push` qua `git config core.hooksPath .githooks` (wired `package.json` `prepare` → `scripts/install-hooks.mjs`): MỌI push chạy `check:conflict-markers` + typecheck; push `refs/heads/main` TỰ chạy FULL `test:unit` (~13-14 phút) rồi mới in `[pre-push] gates green.`; bypass `git push --no-verify` CHỈ khẩn cấp (CI vẫn gate main). (7) **GH#62 foreground completion** (`62c9ed0d`→`51adb1e1`→`3ed0067e`→`c956b2d4`→`af462d7b`) — foreground-run completion path sống sót qua spread-copy contexts; suite `test/unit/extension/registration/foreground-run-completion.test.ts` (4 tests). (8) **hermeticWorkers DEFAULT ON** (`config-schema.ts:89`; env `PI_CREW_HERMETIC_WORKERS`; `resolveHermeticWorkers` `pi-args.ts:149` — `explicit ?? true`). (9) **U9 version-floor wiring** — suites `test/unit/runtime/child-pi/pi-version.test.ts` (11) + `test/unit/runtime/model/pi-args-version-floor.test.ts` (7) + `test/unit/runtime/child-pi/pi-version-floor-wiring.test.ts` (2). (10) **RT-11 lesson (`48cc088d`)** — static spawn-site scan (`child-pi-spawn-registration.test.ts:33`) đếm CẢ prose trong comment (`spawn (`); khi dash vì comment → **reword comment, KHÔNG relax test**. Suite anchors @ 2026-10-07: unit **8711 tests / 8707 pass / 1 flaky-under-load / 3 skipped** (968 files, ~13 min; flaky = `team-tool-resume-liveness` F1 liveness race — 6/6 pass isolated ×3, KHÔNG phải regression), integration **139 / 135 pass / 4 skipped** (exit 0 — CHỈ khi scrub ĐỦ `PI_CREW_*`/`PI_TEAMS_*`, xem Failure-symptoms row MAX_DEPTH), `test:critical` **120** (~19-20s), bundle **1620.1 KB** (1,658,985 B) md5 `20544cf9ea436c69b0b434462def91b9` (`--committed-hash` OK), event registry **176 registered / 141 emitted** (`check:event-types --enforce` clean), devDeps `@earendil-works/*` ^1.0.4 / host pi **1.0.4**.
154
+ > **2026-10-08 update (L-wave UI instability review + L10 plan flicker + P0 completion crash, commits `28e0d58d`+`c068fc4a`+`c89c759a`, report `docs/real-test/reports/real-test-2026-10-07-ui-instability-review.md` + fix-wave addenda)**: (1) **P0 — completion throw giết cả pi**: `/team-transcript` popup race làm input dính `/<cmd>` suffix vào runId token → `assertSafePathId` throw bên trong `CombinedAutocompleteProvider.getSuggestions` (pi core KHÔNG catch lỗi extension) → uncaught_exception chết cả process. Fix: MỌI completion provider (`suggestTaskIds`, `suggestRunIds`…) try/catch → null; pin test + mutation. **Evidence source mới: `~/.pi/agent/crashes.json`** — khi pi/tmux chết bất ngờ giữa probe, đọc file này TRƯỚC khi đoán (có kind/message/stack qua dist). Decision table có row mới cho command-completions. (2) **L10 — plan card flicker**: tasks variant không được paint spinner động (comment thiết kế của chính nó); signature phải strip `:spin=`. Dock giữ đúng MỘT dòng spinner (chủ ý). (3) **Probe-hygiene lessons** (đều tốn điều tra giả session này): tmux `new-session -t <sess>` tạo GROUPED session (cùng process — vô dụng cho bystander probe; phải `new-session -d -s <tên> "cd … && exec pi"`); `pkill -f <pattern>` từ bash của assistant TỰ MATCH command line của chính nó (chuỗi literal nằm trong args/heredoc — bracket-trick không đủ; kill bằng PID hoặc tên script riêng); so sánh frame theo row-index bị transcript-scroll làm nhiễu — so BLOCK theo marker `┏ NAME ▸`…`┗`, và dock có HAI dạng render (`┏ CREW ▸` mở rộng / `┃ CREW ▸` thu gọn — marker phải match cả hai, từng gây báo động giả "dock biến mất"); dispatch sync run khi có run khác đang chạy → assistant có thể join/wait run cũ thay vì tạo run mới (luôn verify runId mới trong `.crew/state/runs/` trước khi probe). (4) **Load-flake rule**: `test:critical` đỏ 1 suite lạ + treo isolation dưới load 82+ (build song song của user) = starvation, KHÔNG phải regression — check `uptime` TRƯỚC, re-run khi máy êm (session này: symlink-steering treo dưới load 82, 120/120 xanh khi load 0.8). Suite anchors @ 2026-10-08: `test:critical` **120**, bundle **1622.6 KB** md5 `4f76457497bd20779e298cee4d8d982e`.
149
155
  > **2026-09-21 update (F-L2 — steer boot-window loss, found by the live feature battery)**: a steer written to `<artifactsRoot>/steering/<taskId>.jsonl` while the worker extension is still LOADING was silently lost forever — `registerPiTeamsPromptRuntime` starts the file poll at load time, pi's loader gates action methods until bind (`sendMessage: notInitialized` THROWS), and the old poll advanced `lastOffset` to `stat.size` BEFORE the line loop while its per-line catch swallowed the throw as "malformed line" (run `team_20260921035840`, root-caused by watcher-driven bisection probes R7-R9: +0.65s post-boot = lost, +2.5s+ = delivered). Fix (`src/prompt/prompt-runtime.ts`): walk the buffer by byte offset, advance only past terminal verdicts (delivered/non-steer/rejected/malformed), rewind to the failing line on a DELIVERY failure, `createSeenSteerIdSet.unmark()` releases the dedup id so the retry is not skipped. Regression suite `test/unit/prompt/prompt-runtime-steer-boot-window.test.ts` (4 tests) — mutation-checked both layers (offset-revert → 4 fail; unmark-removal → exactly the id-bearing test fails). Note: `dist/index.mjs` is UNCHANGED by this fix — child workers load prompt-runtime from the SOURCE path (`pi-args.ts PROMPT_RUNTIME_EXTENSION_PATH`), so the fix is live without a bundle bump.
150
156
  >
151
157
  > **F-L2 live re-verification (post-restart, 2026-09-21 afternoon)** — two independent proofs: (a) **lab**: pre-write the steer, then `PI_CREW_STEERING_FILE=<file> pi --extension <repo>/src/prompt/prompt-runtime.ts -p '<long task>'` — stderr shows `[pi-crew:prompt-runtime.steer-delivery-failed] Extension runtime not initialized…` ×2 (pre-bind ticks now LOGGED instead of swallowed) then the post-bind tick delivers and the model complies; (b) **real team run**: watcher writes at **+0.51s** after the steering dir appears (the previously-lost window) → worker session log gains the `custom_message` and the probe token lands in `results/<taskId>.txt`. Suite anchors @ 2026-09-21 (post-fix): unit **8006 tests / 8003 pass / 0 fail / 3 skipped** (875 files; 1088s under load), integration **134 / 130 pass / 4 skipped**, `test:critical` **116**, bundle **3360.1 KB** md5 `2f45df91ed0580ee5bb5b9b57a8a8c3f` (UNCHANGED — see the source-path note above).
@@ -408,6 +414,20 @@ tmux capture-pane -t pi -p > /tmp/screen-after-up.txt
408
414
  | App-cursor-mode | `\x1bOA` | `\x1bOB` | vim, less, full-screen apps |
409
415
  | Kitty protocol | `\x1b[1;2A` (Shift+Up) etc. | — | modern terminals (kitty, foot, ghostty) |
410
416
 
417
+ **Completion-popup probe (P0 2026-10-08 — dùng khi đụng `command-completions.ts` / slash-command handlers)**: completion callbacks chạy trong `CombinedAutocompleteProvider.getSuggestions` của pi core — nơi KHÔNG catch lỗi extension, một throw = chết cả pi process. Probe:
418
+
419
+ ```bash
420
+ # (a) input từng gây crash: runId token dính suffix "/<cmd>" + trailing space cho popup fire
421
+ tmux -S /tmp/sock send-keys -t pi '/team-transcript team_20261008_xxx/team-transcript '
422
+ sleep 2.5; tmux -S /tmp/sock ls >/dev/null && echo ALIVE # phải sống
423
+ # crashes.json phải KHÔNG có entry mới (đọc ~/.pi/agent/crashes.json, lọc timestamp > thời điểm probe)
424
+ # (b) flow hợp lệ: runId thật + space + 1 ký tự → popup gợi ý task id
425
+ tmux -S /tmp/sock send-keys -t pi C-c
426
+ tmux -S /tmp/sock send-keys -t pi '/team-transcript <runId-thật> '
427
+ sleep 2; tmux -S /tmp/sock send-keys -t pi '0'; sleep 2
428
+ tmux -S /tmp/sock capture-pane -t pi -p | grep -aE '0[123]_' | head -3 # kỳ vọng 01_explore/02_execute…
429
+ ```
430
+
411
431
  **References**:
412
432
 
413
433
  | What | Where |
@@ -1144,6 +1164,12 @@ When a tier fails, the recovery is usually quick. Match the symptom to the cause
1144
1164
 
1145
1165
  | Symptom | Likely cause | Recovery |
1146
1166
  |---|---|---|
1167
+ | Pi process / tmux server chết bất ngờ giữa probe (capture-pane báo "no server running") | **Extension giết pi bằng uncaught exception** — pi core không catch lỗi extension trong autocomplete provider / async callback. P0 2026-10-08: `Invalid runId: team_x/team-transcript` từ `assertSafePathId` qua `suggestTaskIds` | Đọc `~/.pi/agent/crashes.json` TRƯỚC khi đoán gì khác — có `kind`/`message`/`stack` (symbol qua `dist/index.mjs` → map lại src). Session mới mở cũng hiện banner "pi crashed on …" kèm message. Fix pattern: callback phải try/catch → degrade, KHÔNG bao giờ throw ra ngoài |
1168
+ | `test:critical` đỏ đúng 1 suite lạ (broker/steering) + treo cả khi chạy isolated, trong khi xanh hoàn toàn vài giờ trước | **Starvation dưới load cao** (build song song, load 80+): broker handshake/timeout-based tests chết đói CPU, KHÔNG phải regression từ thay đổi UI — session 2026-10-08 đỏ symlink-steering dưới load 82, 120/120 xanh lại khi load 0.8 | `uptime` + `ps -eo pcpu --sort=-pcpu | head` TRƯỚC; đợi/re-run khi máy êm; chỉ chẩn đoán regression khi đỏ lại trên máy sạch |
1169
+ | Probe session thứ 2 (bystander) hiển thị y hệt session 1, keystroke session này ảnh hưởng session kia | `tmux new-session -t <sess>` tạo **GROUPED session** — cùng window/cùng process, không phải pi riêng | Bystander phải STANDALONE: `tmux -S <sock> new-session -d -x 150 -y 50 -s by "cd <cwd> && exec pi 2>&1"` (KHÔNG dùng `-t`); verify bằng 2 PID pi riêng trong `ps` |
1170
+ | Bash của assistant tự chết exit 143 ngay sau lệnh `pkill -f <pattern>` | **pkill tự match**: command line của chính bash chứa chuỗi pattern (trong heredoc/args) — bracket-trick `pat[.]tern` KHÔNG đủ khi chuỗi literal nằm trong tham số | Đặt tên script capture riêng (capV7.sh…), kill bằng `pkill -f loopV7` với tên KHÔNG xuất hiện trong lệnh hiện tại, hoặc kill theo PID ghi sẵn |
1171
+ | So sánh frame theo row-index cho hàng trăm "diff" giả (mọi row "đổi" liên tục) | **Transcript scroll**: output worker/stream làm mọi dòng dịch chuyển row — so row-index đếm toàn bộ là đổi | So BLOCK theo marker biên: `┏ PLAN`…`┗`, `┏ DASHBOARD`…, và LƯU Ý dock có 2 dạng: mở rộng `┏ CREW ▸` (có progress bar) / thu gọn `┃ CREW ▸` (1 dòng) — match cả hai, từng gây báo động giả "dock biến mất" |
1172
+ | Dispatch sync run nhưng không có run mới trong `.crew/state/runs/`, transcript nói "ran synchronously despite async=true" | Assistant/agent JOIN hoặc WAIT vào run đang chạy thay vì tạo run mới | Luôn verify runId mới xuất hiện (`ls -t .crew/state/runs/`) sau lệnh dispatch trước khi probe; dispatch khi không run nào active |
1147
1173
  | `test:critical` returns `# fail N>0` | Regression in touched source | Read the failing test's name + assertion; fix the source; rerun |
1148
1174
  | `test:critical` hangs >60s | One test opened a socket/pty that didn't close | Run individual file: `node --import tsx/esm --test --test-force-exit test/unit/<file>.test.ts`; check for missing `await` or unclosed handle |
1149
1175
  | `typecheck` fails with `TS2xxx` | TS type drift after src/ edit | Fix the type error; do not commit until exit 0 |
@@ -138,7 +138,14 @@ export const DEFAULT_UI = {
138
138
  widgetMaxLines: 8,
139
139
  inlinePanel: true,
140
140
  powerbar: true,
141
- dashboardPlacement: "center" as const,
141
+ // L3 (2026-10-07 real-test review): default "right" — the centered 90%
142
+ // overlay leaves both margins showing chopped background words (pi-tui's
143
+ // compositeTuiLine keeps base.before/base.after visible outside overlay
144
+ // bounds by design); the right-anchored panel covers them. Mirror updates:
145
+ // install.mjs + the two EFFECTIVE_DEFAULTS maps (handle-settings.ts,
146
+ // settings-overlay.ts) — install-defaults-sync.test.ts enforces the first,
147
+ // G17 lesson enforces the rest.
148
+ dashboardPlacement: "right" as const,
142
149
  dashboardWidth: 72,
143
150
  dashboardLiveRefreshMs: 1000,
144
151
  autoOpenDashboard: false,
@@ -53,31 +53,53 @@ function statusIcon(status: TeamRunManifest["status"]): string {
53
53
  * process cwd matches the session cwd.
54
54
  */
55
55
  export function suggestRunIds(_prefix: string, cwd?: string): AutocompleteItem[] | null {
56
- const resolvedCwd = cwd ?? process.cwd();
57
- const runs = listRecentRuns(resolvedCwd, MAX_RUN_SUGGESTIONS);
58
- if (runs.length === 0) return null;
59
- const items: AutocompleteItem[] = runs.map((run) => ({
60
- value: run.runId,
61
- label: run.runId,
62
- description: `${statusIcon(run.status)} ${run.status} · ${run.team} · ${(run.goal ?? "").slice(0, 48)}`,
63
- }));
64
- return filterByPrefix(items, _prefix);
56
+ // L-crash (2026-10-08): completion callbacks run inside pi's autocomplete
57
+ // provider, which does not catch extension errors — never throw here.
58
+ try {
59
+ const resolvedCwd = cwd ?? process.cwd();
60
+ const runs = listRecentRuns(resolvedCwd, MAX_RUN_SUGGESTIONS);
61
+ if (runs.length === 0) return null;
62
+ const items: AutocompleteItem[] = runs.map((run) => ({
63
+ value: run.runId,
64
+ label: run.runId,
65
+ description: `${statusIcon(run.status)} ${run.status} · ${run.team} · ${(run.goal ?? "").slice(0, 48)}`,
66
+ }));
67
+ return filterByPrefix(items, _prefix);
68
+ } catch {
69
+ return null;
70
+ }
65
71
  }
66
72
 
67
- /** Suggest task IDs within a specific run (for /team-result <runId> <taskId>). */
73
+ /**
74
+ * Suggest task IDs within a specific run (for /team-result <runId> <taskId>).
75
+ *
76
+ * L-crash (2026-10-08, P0): this runs inside pi's autocomplete provider
77
+ * (`CombinedAutocompleteProvider.getSuggestions`), which does NOT catch
78
+ * extension errors — a throw here kills the whole pi process (live-caught:
79
+ * uncaught_exception "Invalid runId: team_x/team-transcript" after the
80
+ * autocomplete popup raced typed input and glued a "/…" suffix onto the
81
+ * runId token; assertSafePathId correctly rejected it, but the throw
82
+ * escaped). Completion callbacks must NEVER throw: any failure -> null.
83
+ */
68
84
  export async function suggestTaskIds(runId: string, prefix: string, cwd?: string): Promise<AutocompleteItem[] | null> {
69
- const resolvedCwd = cwd ?? process.cwd();
70
- // Dynamic import to avoid pulling state-store into the hot command-registration path.
71
- // LAZY: defer dynamic import of ../state/stores/state-store.ts to its call site.
72
- const { loadRunManifestById } = await import("../state/stores/state-store.ts");
73
- const loaded = loadRunManifestById(resolvedCwd, runId);
74
- if (!loaded) return null;
75
- const items: AutocompleteItem[] = loaded.tasks.map((task) => ({
76
- value: task.id,
77
- label: task.id,
78
- description: `${task.status} · ${task.role} · ${task.title?.slice(0, 40) ?? ""}`,
79
- }));
80
- return filterByPrefix(items, prefix);
85
+ try {
86
+ const resolvedCwd = cwd ?? process.cwd();
87
+ // Dynamic import to avoid pulling state-store into the hot command-registration path.
88
+ // LAZY: defer dynamic import of ../state/stores/state-store.ts to its call site.
89
+ const { loadRunManifestById } = await import("../state/stores/state-store.ts");
90
+ const loaded = loadRunManifestById(resolvedCwd, runId);
91
+ if (!loaded) return null;
92
+ const items: AutocompleteItem[] = loaded.tasks.map((task) => ({
93
+ value: task.id,
94
+ label: task.id,
95
+ description: `${task.status} · ${task.role} · ${task.title?.slice(0, 40) ?? ""}`,
96
+ }));
97
+ return filterByPrefix(items, prefix);
98
+ } catch {
99
+ // Invalid runId (path-unsafe chars), import/state-store/cache failure —
100
+ // degrade to "no suggestions" instead of crashing the host process.
101
+ return null;
102
+ }
81
103
  }
82
104
 
83
105
  /** Suggest available teams for /team-run <team>. */
@@ -620,26 +620,16 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
620
620
  continue;
621
621
  }
622
622
  if (selection.action === "agent-transcript" && (await openTranscriptViewer(cmdCtx, selection.runId))) continue;
623
+ // F1 (2026-10-08): openLiveConversation now owns ALL not-found messaging
624
+ // with a runtime-aware notice (child-process/scaffold runs point at the
625
+ // transcript viewer; live-session runs keep "No live agent found"). Its
626
+ // old fall-through block here fired even on picker-cancel — removed.
623
627
  if (selection.action === "agent-live" && (await openLiveConversation(cmdCtx, selection.runId))) continue;
624
628
  // feat/agents-browser: `b` from the dashboard opens the unified Agents
625
629
  // & Jobs overlay WITHOUT closing the loop — continue reopens the
626
630
  // dashboard underneath, mirroring the transcript/live-conversation
627
631
  // viewers. runId may be "" (no run selected — jobs-only view).
628
632
  if (selection.action === "browser-open" && (await openAgentsJobsBrowser(cmdCtx))) continue;
629
- if (selection.action === "agent-live") {
630
- await notifyCommandResult(
631
- cmdCtx,
632
- commandText({
633
- content: [
634
- {
635
- type: "text",
636
- text: "No live agent found for this run.",
637
- },
638
- ],
639
- }),
640
- );
641
- continue;
642
- }
643
633
  const result =
644
634
  selection.action === "api"
645
635
  ? await handleTeamTool(
@@ -319,12 +319,34 @@ export function setupRenderLoop(
319
319
  );
320
320
  }
321
321
  };
322
+ // L2 (real-test 2026-10-07, report L2): rebuild-in-place, NEVER
323
+ // hard-delete. The health gates below used `invalidate(runId)`
324
+ // (entries.delete) on every tick for terminal/divergent runs — exactly
325
+ // what the FLICKER FIX invariant (run-snapshot-cache.ts scheduleRefresh
326
+ // docs + the onRunChange wiring above) forbids: for 1-n frames
327
+ // `snapshotCache.get(runId)` returned undefined and the widget/powerbar
328
+ // fell back to their disk-read branch around every run completion
329
+ // (visible flicker). Route through the coalesced ASYNC rebuild instead —
330
+ // the entry stays populated until the rebuild re-sets it in place. No
331
+ // renderScheduler.schedule() here: unlike the fs.watch onRunChange path
332
+ // we are ALREADY inside a renderTick, and list(20) keeps terminal runs
333
+ // in sessionManifests for many ticks, so a schedule() from the gates
334
+ // would reset lastEventAt on every tick and defeat the R1 idle stop
335
+ // (pinned by preload-idle-render.test.ts).
336
+ const refreshSnapshotInPlace = (runId: string): void => {
337
+ try {
338
+ snapshotCache.scheduleRefresh(runId);
339
+ } catch (error) {
340
+ logInternalError("register.renderLoop.snapshotRefresh", error, runId);
341
+ }
342
+ };
322
343
  for (const run of sessionManifests) {
323
344
  if (run.status !== "running") {
324
- // GATE 1 — preloaded manifest says terminal. Purge any stale snapshot
325
- // and clear previously-emitted health notifications so the dashboard
326
- // stays clean (belt-and-suspenders with the FIX #1 fresh-read gate).
327
- snapshotCache.invalidate(run.runId);
345
+ // GATE 1 — preloaded manifest says terminal. Refresh the snapshot in
346
+ // place (never delete) and clear previously-emitted health notifications
347
+ // so the dashboard stays clean (belt-and-suspenders with the FIX #1
348
+ // fresh-read gate).
349
+ refreshSnapshotInPlace(run.runId);
328
350
  clearHealthNotifications(run.runId);
329
351
  continue;
330
352
  }
@@ -333,20 +355,25 @@ export function setupRenderLoop(
333
355
  // (from lastPreloadedManifests) can lag the on-disk terminal
334
356
  // transition; the manifest cache has a 500ms TTL + file watcher so it
335
357
  // is the source of truth. A terminal run must NEVER reach
336
- // maybeNotifyHealth. Also purge the stale snapshot + clear any
337
- // previously-emitted health notification for this run.
358
+ // maybeNotifyHealth. Also refresh the stale snapshot in place
359
+ // (never delete) and clear any previously-emitted health
360
+ // notification for this run.
338
361
  const freshManifest = ctx.getManifestCache(extensionCtx.cwd).get(run.runId);
339
362
  if (freshManifest?.status !== "running") {
340
- snapshotCache.invalidate(run.runId);
363
+ refreshSnapshotInPlace(run.runId);
341
364
  clearHealthNotifications(run.runId);
342
365
  continue;
343
366
  }
344
- const snapshot = snapshotCache.get(run.runId);
367
+ // L5: paint-path read through the read-only accessor — cached
368
+ // snapshot only; a lapsed TTL schedules the coalesced async refresh
369
+ // instead of ever rebuilding synchronously inside renderTick.
370
+ const snapshot = snapshotCache.readForRender(run.runId);
345
371
  if (!snapshot) continue;
346
372
  if (snapshot.manifest.status !== "running") {
347
- // GATE 2 — a running snapshot paired with a now-terminal manifest is
348
- // stale. Purge it so subsequent ticks get a fresh view, and clear.
349
- snapshotCache.invalidate(run.runId);
373
+ // GATE 2 — a running manifest paired with a now-terminal snapshot is
374
+ // stale. Refresh it in place so subsequent ticks get the fresh view,
375
+ // and clear.
376
+ refreshSnapshotInPlace(run.runId);
350
377
  clearHealthNotifications(run.runId);
351
378
  continue;
352
379
  }
@@ -357,10 +384,10 @@ export function setupRenderLoop(
357
384
  // worker counted as active-without-heartbeat and fired a false
358
385
  // "dead worker" (live: team_20260923100114, 01_explore parked on
359
386
  // ask). Overlay FRESH statuses before summarizing; divergence also
360
- // invalidates the stale cache entry so the next tick rebuilds it.
387
+ // schedules an in-place rebuild so the cache syncs off the tick.
361
388
  const freshTasks = loadTasksWithRecovery(freshManifest.tasksPath, freshManifest.eventsPath, run.runId);
362
389
  const overlaid = overlayFreshTaskStatuses(snapshot, freshTasks);
363
- if (overlaid !== snapshot) snapshotCache.invalidate(run.runId);
390
+ if (overlaid !== snapshot) refreshSnapshotInPlace(run.runId);
364
391
  const summary = summarizeHeartbeats(overlaid, { now });
365
392
  const fingerprint = healthNotifyFingerprint(summary, overlaid.tasks.length);
366
393
  const maybeNotifyHealth = (kind: string, count: number, title: string, body: string): void => {
@@ -424,13 +451,16 @@ export function setupRenderLoop(
424
451
  // The next `renderTick` then saw `get() === undefined` for every run,
425
452
  // so `activeWidgetRuns` dropped them to "(loading…)" until the async
426
453
  // preload rebuilt the cache — an endless visible flicker. For a
427
- // specific runId we now refresh-if-stale (stale-while-revalidate) so
428
- // the widget always sees a populated snapshot; a no-runId tick does
429
- // nothing (renderTick itself repaints; the cache's own
430
- // run:state/worker:lifecycle subscription refreshes affected runs).
454
+ // specific runId we schedule the coalesced ASYNC refresh (L5,
455
+ // real-test 2026-10-07): refreshIfStale() did a SYNC full rebuild
456
+ // between paints (and a sync build for missing entries), blocking the
457
+ // event loop mid-render; scheduleRefresh keeps the entry populated and
458
+ // rebuilds off the tick. A no-runId tick does nothing (renderTick
459
+ // itself repaints; the cache's own run:state/worker:lifecycle
460
+ // subscription refreshes affected runs).
431
461
  if (!runId) return;
432
462
  try {
433
- ctx.getRunSnapshotCache(extensionCtx.cwd).refreshIfStale(runId);
463
+ ctx.getRunSnapshotCache(extensionCtx.cwd).scheduleRefresh(runId);
434
464
  } catch (error) {
435
465
  logInternalError("register.renderScheduler.refresh", error, runId);
436
466
  }
@@ -442,9 +472,9 @@ export function setupRenderLoop(
442
472
  const unsubscribeRunEvents = runEventBus.onAny((event) => {
443
473
  // bug-026 sub-issue C: evict terminal runs from the preloaded manifest
444
474
  // frame so a stale "running" entry cannot persist for the session
445
- // lifetime. Additive to the renderTick GATE 1 / FIX #1 / GATE 2 snapshot
446
- // purges (which invalidate the snapshot cache) — those gates do not
447
- // touch lastPreloadedManifests itself.
475
+ // lifetime. Additive to the renderTick GATE 1 / FIX #1 / GATE 2
476
+ // snapshot refreshes (which rebuild the snapshot cache entries in
477
+ // place) — those gates do not touch lastPreloadedManifests itself.
448
478
  lastPreloadedManifests = applyTerminalRunEventToManifests(lastPreloadedManifests, event);
449
479
  sched.schedule({
450
480
  runId: event.runId,
@@ -10,6 +10,7 @@ import { requestRenderTarget } from "../../ui/pi-ui-compat.ts";
10
10
  import { asCrewTheme } from "../../ui/theme-adapter.ts";
11
11
  // Lazy-loaded: DurableTranscriptViewer is 658ms — only needed for /crew transcript command
12
12
  import type { DurableTranscriptViewer as DurableTranscriptViewerType } from "../../ui/transcript-viewer.ts";
13
+ import { notifyCommandResult } from "./command-utils.ts";
13
14
 
14
15
  async function getViewer(): Promise<typeof DurableTranscriptViewerType> {
15
16
  // LAZY: DurableTranscriptViewer is 658ms — only needed for /crew transcript.
@@ -68,6 +69,22 @@ export async function openTranscriptViewer(
68
69
  return true;
69
70
  }
70
71
 
72
+ /**
73
+ * F1 (2026-10-08 full-tier battery): notice shown when `V` (live conversation)
74
+ * finds no live agent for a run. With the default child-process runtime the
75
+ * in-process live-agent registry is ALWAYS empty by design — agents run as
76
+ * child Pis and stream transcripts to disk, and only the (ADR-frozen)
77
+ * live-session runtime ever registers handles. The old generic "No live agent
78
+ * found" therefore read as a bug on every child-process run. Route the user
79
+ * to the surface that works instead of a dead end.
80
+ */
81
+ export function liveConversationNotice(runtimeKind: string | undefined): string {
82
+ if (runtimeKind === "child-process" || runtimeKind === "scaffold") {
83
+ return `Live conversation requires the live-session runtime; this run executes via ${runtimeKind} and streams transcripts to disk. Press v (dashboard) or /team-transcript to follow along.`;
84
+ }
85
+ return "No live agent found for this run.";
86
+ }
87
+
71
88
  /** R2: Open live conversation overlay for a running live-session agent. */
72
89
  export async function openLiveConversation(
73
90
  ctx: ExtensionCommandContext,
@@ -78,7 +95,14 @@ export async function openLiveConversation(
78
95
  if (!selected || !ctx.hasUI) return false;
79
96
  const liveAgents = listLiveAgents();
80
97
  const handle = liveAgents.find((h) => h.runId === selected.runId && (selected.taskId ? h.taskId === selected.taskId : true));
81
- if (!handle) return false;
98
+ if (!handle) {
99
+ // F1: runtime-aware notice instead of a dead-end error. Contract change:
100
+ // `true` now also covers "answered with a notice" — `false` means the
101
+ // user cancelled the picker (or no UI), so callers stay silent on false.
102
+ const manifest = loadRunManifestById(ctx.cwd, selected.runId);
103
+ await notifyCommandResult(ctx, liveConversationNotice(manifest?.manifest.runtimeResolution?.kind));
104
+ return true;
105
+ }
82
106
  const theme = asCrewTheme({});
83
107
  await ctx.ui.custom<undefined>(
84
108
  (tui, _theme, _keybindings, done) => {
@@ -35,7 +35,10 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
35
35
  "ui.showModel": true,
36
36
  "ui.showTokens": true,
37
37
  "ui.showTools": true,
38
- "ui.dashboardPlacement": "center",
38
+ // L3 (2026-10-07): mirrors DEFAULT_UI.dashboardPlacement = "right"
39
+ // (defaults.ts) — G17: duplicated EFFECTIVE_DEFAULTS maps must track the
40
+ // canonical default or the settings overlay shows a stale value.
41
+ "ui.dashboardPlacement": "right",
39
42
  "ui.dashboardWidth": 72,
40
43
  "ui.autoOpenDashboard": false,
41
44
  // G17 sync (2026-09-10 review): canonical DEFAULT_UI.widgetPlacement =
@@ -134,8 +134,9 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
134
134
 
135
135
  // `3/3 tasks · 3 agents` — the label arrives pre-pluralised by the caller
136
136
  // ("N agents"); the task tally keeps the slash form on purpose (it reads as
137
- // progress, not as a count).
138
- lines.push(`${completed}/${total} tasks · ${label}`);
137
+ // progress, not as a count), with the noun pluralised by the TOTAL
138
+ // (formatCount, L9): `1/1 task`, `3/3 tasks`.
139
+ lines.push(`${completed}/${formatCount(total, "task")} · ${label}`);
139
140
 
140
141
  for (const agent of realAgents.slice(0, 12)) {
141
142
  const liveHandle = liveByTaskId.get(agent.taskId);
@@ -16,8 +16,13 @@ function seconds(ms: number): string {
16
16
  export function renderHealthPane(snapshot: RunUiSnapshot | undefined, opts: HealthPaneOptions = {}): string[] {
17
17
  if (!snapshot) return ["Health pane: snapshot unavailable"];
18
18
  const summary = summarizeHeartbeats(snapshot, opts);
19
+ // L7 judgment: this pane IS the diagnostic surface — `stale`/`dead`/
20
+ // `missing` are the exact vocabulary the K kill-stale / R recovery actions
21
+ // and the diagnostic export act on, so the LABELS stay. But the wire
22
+ // separator goes (report L7 flags `stale=`): the line now speaks the same
23
+ // count-first tally dialect as `1/3 healthy`, not `stale=2` key=value.
19
24
  const lines = [
20
- `Health pane: ${summary.healthy}/${summary.totalTasks} healthy · stale=${summary.stale} · dead=${summary.dead} · missing=${summary.missing}`,
25
+ `Health pane: ${summary.healthy}/${summary.totalTasks} healthy · ${summary.stale} stale · ${summary.dead} dead · ${summary.missing} missing`,
21
26
  ];
22
27
  if (summary.worstStaleMs > 0) lines.push(`Worst stale: ${seconds(summary.worstStaleMs)} ago`);
23
28
  // One hint format (rail.ts `formatHint`): keys `label` joined by ` · `.
@@ -1,3 +1,4 @@
1
+ import { formatCount } from "../format-helpers.ts";
1
2
  import { formatHint } from "../rail.ts";
2
3
  import type { RunUiSnapshot } from "../snapshot-types.ts";
3
4
 
@@ -8,24 +9,28 @@ export function renderMailboxPane(snapshot: RunUiSnapshot | undefined): string[]
8
9
  const unread = mailbox.inboxUnread ?? 0;
9
10
  const outgoing = mailbox.outboxPending ?? 0;
10
11
  const attention = mailbox.needsAttention ?? 0;
11
- const lines: string[] = [`Mailbox pane: inbox unread=${unread} · outbox pending=${outgoing} · attention=${attention}${approx}`];
12
- // Kind-separated breakdown
12
+ // L7 dialect: counts speak the compactUsage TUI form (the `↑2.8k ↓3.7k`
13
+ // pattern from live-run-sidebar.ts), NOT the `unread=2` wire format. ↓ =
14
+ // received (inbox), ↑ = sent (outbox pending); ⚠ marks attention (the same
15
+ // pane-local warning glyph the steering line below already uses).
16
+ const lines: string[] = [`Mailbox pane: ↓${unread} unread · ↑${outgoing} pending · ⚠${attention} attention${approx}`];
17
+ // Kind-separated breakdown — every kind here is an inbox (received) count.
13
18
  const kindParts: string[] = [];
14
19
  const steer = mailbox.steerUnread ?? 0;
15
20
  const followUp = mailbox.followUpUnread ?? 0;
16
21
  const response = mailbox.responseUnread ?? 0;
17
22
  const message = mailbox.messageUnread ?? 0;
18
- if (steer > 0) kindParts.push(`steer=${steer}`);
19
- if (followUp > 0) kindParts.push(`follow-up=${followUp}`);
20
- if (response > 0) kindParts.push(`response=${response}`);
21
- if (message > 0) kindParts.push(`message=${message}`);
23
+ if (steer > 0) kindParts.push(`↓${steer} steer`);
24
+ if (followUp > 0) kindParts.push(`↓${followUp} follow-up`);
25
+ if (response > 0) kindParts.push(`↓${response} response`);
26
+ if (message > 0) kindParts.push(`↓${message} message`);
22
27
  if (kindParts.length > 0) {
23
28
  lines.push(` Breakdown: ${kindParts.join(" · ")}`);
24
29
  if (steer > 0) {
25
30
  lines.push(" ⚠ Urgent: steering messages require immediate attention.");
26
31
  }
27
32
  if (followUp > 0) {
28
- lines.push(` 📋 ${followUp} follow-up(s) pending review.`);
33
+ lines.push(` 📋 ${formatCount(followUp, "follow-up")} pending review.`);
29
34
  }
30
35
  }
31
36
  if (attention > 0) {
@@ -17,6 +17,7 @@
17
17
  import { isPlanApprovalPending } from "../../runtime/plan-approval.ts";
18
18
  import { deriveItemProgress } from "../../state/stores/plan-store.ts";
19
19
  import type { PlanItemStatus, TeamTaskState } from "../../state/types.ts";
20
+ import { formatCount } from "../format-helpers.ts";
20
21
  import { ACTIVE, formatHint, statusIcon } from "../rail.ts";
21
22
 
22
23
  /**
@@ -99,7 +100,9 @@ export function planRevisionDiff(snapshot: RunUiSnapshot): string[] {
99
100
  const beforeTasks = before.taskIds?.length ?? 0;
100
101
  const currentTasks = item.taskIds?.length ?? 0;
101
102
  if (before.status !== item.status || beforeTasks !== currentTasks) {
102
- lines.push(` ~ ${item.id} ${item.title ?? "?"} [${before.status}→${item.status} · ${beforeTasks}→${currentTasks} tasks]`);
103
+ lines.push(
104
+ ` ~ ${item.id} ${item.title ?? "?"} [${before.status}→${item.status} · ${beforeTasks}→${formatCount(currentTasks, "task")}]`,
105
+ );
103
106
  }
104
107
  prevItems.delete(item.id);
105
108
  }
@@ -128,7 +131,7 @@ export function renderPlanPane(snapshot: RunUiSnapshot, options: PlanPaneOptions
128
131
  const itemById = new Map(items.map((i) => [i.id, i]));
129
132
 
130
133
  const pending = isPlanApprovalPending(snapshot.manifest);
131
- const header = `Plan pane: ${current.title ?? "?"} @v${current.version} (${phases.length} phases · ${items.length} items)`;
134
+ const header = `Plan pane: ${current.title ?? "?"} @v${current.version} (${formatCount(phases.length, "phase")} · ${formatCount(items.length, "item")})`;
132
135
  // Hint join format is the rail one (` · `, close/deny last). NOT routed
133
136
  // through `formatHint` (yet): its `keyToken` upper-cases a bare letter
134
137
  // (`n` → `N`) and the locked pane test
@@ -8,8 +8,14 @@ export function renderProgressPane(snapshot: RunUiSnapshot | undefined): string[
8
8
  if (!snapshot) return ["Progress pane: snapshot unavailable"];
9
9
  const progress = snapshot.progress;
10
10
  const groupJoins = snapshot.groupJoins ?? [];
11
+ // L7 judgment: the header and group-join lines speak the count-first tally
12
+ // dialect (`1 running · 0 queued`, `req-123 · acknowledged`), not the
13
+ // `running=1`/`ack=acknowledged` wire format. The ONE deliberate
14
+ // key=value survivor is `reason=` below: the cancellation reason is a
15
+ // machine code (`leader_interrupted`) — diagnostic data, not prose, and
16
+ // the shape (not the wording) is what the operator greps for.
11
17
  const groupJoinLines = groupJoins.length
12
- ? groupJoins.map((item) => `group join ${item.partial ? "partial" : "completed"}: ${item.requestId} ack=${item.ack}`)
18
+ ? groupJoins.map((item) => `group join ${item.partial ? "partial" : "completed"}: ${item.requestId} · ${item.ack}`)
13
19
  : ["group joins: none"];
14
20
  const cancellationLine = snapshot.cancellationReason ? [`cancelled: reason=${snapshot.cancellationReason}`] : [];
15
21
  const runProgress = computePhaseProgress(snapshot.tasks);
@@ -30,7 +36,7 @@ export function renderProgressPane(snapshot: RunUiSnapshot | undefined): string[
30
36
  // uncolored by design). One line while the run is parked on approval.
31
37
  const planBanner = isPlanApprovalPending(snapshot.manifest) ? [`⚠ plan approval pending — ${PLAN_APPROVAL_HINT}`] : [];
32
38
  return [
33
- `Progress pane: ${progress.completed}/${progress.total} completed · running=${progress.running} queued=${progress.queued} failed=${progress.failed}`,
39
+ `Progress pane: ${progress.completed}/${progress.total} completed · ${progress.running} running · ${progress.queued} queued · ${progress.failed} failed`,
34
40
  ...planBanner,
35
41
  ...dwfPhaseLines,
36
42
  ...phaseHeader,
@@ -28,7 +28,7 @@
28
28
  import { matchesKey } from "@earendil-works/pi-tui";
29
29
  import type { LiveAgentHandle } from "../runtime/live-session/live-agent-manager.ts";
30
30
  import { truncate } from "../utils/visual.ts";
31
- import { formatCount } from "./format-helpers.ts";
31
+ import { formatCount, formatDuration } from "./format-helpers.ts";
32
32
  import { computeLiveDurationMs } from "./live-duration.ts";
33
33
  import { canopyLine, formatHint, gaugeBar, RAIL, railLeaders, railLine, statusSlot } from "./rail.ts";
34
34
  import { spinnerFrame } from "./spinner.ts";
@@ -120,7 +120,9 @@ export class LiveConversationOverlay {
120
120
 
121
121
  private refreshSummary(): void {
122
122
  const act = this.handle.activity;
123
- const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${formatCount(act.turnCount ?? 0, "turn")} · ${formatCount(act.toolUses ?? 0, "tool")} · ${(computeLiveDurationMs(act, this.nowMs()) / 1000).toFixed(1)}s]`;
123
+ // L9: durations speak formatDuration (`5m44s`), never raw
124
+ // `(ms/1000).toFixed(1)s` seconds (`344.0s`).
125
+ const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${formatCount(act.turnCount ?? 0, "turn")} · ${formatCount(act.toolUses ?? 0, "tool")} · ${formatDuration(computeLiveDurationMs(act, this.nowMs()))}]`;
124
126
  const lastLine = this.cachedLines[this.cachedLines.length - 1];
125
127
  if (lastLine?.startsWith(LiveConversationOverlay.SUMMARY_PREFIX)) {
126
128
  this.cachedLines[this.cachedLines.length - 1] = summary;
@@ -270,7 +272,7 @@ export class LiveConversationOverlay {
270
272
  if (act.maxTurns != null) parts.push(`turn ${act.turnCount ?? 0}/${act.maxTurns}`);
271
273
  else if ((act.turnCount ?? 0) > 0) parts.push(`turn ${act.turnCount}`);
272
274
  if ((act.toolUses ?? 0) > 0) parts.push(formatCount(act.toolUses ?? 0, "tool"));
273
- parts.push(`${(computeLiveDurationMs(act, this.nowMs()) / 1000).toFixed(1)}s`);
275
+ parts.push(formatDuration(computeLiveDurationMs(act, this.nowMs())));
274
276
  try {
275
277
  const ctxPct = this.handle.session.getSessionStats?.()?.contextUsage?.percent;
276
278
  if (ctxPct != null) {
@@ -49,7 +49,8 @@ export function computeLiveDurationMs(activity: LiveActivity, nowMs: number = Da
49
49
  return Number.isFinite(ms) && ms >= 0 ? ms : 0;
50
50
  }
51
51
 
52
- /** Format a live duration in seconds, e.g. `12.3s`. Returns `0.0s` for 0. */
53
- export function formatLiveDuration(activity: LiveActivity, nowMs: number = Date.now()): string {
54
- return `${(computeLiveDurationMs(activity, nowMs) / 1000).toFixed(1)}s`;
55
- }
52
+ // L9 note: the `formatLiveDuration` export was deleted (2026-10-07). It had
53
+ // zero production importers — its raw-seconds `(ms/1000).toFixed(1)s` output
54
+ // was exactly the wire format the dialect sweep retires; every live surface
55
+ // now formats via `formatDuration` (format-helpers.ts) over
56
+ // `computeLiveDurationMs`.