pi-crew 0.11.7 → 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.
Files changed (66) hide show
  1. package/.githooks/pre-commit +22 -0
  2. package/.githooks/pre-push +56 -0
  3. package/CHANGELOG.md +196 -1
  4. package/dist/index.mjs +533 -515
  5. package/docs/publishing.md +29 -0
  6. package/docs/usage.md +1 -1
  7. package/index.ts +3 -3
  8. package/install.mjs +4 -1
  9. package/package.json +8 -5
  10. package/scripts/install-hooks.mjs +32 -0
  11. package/skills/real-test-pi-crew/SKILL.md +57 -18
  12. package/src/config/config-validation.ts +8 -1
  13. package/src/config/defaults.ts +8 -1
  14. package/src/config/env-vars.ts +16 -1
  15. package/src/config/types.ts +28 -4
  16. package/src/extension/command-completions.ts +44 -22
  17. package/src/extension/registration/foreground-run-controller.ts +19 -10
  18. package/src/extension/registration/lifecycle-handlers.ts +6 -491
  19. package/src/extension/registration/lifecycle.ts +7 -90
  20. package/src/extension/registration/render-loop.ts +532 -0
  21. package/src/extension/team-tool/doctor.ts +6 -0
  22. package/src/extension/team-tool/handle-settings.ts +4 -1
  23. package/src/runtime/child-pi/child-pi-spawn.ts +7 -0
  24. package/src/runtime/child-pi/child-pi.ts +37 -2
  25. package/src/runtime/child-pi/pi-version.ts +187 -0
  26. package/src/runtime/classifier/classifier-service.ts +298 -0
  27. package/src/runtime/dispatch-batch.ts +4 -0
  28. package/src/runtime/live-session/live-session-runtime.ts +31 -0
  29. package/src/runtime/model/pi-args.ts +62 -3
  30. package/src/runtime/process/zombie-scanner.ts +168 -7
  31. package/src/runtime/rpc/README.md +64 -25
  32. package/src/runtime/rpc/rpc-worker.ts +146 -26
  33. package/src/runtime/run-worker.ts +89 -15
  34. package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
  35. package/src/runtime/skill-instructions.ts +66 -1
  36. package/src/runtime/surface/resolve-surface.ts +34 -19
  37. package/src/runtime/task-runner/child-executor.ts +132 -0
  38. package/src/runtime/task-runner/pre-execution.ts +8 -0
  39. package/src/runtime/task-runner.ts +6 -0
  40. package/src/schema/config-schema.ts +20 -8
  41. package/src/schema/team-tool-schema.ts +4 -2
  42. package/src/state/contracts.ts +5 -0
  43. package/src/ui/dashboard-panes/agents-pane.ts +3 -2
  44. package/src/ui/dashboard-panes/health-pane.ts +6 -1
  45. package/src/ui/dashboard-panes/mailbox-pane.ts +12 -7
  46. package/src/ui/dashboard-panes/plan-pane.ts +5 -2
  47. package/src/ui/dashboard-panes/progress-pane.ts +8 -2
  48. package/src/ui/format-helpers.ts +11 -0
  49. package/src/ui/live-conversation-overlay.ts +5 -3
  50. package/src/ui/live-duration.ts +5 -4
  51. package/src/ui/live-run-sidebar.ts +90 -20
  52. package/src/ui/overlays/mailbox-compose-overlay.ts +31 -1
  53. package/src/ui/rail.ts +14 -1
  54. package/src/ui/run-action-dispatcher.ts +2 -1
  55. package/src/ui/run-dashboard.ts +62 -22
  56. package/src/ui/run-snapshot-cache.ts +32 -0
  57. package/src/ui/settings-overlay.ts +36 -3
  58. package/src/ui/tool-renderers/brief-mode.ts +2 -2
  59. package/src/ui/transcript-viewer.ts +4 -1
  60. package/src/ui/widget/index.ts +10 -2
  61. package/src/ui/widget/task-list.ts +22 -7
  62. package/src/ui/widget/widget-model.ts +5 -4
  63. package/src/ui/widget/widget-renderer.ts +212 -37
  64. package/src/utils/relative-time.ts +11 -3
  65. package/src/utils/socket-path.ts +48 -8
  66. package/src/utils/visual.ts +32 -2
@@ -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,
@@ -12,6 +12,17 @@ export function formatTokens(n: number): string {
12
12
  return `${(n / 1_000_000).toFixed(1)}M`;
13
13
  }
14
14
 
