pi-crew 0.11.8 → 0.11.9

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.
@@ -12,9 +12,12 @@ import { formatCount, teamWorkflowLabel } from "./format-helpers.ts";
12
12
  import { goalFlagSuffix } from "./goal-flag.ts";
13
13
  import { DASHBOARD_KEYS } from "./keybinding-map.ts";
14
14
  import { ACTIVE, canopyLine, formatHint, RAIL, type RailSlot, railLine, sectionLine, shortId, statusSlot } from "./rail.ts";
15
+ // L5: the EXTENDED cache interface — the base type in snapshot-types.ts does
16
+ // not declare the paint-path accessor (readForRender) or scheduleRefresh.
17
+ import type { RunSnapshotCache } from "./run-snapshot-cache.ts";
15
18
  import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts";
16
19
  import { registerOverlayScheduler } from "./shared-overlay-scheduler.ts";
17
- import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
20
+ import type { RunUiSnapshot } from "./snapshot-types.ts";
18
21
  import { spinnerBucket, spinnerFrame } from "./spinner.ts";
19
22
  import { colorizeStatusGlyphs, iconForStatus } from "./status-colors.ts";
20
23
  import type { CrewTheme } from "./theme-adapter.ts";
@@ -79,6 +82,9 @@ export class LiveRunSidebar {
79
82
  private cachedWidth = 0;
80
83
  private cachedSignature = "";
81
84
  private autoCloseTimeout?: NodeJS.Timeout;
85
+ /** L4 (real-test 2026-10-07): FIXED close deadline (epoch ms), captured
86
+ * ONCE on first eligibility — re-renders must never slide it. */
87
+ private autoCloseDeadline?: number;
82
88
  private hasAutoClosed = false;
83
89
 
84
90
  constructor(input: {
@@ -157,10 +163,7 @@ export class LiveRunSidebar {
157
163
  // M-10 fix (code-review 2026-06-23): clear the auto-close timer so a
158
164
  // disposed sidebar (not closed via the normal path) doesn't fire this.done()
159
165
  // on a disposed component.
160
- if (this.autoCloseTimeout) {
161
- clearTimeout(this.autoCloseTimeout);
162
- this.autoCloseTimeout = undefined;
163
- }
166
+ this.clearAutoClose();
164
167
  this.unsubscribeTheme();
165
168
  this.schedulerHandle.dispose();
166
169
  }
@@ -180,8 +183,15 @@ export class LiveRunSidebar {
180
183
  let rawAgents: ReturnType<typeof readCrewAgents>;
181
184
  let snapshot: RunUiSnapshot | undefined;
182
185
  if (this.snapshotCache) {
186
+ // L5 (real-test 2026-10-07): the paint path reads the cache ONLY.
187
+ // refreshIfStale rebuilt synchronously between paints (multiple stats +
188
+ // manifest/tasks parse + sha256), stuttering frame pacing. readForRender
189
+ // returns the cached snapshot and schedules the COALESCED ASYNC refresh
190
+ // when the entry's TTL has lapsed; a missing entry schedules nothing, so
191
+ // kick one here or the pre-load frame below would never fill.
183
192
  try {
184
- snapshot = this.snapshotCache.refreshIfStale(this.runId);
193
+ snapshot = this.snapshotCache.readForRender(this.runId);
194
+ if (!snapshot) this.snapshotCache.scheduleRefresh(this.runId);
185
195
  } catch {
186
196
  snapshot = undefined;
187
197
  }
@@ -331,25 +341,29 @@ export class LiveRunSidebar {
331
341
  }
332
342
  // F-6: compute the auto-close countdown BEFORE the cap so the countdown
333
343
  // renders inside the frame rather than below it.
334
- // Auto-close logic: if run is terminal and no active agents, close after delay
344
+ // Auto-close logic: if run is terminal and no active agents, close after delay.
345
+ // L4 (real-test 2026-10-07): the deadline is captured ONCE on FIRST
346
+ // eligibility and never re-armed while eligibility persists. The old code
347
+ // did clearTimeout+setTimeout(full delay) on every fresh render, so any
348
+ // trailing event < autoCloseMs apart slid the deadline forever and the
349
+ // countdown froze while the sidebar stayed open indefinitely.
335
350
  const isTerminal = ["completed", "failed", "cancelled", "blocked"].includes(run.status);
336
351
  const hasActiveAgents = agents.some((a) => a.status === "running");
337
352
  if (isTerminal && !hasActiveAgents && !this.hasAutoClosed) {
338
353
  const autoCloseMs = this.config?.autoCloseDashboardMs ?? 3000;
339
354
  if (autoCloseMs > 0) {
340
- if (this.autoCloseTimeout) clearTimeout(this.autoCloseTimeout);
341
- this.autoCloseTimeout = setTimeout(() => {
342
- this.hasAutoClosed = true;
343
- this.done(undefined);
344
- }, autoCloseMs);
345
- this.autoCloseTimeout?.unref();
346
- lines.push(this.bodyRow(this.theme.fg("dim", `auto-close in ${Math.round(autoCloseMs / 1000)}s…`), budget));
355
+ if (this.autoCloseDeadline === undefined) {
356
+ this.autoCloseDeadline = Date.now() + autoCloseMs;
357
+ this.armAutoClose(this.autoCloseDeadline);
358
+ }
359
+ const remainingMs = Math.max(0, this.autoCloseDeadline - Date.now());
360
+ lines.push(this.bodyRow(this.theme.fg("dim", `auto-close in ${Math.ceil(remainingMs / 1000)}s…`), budget));
347
361
  }
348
- }
349
- // Clear timeout if conditions change
350
- else if (this.autoCloseTimeout) {
351
- clearTimeout(this.autoCloseTimeout);
352
- this.autoCloseTimeout = undefined;
362
+ } else {
363
+ // Off the terminal window (run resumed, active agents (re)appeared) or
364
+ // already closed: cancel AND forget the deadline — the NEXT eligibility
365
+ // captures a fresh one. hasAutoClosed keeps done() from re-arming.
366
+ this.clearAutoClose();
353
367
  }
354
368
  this.cachedLines = renderLines(this.renderFrame(lines, budget, slot), w);
355
369
  this.cachedSignature = signature;
@@ -363,13 +377,69 @@ export class LiveRunSidebar {
363
377
  return railLine(RAIL.body, "border", content, this.theme, budget);
364
378
  }
365
379
 
366
- /** Append the `┗ <hint>` cap and colorize the glyphs (F-1 / V-3). */
380
+ /** L4: arm the close callback for a FIXED deadline. Re-renders never touch
381
+ * it, so the close lands at firstEligibility + autoCloseMs regardless of
382
+ * how many trailing events repaint the frame in between. */
383
+ private armAutoClose(deadline: number): void {
384
+ if (this.autoCloseTimeout) clearTimeout(this.autoCloseTimeout);
385
+ this.autoCloseTimeout = setTimeout(
386
+ () => {
387
+ this.hasAutoClosed = true;
388
+ this.done(undefined);
389
+ },
390
+ Math.max(0, deadline - Date.now()),
391
+ );
392
+ this.autoCloseTimeout?.unref();
393
+ }
394
+
395
+ /** L4: cancel the fixed deadline — only a NEW eligibility re-arms it. */
396
+ private clearAutoClose(): void {
397
+ if (this.autoCloseTimeout) {
398
+ clearTimeout(this.autoCloseTimeout);
399
+ this.autoCloseTimeout = undefined;
400
+ }
401
+ this.autoCloseDeadline = undefined;
402
+ }
403
+
404
+ /** Append the `┗ <hint>` cap, lock the frame to ONE stable height (L6)
405
+ * and colorize the glyphs (F-1 / V-3). */
367
406
  private renderFrame(lines: string[], budget: number, slot: RailSlot): string[] {
368
407
  const hint = this.theme.fg("dim", formatHint(SIDEBAR_HINT));
369
408
  const framed = [...lines, railLine(RAIL.close, slot, hint, this.theme, budget)];
409
+ this.lockHeight(framed, budget);
370
410
  return framed.map((entry) => this.colorLine(entry));
371
411
  }
372
412
 
413
+ /**
414
+ * L6 (real-test 2026-10-07): pad with blank rail rows (cap stays LAST) or
415
+ * slice (cap preserved) so EVERY frame — the 3-line pre-load frame and the
416
+ * 20+ line loaded frame — is exactly targetHeight() lines. The host
417
+ * positions overlays by the returned line count; a frame that flips
418
+ * between heights moves the anchor row and the differential renderer
419
+ * leaves a ghost footprint below the sidebar. Same contract as
420
+ * run-dashboard's targetHeight() clamp.
421
+ */
422
+ private lockHeight(lines: string[], budget: number): void {
423
+ const target = this.targetHeight();
424
+ if (lines.length < target) {
425
+ const blankRow = railLine(RAIL.body, "border", "", this.theme, budget);
426
+ const bottom = lines.pop();
427
+ while (lines.length < target - 1) lines.push(blankRow);
428
+ if (bottom !== undefined) lines.push(bottom);
429
+ } else if (lines.length > target) {
430
+ const bottom = lines[lines.length - 1];
431
+ lines.length = target - 1;
432
+ lines.push(bottom);
433
+ }
434
+ }
435
+
436
+ /** L6: stable overlay height — clamped like run-dashboard's targetHeight()
437
+ * but scaled for the sidebar's single column (12–30 instead of 12–36). */
438
+ private targetHeight(): number {
439
+ const rows = Number.isFinite(process.stdout?.rows) ? Number(process.stdout?.rows) : 30;
440
+ return Math.max(12, Math.min(30, rows - 2));
441
+ }
442
+
373
443
  handleInput(data: string): void {
374
444
  if (data === "q" || data === "\u001b") this.done(undefined);
375
445
  }
@@ -23,7 +23,7 @@
23
23
  * from `formatHint` (discard LAST, keys through `keyToken`).
24
24
  */
25
25
 
26
- import { CURSOR_MARKER } from "@earendil-works/pi-tui";
26
+ import * as piTui from "@earendil-works/pi-tui";
27
27
  import type { MailboxDirection } from "../../state/coordination/mailbox.ts";
28
28
  import { pad, sanitizeLine, truncate } from "../../utils/visual.ts";
29
29
  import { overlayActionForKey } from "../keybinding-map.ts";
@@ -32,6 +32,22 @@ import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
32
32
  import { ConfirmOverlay } from "./confirm-overlay.ts";
33
33
  import { renderComposePreview } from "./mailbox-compose-preview.ts";
34
34
 
35
+ /** Resolve pi-tui's zero-width IME cursor anchor defensively (L8, policy
36
+ * W4/G22) — same typeof-guard shape as hyperlink() at widget-renderer.ts:
37
+ * 118-128 and the sibling resolver in settings-overlay.ts. The peer range is
38
+ * `*`, so a host running a pi-tui build without the export must still render
39
+ * instead of failing the named import at link time. Exported for the
40
+ * fallback pin in test/unit/ui/settings-overlay-cursor-marker.test.ts. */
41
+ export function resolveCursorMarker(mod: unknown): string {
42
+ const candidate = (mod as { CURSOR_MARKER?: unknown }).CURSOR_MARKER;
43
+ return typeof candidate === "string" ? candidate : "";
44
+ }
45
+
46
+ /** "" on hosts whose pi-tui predates CURSOR_MARKER — the active field then
47
+ * renders without the IME anchor (marker is zero-width, stripped before
48
+ * painting; pure enhancement, never a layout dependency). */
49
+ const CURSOR_MARKER = resolveCursorMarker(piTui);
50
+
35
51
  export interface MailboxComposePayload {
36
52
  from: string;
37
53
  to: string;
@@ -19,6 +19,7 @@ import type { MailboxDirection, MailboxMessage } from "../state/coordination/mai
19
19
  import { appendEventBuffered } from "../state/event-log/event-log.ts";
20
20
  import { loadRunManifestById, saveRunTasks } from "../state/stores/state-store.ts";
21
21
  import { logInternalError } from "../utils/internal-error.ts";
22
+ import { formatCount } from "./format-helpers.ts";
22
23
 
23
24
  export interface RunActionResult {
24
25
  ok: boolean;
@@ -107,7 +108,7 @@ export async function dispatchMailboxAckAll(ctx: ExtensionContext, runId: string
107
108
  }
108
109
  return {
109
110
  ok: true,
110
- message: `Acknowledged ${count} messages.`,
111
+ message: `Acknowledged ${formatCount(count, "message")}.`,
111
112
  data: { count },
112
113
  };
113
114
  }
@@ -28,7 +28,7 @@ import { renderPlanPane } from "./dashboard-panes/plan-pane.ts";
28
28
  import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
29
29
  import { renderScheduleDetails, renderSchedulesPane } from "./dashboard-panes/schedules-pane.ts";
30
30
  import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
31
- import { formatCount, teamWorkflowLabel } from "./format-helpers.ts";
31
+ import { formatCount, formatDuration, formatTokens, teamWorkflowLabel } from "./format-helpers.ts";
32
32
  import { goalFlagSuffix } from "./goal-flag.ts";
33
33
  import { type DashboardKeyAction, dashboardActionForKey } from "./keybinding-map.ts";
34
34
  import { HelpOverlay } from "./overlays/help-overlay.ts";
@@ -228,14 +228,14 @@ function runListWindow(scrollOffset: number, count: number): RunListWindow {
228
228
  return { slots, hasTop, hasBottom };
229
229
  }
230
230
 
231
+ /** L7 (real-test 2026-10-07): agent age in the shared formatDuration dialect
232
+ * (`5m44s`), rendered bare — the retired `age=` prefix was wire format.
233
+ * Sub-second ages read `now` (quieter than `123ms` on a rail row). */
231
234
  function formatAge(iso: string | undefined): string | undefined {
232
235
  if (!iso) return undefined;
233
236
  const ms = Math.max(0, Date.now() - new Date(iso).getTime());
234
237
  if (!Number.isFinite(ms)) return undefined;
235
- if (ms < 1000) return "now";
236
- if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
237
- if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
238
- return `${Math.floor(ms / 3_600_000)}h`;
238
+ return ms < 1000 ? "now" : formatDuration(ms);
239
239
  }
240
240
 
241
241
  function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): string[] {
@@ -271,17 +271,21 @@ function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?:
271
271
  }
272
272
  }
273
273
 
274
- function formatTokens(usage: UsageState | undefined): string | undefined {
274
+ /**
275
+ * L7 (real-test 2026-10-07): TUI usage dialect — `↑1.0k ↓250 $0.012` —
276
+ * mirroring the compactUsage pattern in live-run-sidebar.ts (kept local
277
+ * because the sidebar's helper is module-private; do not edit that file).
278
+ * Replaces the retired `tok=…/in=…/out=…/cache=…/$…` wire format, which
279
+ * leaked log-line grammar onto rail rows and buried the numbers behind
280
+ * key=value prefixes. cacheRead/cacheWrite stay hidden (sidebar parity).
281
+ */
282
+ function compactUsage(usage: UsageState | undefined): string | undefined {
275
283
  if (!usage) return undefined;
276
- const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
277
- if (!total) return undefined;
278
- const compact = total >= 1000 ? `${(total / 1000).toFixed(total >= 10_000 ? 0 : 1)}k` : `${total}`;
279
- const parts = [`tok=${compact}`];
280
- if (usage.input) parts.push(`in=${usage.input}`);
281
- if (usage.output) parts.push(`out=${usage.output}`);
282
- if (usage.cacheRead) parts.push(`cache=${usage.cacheRead}`);
283
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
284
- return parts.join("/");
284
+ const parts: string[] = [];
285
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
286
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
287
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(3)}`);
288
+ return parts.length ? parts.join(" ") : undefined;
285
289
  }
286
290
 
287
291
  function snapshotFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): RunUiSnapshot | undefined {
@@ -292,6 +296,38 @@ function snapshotFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): Ru
292
296
  }
293
297
  }
294
298
 
299
+ /**
300
+ * L5 (real-test 2026-10-07): paint-path snapshot read for the frame-local
301
+ * SnapshotResolver in `renderUnsafe`. Uses the cache's `readForRender`
302
+ * accessor when present — cached snapshot ONLY; a lapsed TTL schedules the
303
+ * coalesced async refresh off the paint path; a missing entry schedules one
304
+ * refresh so the pre-load rows fill on a later frame (sidebar precedent,
305
+ * live-run-sidebar.ts render()). Feature-detected because
306
+ * `RunDashboardOptions.snapshotCache` keeps the BASE interface
307
+ * (snapshot-types.ts): production wires the extended cache, while test mocks
308
+ * and legacy callers may implement only get/refreshIfStale — for those a
309
+ * plain get() keeps the render read-only. Keypress handlers and other
310
+ * read-your-writes sites keep `snapshotFor()` (sync refreshIfStale) — Tier 11a.
311
+ */
312
+ function snapshotForRender(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): RunUiSnapshot | undefined {
313
+ const cache = snapshotCache as
314
+ | (RunSnapshotCache & {
315
+ readForRender?: (runId: string) => RunUiSnapshot | undefined;
316
+ scheduleRefresh?: (runId: string) => void;
317
+ })
318
+ | undefined;
319
+ if (typeof cache?.readForRender === "function") {
320
+ try {
321
+ const snapshot = cache.readForRender(run.runId);
322
+ if (!snapshot && typeof cache.scheduleRefresh === "function") cache.scheduleRefresh(run.runId);
323
+ return snapshot;
324
+ } catch {
325
+ return snapshotCache?.get(run.runId);
326
+ }
327
+ }
328
+ return snapshotCache?.get(run.runId);
329
+ }
330
+
295
331
  /**
296
332
  * PERF (2026-08-24, task 20) — per-frame snapshot resolver. The render path
297
333
  * used to resolve each run's snapshot 6-8 times per frame (refreshRuns,
@@ -344,16 +380,16 @@ function usageForAgent(agent: CrewAgentRecord, task: TeamTaskState | undefined):
344
380
  function agentPreviewLine(agent: CrewAgentRecord, task: TeamTaskState | undefined, options: RunDashboardOptions): string {
345
381
  const stats = [
346
382
  agent.progress?.activityState,
347
- options.showModel !== false && modelForAgent(agent, task) ? `model=${modelForAgent(agent, task)}` : undefined,
383
+ options.showModel !== false ? modelForAgent(agent, task) : undefined,
348
384
  options.showTokens !== false
349
- ? (formatTokens(usageForAgent(agent, task)) ??
350
- (agent.progress?.tokens !== undefined ? `tok=${agent.progress.tokens}` : undefined))
385
+ ? (compactUsage(usageForAgent(agent, task)) ??
386
+ (agent.progress?.tokens !== undefined ? `↑${formatTokens(agent.progress.tokens)}` : undefined))
351
387
  : undefined,
352
388
  options.showTools !== false && agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined,
353
389
  options.showTools !== false && agent.toolUses !== undefined ? formatCount(agent.toolUses, "tool") : undefined,
354
- agent.progress?.turns !== undefined ? `${agent.progress.turns} turns` : undefined,
390
+ agent.progress?.turns !== undefined ? formatCount(agent.progress.turns, "turn") : undefined,
355
391
  agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined,
356
- agent.startedAt ? `age=${formatAge(agent.completedAt ?? agent.startedAt)}` : undefined,
392
+ agent.startedAt ? formatAge(agent.completedAt ?? agent.startedAt) : undefined,
357
393
  ].filter((part): part is string => Boolean(part));
358
394
  const recent = agent.progress?.recentOutput?.at(-1);
359
395
  const icon = iconForStatus(agent.status, {
@@ -394,7 +430,8 @@ function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashbo
394
430
  cost: number;
395
431
  },
396
432
  );
397
- const header = formatTokens(totals) ? `Agents: ${formatTokens(totals)}` : "Agents:";
433
+ const usage = compactUsage(totals);
434
+ const header = usage ? `Agents: ${usage}` : "Agents:";
398
435
  return [
399
436
  header,
400
437
  ...agents.slice(0, maxLines).map((agent) => agentPreviewLine(agent, taskForAgent(tasks, agent), options)),
@@ -834,9 +871,12 @@ export class RunDashboard implements DashboardComponent {
834
871
  // PERF (2026-08-24): snapshot resolution stat'd 7-8 files per run 6-8
835
872
  // times per frame. Resolve once per frame into a local map and thread
836
873
  // it through every render-path consumer below.
874
+ // L5 (real-test 2026-10-07): the resolver reads the cache ONLY
875
+ // (snapshotForRender → readForRender) — the sync refreshIfStale rebuild
876
+ // moved off the paint path; refreshes are coalesced + async.
837
877
  const frameSnapshots = new Map<string, RunUiSnapshot | undefined>();
838
878
  const snapshotOnce: SnapshotResolver = (run) => {
839
- if (!frameSnapshots.has(run.runId)) frameSnapshots.set(run.runId, snapshotFor(run, this.options.snapshotCache));
879
+ if (!frameSnapshots.has(run.runId)) frameSnapshots.set(run.runId, snapshotForRender(run, this.options.snapshotCache));
840
880
  return frameSnapshots.get(run.runId);
841
881
  };
842
882
  this.refreshRuns(snapshotOnce);
@@ -27,6 +27,14 @@ export interface RunSnapshotCache extends RunSnapshotCacheBase {
27
27
  preloadAllStale(runIds: string[]): Promise<void>;
28
28
  /** Task 17 (perf/review-2026-08-24): watcher-facing coalesced async refresh. */
29
29
  scheduleRefresh(runId: string): void;
30
+ /**
31
+ * L5 (real-test 2026-10-07): paint-path read — cached snapshot only.
32
+ * NEVER sync-rebuilds: a lapsed TTL schedules the coalesced async refresh
33
+ * (scheduleRefresh pipeline) instead of rebuilding on the paint path, and a
34
+ * missing entry returns undefined without building. See the method doc on
35
+ * the implementation for the staleness/Tier-11a contract.
36
+ */
37
+ readForRender(runId: string): RunUiSnapshot | undefined;
30
38
  /**
31
39
  * F13 (RR-018): true once dispose() has run. Callers that retain the cache
32
40
  * (RegistrationContext.getManifestCache/getRunSnapshotCache) use this to
@@ -1149,6 +1157,30 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
1149
1157
  scheduleRefresh(runId: string): void {
1150
1158
  scheduleCoalescedRefresh(runId);
1151
1159
  },
1160
+ /**
1161
+ * L5 (real-test 2026-10-07): read for the paint path. Returns the
1162
+ * CACHED snapshot (touching LRU/access) and NEVER sync-rebuilds — no
1163
+ * manifest/tasks parse, no agents.json read, no sha256 between paints.
1164
+ * When the entry's TTL has lapsed (the read-time proxy for "stamps may
1165
+ * look stale"), it schedules the EXISTING coalesced async refresh
1166
+ * (scheduleRefresh → 80ms coalesce → preloadStale); the actual stamp
1167
+ * comparison happens inside preloadStale, where a stamp-equal hit just
1168
+ * re-stamps the entry (no rebuild), so a quiet run costs one async stat
1169
+ * round per TTL window, off the paint path. A missing entry returns
1170
+ * undefined without building and without scheduling — callers render
1171
+ * their loading state and may call scheduleRefresh()/preloadStale()
1172
+ * themselves when they want the entry filled.
1173
+ * Tier 11a (read-your-writes) is preserved: refresh()/refreshIfStale()
1174
+ * below keep their sync build paths untouched for callers with a sync
1175
+ * reader immediately after write.
1176
+ */
1177
+ readForRender(runId: string): RunUiSnapshot | undefined {
1178
+ const entry = entries.get(runId);
1179
+ if (!entry) return undefined;
1180
+ const snapshot = touch(runId, entry);
1181
+ if (Date.now() - entry.loadedAtMs >= ttlMs) scheduleCoalescedRefresh(runId);
1182
+ return snapshot;
1183
+ },
1152
1184
  preloadStale,
1153
1185
  preloadAllStale,
1154
1186
  invalidate(runId?: string): void {
@@ -4,13 +4,34 @@
4
4
  * label/value alignment, inline toggle, select submenu, and text input.
5
5
  */
6
6
 
7
- import { CURSOR_MARKER } from "@earendil-works/pi-tui";
7
+ import * as piTui from "@earendil-works/pi-tui";
8
8
  import { truncateToWidth, visibleWidth } from "../utils/visual.ts";
9
9
  import { keyOf, matchesKey } from "./key-utils.ts";
10
10
  import { CURSOR, canopyLine, formatHint, overflowHint, RAIL, railLine } from "./rail.ts";
11
11
  import type { CrewTheme } from "./theme-adapter.ts";
12
12
  import { discoverPiThemes, getActivePiTheme } from "./theme-discovery.ts";
13
13
 
14
+ // ---------------------------------------------------------------------------
15
+ // pi-tui CURSOR_MARKER — defensive resolve (L8, policy W4/G22)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Resolve pi-tui's zero-width IME cursor anchor (APC `ESC _ pi:c BEL`) from
19
+ * a pi-tui namespace object. Modeled on the hyperlink() typeof-guard at
20
+ * widget/widget-renderer.ts:118-128: the peer range is `*`, so a host
21
+ * running a pi-tui build without the export must still render instead of
22
+ * failing the named import at link time. Non-string exports are treated as
23
+ * missing (contract drift, not a marker). Exported for the fallback pin in
24
+ * test/unit/ui/settings-overlay-cursor-marker.test.ts. */
25
+ export function resolveCursorMarker(mod: unknown): string {
26
+ const candidate = (mod as { CURSOR_MARKER?: unknown }).CURSOR_MARKER;
27
+ return typeof candidate === "string" ? candidate : "";
28
+ }
29
+
30
+ /** "" on hosts whose pi-tui predates CURSOR_MARKER — text-input rows then
31
+ * render without the IME anchor (the marker is an enhancement, never a
32
+ * layout dependency: zero-width, stripped by visibleWidth). */
33
+ const CURSOR_MARKER = resolveCursorMarker(piTui);
34
+
14
35
  // ---------------------------------------------------------------------------
15
36
  // Types
16
37
  // ---------------------------------------------------------------------------
@@ -378,7 +399,10 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
378
399
  "ui.showModel": true,
379
400
  "ui.showTokens": true,
380
401
  "ui.showTools": true,
381
- "ui.dashboardPlacement": "center",
402
+ // L3 (2026-10-07): mirrors DEFAULT_UI.dashboardPlacement = "right"
403
+ // (defaults.ts) — G17: duplicated EFFECTIVE_DEFAULTS maps must track the
404
+ // canonical default or the overlay shows a stale value to unset keys.
405
+ "ui.dashboardPlacement": "right",
382
406
  "ui.dashboardWidth": 72,
383
407
  "ui.autoOpenDashboard": false,
384
408
  // G17 sync (2026-09-10 review): canonical DEFAULT_UI.widgetPlacement =
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
10
- import { formatDuration, formatTokens, truncLine } from "../format-helpers.ts";
10
+ import { formatCount, formatDuration, formatTokens, truncLine } from "../format-helpers.ts";
11
11
  import { spinnerClockNow } from "../spinner.ts";
12
12
  import type { CrewTheme } from "../theme-adapter.ts";
13
13
 
@@ -100,7 +100,7 @@ function briefTeam(result: { content?: unknown[] }, theme: CrewTheme): string {
100
100
  const total = records.length;
101
101
  const duration = computeTotalDuration(records);
102
102
  const tokens = computeTotalTokens(records);
103
- return `${icon} ${completed}/${total} tasks · ${formatDuration(duration)} · ${formatTokens(tokens)} tok`;
103
+ return `${icon} ${completed}/${formatCount(total, "task")} · ${formatDuration(duration)} · ${formatTokens(tokens)} tok`;
104
104
  }
105
105
 
106
106
  // Fallback: compact status line
@@ -304,7 +304,10 @@ function renderViewerBase(state: ViewerState, width: number, lines: string[], ti
304
304
  // and the full-wrap branch keep the exact count).
305
305
  const totalLines = tailWindow ? skippedCount + visible.length : visualLines.length;
306
306
  const totalLabel = tailWindow && skippedCount > 0 ? `≥ ${totalLines}` : `${totalLines}`;
307
- const statusLine = `${totalLabel} lines · ${totalLines ? Math.round(((state.scroll + visible.length) / totalLines) * 100) : 100}% · auto-scroll ${state.autoScroll ? "on" : "off"}`;
307
+ // L-plural (2026-10-08): "1 lines" grammar slip live-caught in the transcript
308
+ // viewer footer — pluralize only from 2 (and keep "≥" prefix intact).
309
+ const linesWord = totalLines === 1 ? "line" : "lines";
310
+ const statusLine = `${totalLabel} ${linesWord} · ${totalLines ? Math.round(((state.scroll + visible.length) / totalLines) * 100) : 100}% · auto-scroll ${state.autoScroll ? "on" : "off"}`;
308
311
  // RAIL frame (design system §2.E): `┏ NAME ▸ SUBJECT` canopy, `┃` body rows,
309
312
  // `┗ <outcome/hint>` close. The rounded `╭─╮│╰─╯` box is retired. This
310
313
  // viewer is a standalone overlay that paints its own frame, so the rail
@@ -322,13 +322,21 @@ class CrewWidgetComponent implements WidgetComponent {
322
322
  // Task-list variant: the run's plan above the editor (task-list.ts).
323
323
  // No panel state, no spinner glyph — the list changes only on task
324
324
  // transitions, which the run signature already covers.
325
+ // L-plan-flicker (2026-10-07): `sigBase` carries `:spin=<bucket>` while
326
+ // any worker runs — keying this variant on it rebuilt the whole card
327
+ // every 160ms spinner bucket. Strip the animation fragment: the tasks
328
+ // card paints no animated glyph (task-list.ts running row is a static ▶),
329
+ // so a bucket change can never change its output. Live effect measured
330
+ // in the owner session: content diffs dropped from 544/599 samples at
331
+ // 0.2s to state-transition-rate only.
325
332
  if (this.variant === "tasks") {
326
- if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
333
+ const tasksSignature = `${sigBase.replace(/:spin=\d+$/, "")}:${this.model.notificationCount ?? 0}`;
334
+ if (this.cacheSignature !== tasksSignature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
327
335
  this.cachedBaseLines = buildTaskListLines(runs, width, this.theme);
328
336
  this.cachedLines = this.colorize(this.cachedBaseLines, width);
329
337
  this.cachedWidth = width;
330
338
  this.cachedTheme = this.theme;
331
- this.cacheSignature = signature;
339
+ this.cacheSignature = tasksSignature;
332
340
  this.lastTruncateWidth = -1;
333
341
  }
334
342
  if (runs.length === 0) {
@@ -29,7 +29,6 @@ import type { TeamTaskState } from "../../state/types.ts";
29
29
  import { visibleWidth } from "../../utils/visual.ts";
30
30
  import { formatCount } from "../format-helpers.ts";
31
31
  import { canopyLine, gaugeBar, overflowHint, RAIL, railLine, statusSlot } from "../rail.ts";
32
- import { spinnerFrame } from "../spinner.ts";
33
32
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
34
33
  import { shortRunLabel } from "./widget-model.ts";
35
34
  import type { WidgetRun } from "./widget-types.ts";
@@ -62,12 +61,21 @@ function taskTitle(task: TeamTaskState): string {
62
61
  return (task.displayName ?? task.title ?? "").replace(/\s+/g, " ").trim();
63
62
  }
64
63
 
65
- /** pi-tasks status glyph per row: ✔ done (struck through), spinner for the
66
- * actively executing task, ◼ parked/needs input, ◻ queued, ✗ dead. */
64
+ /** pi-tasks status glyph per row: ✔ done (struck through), ▶ the actively
65
+ * executing task, ◼ parked/needs input, ◻ queued, ✗ dead.
66
+ *
67
+ * L-plan-flicker (2026-10-07 live probe, 600 frames @0.2s in the owner
68
+ * session): the running row previously painted `spinnerFrame()` — a braille
69
+ * frame rotating every 160ms — turning the whole card into a ~8Hz repaint
70
+ * above the editor ("nháy nháy liên tục"). The plan card is a LIST: per the
71
+ * variant's own design comment it must change only on task transitions, so
72
+ * the running glyph is STATIC (like ◼/◻) and the elapsed suffix carries
73
+ * liveness at 1s grain. The dock keeps its spinner — one animated row is
74
+ * intentional there. */
67
75
  function taskStatusIcon(task: TeamTaskState): string {
68
76
  switch (task.status) {
69
77
  case "running":
70
- return spinnerFrame("crew-task-list");
78
+ return "▶";
71
79
  case "queued":
72
80
  return "◻";
73
81
  case "waiting":
@@ -55,10 +55,11 @@ export function activeWidgetRuns(
55
55
  }
56
56
  }
57
57
 
58
- let runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
59
- if (workspaceId) {
60
- runs = runs.filter((run) => !run.ownerSessionId || run.ownerSessionId === workspaceId);
61
- }
58
+ // L1 (ui-instability review 2026-10-07): the dock renders in EVERY session, so
59
+ // NO ownerSessionId display filter here — bystander terminals must see runs
60
+ // owned by other sessions (live evidence: 308 frames / 0 dock rendered).
61
+ // workspaceId stays in scope: crash recovery below is still session-scoped.
62
+ const runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
62
63
 
63
64
  return runs
64
65
  .map((run) => {