15
+ /**
16
+ * Canonical generic duration formatter (QW#5 collision resolution):
17
+ * `45ms` / `12.3s` / `3m5s` / `75m` — minutes are unbounded (no hours
18
+ * bucket) and there is no invalid-input guard. Two same-stemmed helpers are
19
+ * DELIBERATELY separate dialects and must not be merged into this one
20
+ * (their output formats are design-pinned):
21
+ * - `formatTaskElapsed` (ui/widget/task-list.ts) — plan-card elapsed:
22
+ * `2m 49s` / `1h 03m`, empty string on invalid input.
23
+ * - `formatRelativeCompact` (utils/relative-time.ts) — relative-time
24
+ * ladder buckets `84m` / `14h` / `2d14h`.
25
+ */
15
26
  export function formatDuration(ms: number): string {
16
27
  if (ms < 1000) return `${ms}ms`;
17
28
  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
@@ -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`.
@@ -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
  }
@@ -14,11 +14,16 @@
14
14
  * canopy/hint rows are NOT part of the split any more, so the hint keeps its
15
15
  * full width instead of being cropped to 60% of the overlay.
16
16
  *
17
+ * R3-6 (IME): the ACTIVE field emits pi-tui's zero-width `CURSOR_MARKER` at
18
+ * its value end — the host TUI strips it and parks the hardware cursor there
19
+ * so the IME candidate window follows the logical text cursor (CJK input).
20
+ *
17
21
  * Keys/behaviour are unchanged (same `overlay:*` dispatch, same free-text
18
22
  * passthrough, same validation errors, same discard confirmation). Hints come
19
23
  * from `formatHint` (discard LAST, keys through `keyToken`).
20
24
  */
21
25
 
26
+ import * as piTui from "@earendil-works/pi-tui";
22
27
  import type { MailboxDirection } from "../../state/coordination/mailbox.ts";
23
28
  import { pad, sanitizeLine, truncate } from "../../utils/visual.ts";
24
29
  import { overlayActionForKey } from "../keybinding-map.ts";
@@ -27,6 +32,22 @@ import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
27
32
  import { ConfirmOverlay } from "./confirm-overlay.ts";
28
33
  import { renderComposePreview } from "./mailbox-compose-preview.ts";
29
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
+
30
51
  export interface MailboxComposePayload {
31
52
  from: string;
32
53
  to: string;
@@ -117,7 +138,16 @@ export class MailboxComposeOverlay {
117
138
  // (the field is a single-line cell; multi-line content belongs to the
118
139
  // preview column).
119
140
  const value = sanitizeLine(this.fields[field] ?? "");
120
- return `${active ? CURSOR : " "} ${label}: ${truncate(value, Math.max(8, width - label.length - 5))}`;
141
+ // R3-6 (IME): CURSOR_MARKER is pi-tui's zero-width APC sequence — the host
142
+ // TUI finds it, positions the HARDWARE cursor there (IME candidate window
143
+ // anchor for CJK input), and strips it before painting. This overlay's
144
+ // fields append/backspace at end-of-value only, so the logical cursor IS
145
+ // the value end — same pattern as pi-tui's own Input component
146
+ // (marker + fake cursor). The `›` prefix stays: it is the RAIL list-selection
147
+ // glyph, orthogonal to the text cursor. Direction checkbox row gets NO
148
+ // marker — it toggles on space and has no text insertion point.
149
+ const marker = active && !this.confirm ? CURSOR_MARKER : "";
150
+ return `${active ? CURSOR : " "} ${label}: ${truncate(value, Math.max(8, width - label.length - 5))}${marker}`;
121
151
  }
122
152
 
123
153
  private activeName(): FieldName {
package/src/ui/rail.ts CHANGED
@@ -58,7 +58,20 @@ export const RAIL = { open: "┏", section: "┣", body: "┃", close: "┗" } a
58
58
  */
59
59
  export const PI_EXPAND_CHORD = "ctrl+o";
60
60
 
61
- /** Selection cursor (lists) and active/step marker (sections, goals). */
61
+ /** Selection cursor (lists) and active/step marker (sections, goals).
62
+ *
63
+ * R3-6 audit (2026-10-04): this glyph marks LIST SELECTION in dashboards,
64
+ * pickers, settings menus, and compose field rows — a navigation affordance,
65
+ * not a text insertion point. It therefore deliberately does NOT adopt
66
+ * pi-tui's `CURSOR_MARKER` (the zero-width APC sequence that anchors the
67
+ * IME candidate window at a TEXT cursor). Sites that DO own a text cursor —
68
+ * mailbox-compose field values, settings text-input/edit buffers — emit
69
+ * `CURSOR_MARKER` at their buffer end in addition to any visual glyph.
70
+ * Not converted, by design: run-dashboard run rows, settings menu rows,
71
+ * agents-jobs-browser rows, mailbox-detail rows, agent-picker rows,
72
+ * schedules-pane job rows (all list selection); widget dock `❯` (a
73
+ * single-line focus acknowledgement, not an input); mascot `█` frames
74
+ * (animation glyphs). */
62
75
  export const CURSOR = "›";
63
76
  export const ACTIVE = "▸";
64
77
 
@@ -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 {