pi-crew 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +155 -9
  2. package/README.md +161 -1037
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -90644
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +1 -3
  10. package/schema.json +39 -0
  11. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
  12. package/skills/real-test-pi-crew/SKILL.md +371 -34
  13. package/src/agents/agent-config.ts +1 -1
  14. package/src/agents/discover-agents.ts +1 -1
  15. package/src/config/config-validation.ts +15 -2
  16. package/src/config/config.ts +47 -13
  17. package/src/config/defaults.ts +0 -1
  18. package/src/config/env-vars.ts +35 -0
  19. package/src/config/types.ts +19 -5
  20. package/src/errors.ts +2 -2
  21. package/src/extension/async-notifier.ts +23 -0
  22. package/src/extension/crew-vibes/config.ts +0 -21
  23. package/src/extension/crew-vibes/index.ts +0 -2
  24. package/src/extension/crew-vibes/render.ts +1 -50
  25. package/src/extension/help.ts +21 -12
  26. package/src/extension/knowledge-injection.ts +2 -1
  27. package/src/extension/management.ts +8 -3
  28. package/src/extension/notification-sink.ts +17 -0
  29. package/src/extension/register.ts +7 -2
  30. package/src/extension/registration/command-utils.ts +28 -2
  31. package/src/extension/registration/commands/dashboard.ts +11 -1
  32. package/src/extension/registration/commands/manage.ts +36 -19
  33. package/src/extension/registration/commands/run.ts +24 -2
  34. package/src/extension/registration/commands/shared.ts +23 -1
  35. package/src/extension/registration/commands/status.ts +25 -2
  36. package/src/extension/registration/context-builder.ts +8 -2
  37. package/src/extension/registration/health-notify-policy.ts +100 -0
  38. package/src/extension/registration/lazy-configurers.ts +35 -0
  39. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  40. package/src/extension/registration/lifecycle.ts +75 -10
  41. package/src/extension/registration/observability.ts +98 -35
  42. package/src/extension/registration/registration-types.ts +7 -5
  43. package/src/extension/registration/runtime-cleanup.ts +9 -3
  44. package/src/extension/registration/subagent-helpers.ts +38 -0
  45. package/src/extension/registration/subagent-tools.ts +16 -6
  46. package/src/extension/registration/team-tool.ts +10 -3
  47. package/src/extension/registration/terminal-status-wiring.ts +172 -0
  48. package/src/extension/registration/viewers.ts +6 -0
  49. package/src/extension/registration/wire-cross-extension.ts +28 -0
  50. package/src/extension/run-compare.ts +220 -0
  51. package/src/extension/run-export.ts +37 -5
  52. package/src/extension/run-maintenance.ts +155 -5
  53. package/src/extension/team-tool/dispatch/index.ts +3 -2
  54. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  55. package/src/extension/team-tool/goal.ts +4 -1
  56. package/src/extension/team-tool/handle-settings.ts +33 -4
  57. package/src/extension/team-tool/health-monitor.ts +21 -7
  58. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  59. package/src/extension/team-tool/plan.ts +10 -0
  60. package/src/extension/team-tool/routing-hint.ts +63 -0
  61. package/src/extension/team-tool/status.ts +4 -0
  62. package/src/extension/team-tool.ts +52 -6
  63. package/src/extension/webhook-notify.ts +382 -0
  64. package/src/observability/metric-sink.ts +12 -2
  65. package/src/prompt/prompt-runtime.ts +82 -31
  66. package/src/prompt/worker-events-channel.ts +12 -0
  67. package/src/runtime/README.md +1 -1
  68. package/src/runtime/async-runner.ts +87 -1
  69. package/src/runtime/background-runner.ts +313 -234
  70. package/src/runtime/broker/crew-broker.ts +17 -11
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/wait-status-cache.ts +1 -1
  73. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  74. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  75. package/src/runtime/crew-agent-records.ts +337 -45
  76. package/src/runtime/deadletter.ts +43 -1
  77. package/src/runtime/delegate-spawn.ts +5 -1
  78. package/src/runtime/dispatch-batch.ts +72 -5
  79. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  80. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  81. package/src/runtime/model/model-fallback.ts +21 -1
  82. package/src/runtime/model/pi-args.ts +8 -10
  83. package/src/runtime/recovery/crash-recovery.ts +25 -1
  84. package/src/runtime/run-worker.ts +12 -1
  85. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  86. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  87. package/src/runtime/scheduling/scheduler.ts +49 -13
  88. package/src/runtime/scheduling/semaphore.ts +148 -20
  89. package/src/runtime/scratchpad/README.md +1 -1
  90. package/src/runtime/scratchpad/protocol.ts +1 -1
  91. package/src/runtime/settings-store.ts +1 -1
  92. package/src/runtime/skill-instructions.ts +22 -0
  93. package/src/runtime/stale-reconciler.ts +85 -13
  94. package/src/runtime/task-display.ts +1 -1
  95. package/src/runtime/task-runner/pre-execution.ts +26 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  97. package/src/runtime/task-runner.ts +21 -1
  98. package/src/runtime/team-runner.ts +38 -1
  99. package/src/runtime/workspace-lock.ts +4 -1
  100. package/src/schema/config-schema.ts +18 -0
  101. package/src/schema/team-tool-schema.ts +17 -0
  102. package/src/state/atomic-write.ts +53 -0
  103. package/src/state/contracts.ts +109 -0
  104. package/src/state/coordination/locks.ts +191 -33
  105. package/src/state/coordination/mailbox.ts +140 -15
  106. package/src/state/crew-init.ts +87 -12
  107. package/src/state/event-log/cursor.ts +37 -1
  108. package/src/state/event-log/event-log-rotation.ts +72 -7
  109. package/src/state/stores/active-run-registry.ts +13 -1
  110. package/src/state/stores/state-store.ts +112 -22
  111. package/src/state/types.ts +4 -0
  112. package/src/ui/adaptive-card.ts +65 -0
  113. package/src/ui/agents-jobs-browser.ts +70 -64
  114. package/src/ui/card-colors.ts +36 -7
  115. package/src/ui/dashboard-panes/agents-pane.ts +55 -14
  116. package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
  117. package/src/ui/dashboard-panes/health-pane.ts +7 -5
  118. package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
  119. package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
  120. package/src/ui/dashboard-panes/pane-theme.ts +21 -0
  121. package/src/ui/dashboard-panes/plan-pane.ts +63 -30
  122. package/src/ui/dashboard-panes/progress-pane.ts +3 -2
  123. package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
  124. package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
  125. package/src/ui/dwf-phase-display.ts +3 -20
  126. package/src/ui/format-helpers.ts +22 -0
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/crew-editor.ts +13 -3
  129. package/src/ui/inline-panel/index.ts +60 -4
  130. package/src/ui/keybinding-map.ts +251 -35
  131. package/src/ui/live-conversation-overlay.ts +180 -47
  132. package/src/ui/live-run-sidebar.ts +134 -55
  133. package/src/ui/mascot.ts +32 -16
  134. package/src/ui/overlays/agent-picker-overlay.ts +81 -26
  135. package/src/ui/overlays/confirm-overlay.ts +55 -29
  136. package/src/ui/overlays/help-overlay.ts +108 -53
  137. package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
  138. package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
  139. package/src/ui/powerbar-publisher.ts +0 -1
  140. package/src/ui/rail.ts +333 -0
  141. package/src/ui/run-dashboard.ts +193 -79
  142. package/src/ui/run-snapshot-cache.ts +18 -1
  143. package/src/ui/settings-overlay.ts +81 -39
  144. package/src/ui/spinner.ts +26 -2
  145. package/src/ui/terminal-status.ts +7 -1
  146. package/src/ui/theme-adapter.ts +0 -45
  147. package/src/ui/theme-discovery.ts +12 -6
  148. package/src/ui/tool-progress-formatter.ts +128 -9
  149. package/src/ui/tool-renderers/brief-mode.ts +10 -67
  150. package/src/ui/tool-renderers/index.ts +374 -523
  151. package/src/ui/transcript-viewer.ts +30 -12
  152. package/src/ui/widget/index.ts +32 -52
  153. package/src/ui/widget/task-list.ts +64 -32
  154. package/src/ui/widget/widget-formatters.ts +3 -402
  155. package/src/ui/widget/widget-model.ts +28 -7
  156. package/src/ui/widget/widget-renderer.ts +201 -128
  157. package/src/ui/widget/widget-types.ts +0 -2
  158. package/src/utils/incremental-reader.ts +11 -3
  159. package/src/utils/paths.ts +94 -12
  160. package/src/utils/project-markers.ts +40 -0
  161. package/src/utils/visual.ts +0 -4
  162. package/src/worktree/worktree-manager.ts +206 -26
  163. package/workflows/distill.workflow.md +3 -3
  164. package/workflows/fast-fix.workflow.md +1 -1
  165. package/workflows/plan-execute.workflow.md +1 -1
  166. package/workflows/review.workflow.md +1 -1
  167. package/workflows/strict-fast-fix.workflow.md +1 -1
  168. package/docs/migration-v0.4-v0.5.md +0 -208
  169. package/docs/runtime-flow.md +0 -148
  170. package/src/extension/crew-vibes/figures.ts +0 -22
  171. package/src/extension/crew-vibes/font-detect.ts +0 -71
  172. package/src/ui/dynamic-border.ts +0 -35
  173. package/src/ui/loaders.ts +0 -6
  174. package/src/ui/overlay-stack.ts +0 -148
@@ -7,15 +7,17 @@
7
7
  * precedent: a dedicated bottom-anchored overlay, NOT a new dashboard pane —
8
8
  * the pane route would churn the ActivePane union + keybinding parity goldens.
9
9
  *
10
- * Layout (width-aware for terminals 80..250):
10
+ * Layout (width-aware for terminals 80..250) — RAIL frame (M4, 2026-09-16):
11
+ * canopy + rail-prefixed body rows + `┗` cap, the two columns joined by DOT
12
+ * LEADERS (no inner `│` frame column):
11
13
  *
12
- * Agents & Jobs — 2 agents · 1 job
13
- * Live agents (2) │ ▸ adaptive-01-executor
14
- * › ⠋ Explorer · 41 tok/s │ 0/1 tasks · 1 agents
15
- * ✓ Writer │ ⠋ adaptive-01-executor executor
16
- * Scheduled jobs (1) │ reading…
17
- * › ⏰ watch: omo · cron · next 23h … │
18
- * [↑↓] navigate · [Enter] … │
14
+ * ┏ AGENTS ▸ 2 agents · 1 job ············· ↑/↓ move · Enter transcript
15
+ * ┃ Live agents (2) ··············· ▸ adaptive-01-executor
16
+ * ┃ › ⠋ Explorer · 41 tok/s ··············· 0/1 tasks · 1 agents
17
+ * ┃ ✓ Writer ··············· ⠋ adaptive-01-executor
18
+ * ┃ Scheduled jobs (1) ··············· reading…
19
+ * ┃ ⏰ watch: omo · cron · next 23h ······
20
+ * ┗ Q close
19
21
  *
20
22
  * LEFT column — unified list: live agents first (status glyph via
21
23
  * iconForStatus/spinnerFrame, role name, tok/s while running — the SAME
@@ -67,10 +69,11 @@ import { pad, sanitizeLine, truncate, visibleWidth } from "../utils/visual.ts";
67
69
  import { renderAgentsPane } from "./dashboard-panes/agents-pane.ts";
68
70
  import { renderScheduleDetails, schedulesHiddenJobsHintLine } from "./dashboard-panes/schedules-pane.ts";
69
71
  import { computeLiveDurationMs } from "./live-duration.ts";
72
+ import { CURSOR, canopyLine, formatHint, overflowHint, RAIL, railLeaders, railLine } from "./rail.ts";
70
73
  import type { RunUiSnapshot } from "./snapshot-types.ts";
71
74
  import { spinnerFrame } from "./spinner.ts";
72
75
  import { iconForStatus } from "./status-colors.ts";
73
- import type { CrewTheme } from "./theme-adapter.ts";
76
+ import { asCrewTheme, type CrewTheme } from "./theme-adapter.ts";
74
77
  import { activeWidgetRuns } from "./widget/widget-model.ts";
75
78
 
76
79
  // ─── Public data types ─────────────────────────────────────────────────────
@@ -182,6 +185,7 @@ function watchAgentTranscriptScript(): string | undefined {
182
185
  /** tok/s for record-based entries (no live handle in this process). */
183
186
  function recordTokPerSec(record: CrewAgentRecord, nowMs: number): number | undefined {
184
187
  if (record.status !== "running") return undefined;
188
+ if (!record.taskId) return undefined;
185
189
  try {
186
190
  const usage = getTaskUsage(record.taskId);
187
191
  const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheWrite ?? 0);
@@ -228,6 +232,9 @@ function shellQuote(value: string): string {
228
232
 
229
233
  export class AgentsJobsBrowser {
230
234
  private readonly options: AgentsJobsBrowserOptions;
235
+ /** RAIL helpers need a theme; `asCrewTheme(undefined)` is the no-op theme,
236
+ * so a headless/test render stays plain text exactly as before. */
237
+ private readonly theme: CrewTheme;
231
238
  private cachedEntries: AgentsJobsBrowserEntry[] = [];
232
239
  private cachedAt = -Infinity;
233
240
  private hiddenCount = 0;
@@ -243,6 +250,7 @@ export class AgentsJobsBrowser {
243
250
 
244
251
  constructor(options: AgentsJobsBrowserOptions) {
245
252
  this.options = options;
253
+ this.theme = options.theme ?? asCrewTheme(undefined);
246
254
  this.refreshData(true);
247
255
  this.pollTimer = setInterval(() => {
248
256
  if (this.closed) return;
@@ -408,8 +416,8 @@ export class AgentsJobsBrowser {
408
416
  const manifest = this.manifestFor(entry.runId);
409
417
  if (!record || !manifest) {
410
418
  return [
411
- `▸ ${entry.taskId} (${entry.role})`,
412
- ` status: ${entry.status}`,
419
+ `▸ ${entry.taskId ?? "?"} (${entry.role ?? "?"})`,
420
+ ` status: ${entry.status ?? "?"}`,
413
421
  " (agent record unavailable — run manifest not found)",
414
422
  ];
415
423
  }
@@ -626,36 +634,31 @@ export class AgentsJobsBrowser {
626
634
  * exceed `width` (truncate/visibleWidth from utils/visual — the same
627
635
  * width model pi-tui enforces). */
628
636
  render(width?: number): string[] {
629
- // FRAMED PANEL (redesign 2026-09-14, "dễ nhìn"): a full box —
630
- //
631
- // ╭─ Agents & Jobs ── 2 agents · 1 job ─────────────────────╮
632
- // │ Live agents (2) │ ▸ adaptive-01-executor │
633
- // │ › ⠋ Explorer · 41 t/s │ 0/1 tasks · 1 agents │
634
- // ╰─ [↑↓] move · [⏎] detail · [p] pane · [q] close ────────╯
635
- //
636
- // Title + counts embed in the TOP border, key hints in the BOTTOM
637
- // border (the omp EditorTopBorder idiom) so the body is pure content
638
- // and the panel reads as one visual unit over the terminal.
637
+ // RAIL frame (M4, 2026-09-16) — the rounded box is retired. Canopy
638
+ // `┏ AGENTS ▸ 2 agents · 1 job` + hint segment, every body row prefixed
639
+ // with the rail, the two columns joined by DOT LEADERS (railLeaders
640
+ // absorbs the slack; NO inner `│` frame column) and the surface capped
641
+ // with `┗ <hint>`. railLine owns the glyph + space and the pad/truncate
642
+ // to `budget`, so no line can exceed `width`.
639
643
  const w = Math.max(60, width ?? this.options.columns ?? 80);
644
+ const budget = Math.max(0, w - 2);
640
645
  const nowMs = this.nowMs();
641
- // Inner budget: 2 border cols + 4 padding cols + 1 divider col.
642
- const inner = w - 7;
646
+ const inner = budget;
643
647
  const listWidth = Math.max(20, Math.min(MAX_LIST_WIDTH, Math.round(inner * 0.42)));
644
- const detailWidth = Math.max(16, inner - listWidth);
648
+ const detailWidth = Math.max(16, Math.max(0, inner - listWidth - 2));
645
649
  const maxBody = Math.max(MIN_BODY, Math.min(MAX_BODY, (this.options.rows ?? 24) - 6));
646
- const dim = (text: string) => (this.options.theme ? this.options.theme.fg("border", text) : text);
647
- const accent = (text: string) => (this.options.theme ? this.options.theme.fg("accent", text) : text);
648
- const sep = dim("│");
650
+ const dim = (text: string) => this.theme.fg("border", text);
649
651
 
650
652
  const counts = [
651
653
  `${this.countAgents()} agent${this.countAgents() === 1 ? "" : "s"}`,
652
654
  `${this.countJobs()} job${this.countJobs() === 1 ? "" : "s"}`,
653
655
  ];
654
656
  if (this.hiddenCount > 0) counts.push(`${this.hiddenCount} hidden`);
655
- const activeNotice = this.notice && this.notice.until > nowMs ? ` ${this.notice.text} ` : ` ${counts.join(" · ")} `;
656
- const lines: string[] = [this.framedBorderRow("top", accent(` Agents & Jobs `), dim(truncate(activeNotice, w - 20)), w)];
657
+ // A transient notice (when one is live) replaces the counts in the canopy.
658
+ const subject = this.notice && this.notice.until > nowMs ? this.notice.text : counts.join(" · ");
659
+ const lines: string[] = [canopyLine({ word: "AGENTS", subject, right: this.hintRow(), theme: this.theme, budget })];
657
660
  const hiddenHint = schedulesHiddenJobsHintLine(this.hiddenCount);
658
- if (hiddenHint) lines.push(`${dim("│")} ${pad(dim(truncate(hiddenHint, w - 4)), w - 3)}${dim("│")}`);
661
+ if (hiddenHint) lines.push(railLine(RAIL.body, "border", dim(truncate(hiddenHint, budget)), this.theme, budget));
659
662
 
660
663
  const left = this.renderListColumn(listWidth, maxBody, nowMs);
661
664
  const right = this.renderDetailColumn(detailWidth, maxBody);
@@ -663,30 +666,20 @@ export class AgentsJobsBrowser {
663
666
  // as tall as its tallest column, clamped to [5, maxBody].
664
667
  const bodyHeight = Math.max(5, Math.min(maxBody, Math.max(left.length, right.length)));
665
668
  for (let i = 0; i < bodyHeight; i++) {
666
- const l = pad(left[i] ?? "", listWidth);
667
- const r = pad(right[i] ?? "", detailWidth);
668
- lines.push(`${dim("│")} ${l} ${sep} ${r} ${dim("│")}`);
669
+ const rawLeft = left[i] ?? "";
670
+ const rawRight = right[i] ?? "";
671
+ // Filler row (both columns exhausted): a bare rail row — dot leaders
672
+ // across an empty row would just paint noise.
673
+ const content =
674
+ rawLeft.trim() === "" && rawRight.trim() === ""
675
+ ? ""
676
+ : railLeaders(pad(rawLeft, listWidth), truncate(rawRight, detailWidth), budget, this.theme);
677
+ lines.push(railLine(RAIL.body, "border", content, this.theme, budget));
669
678
  }
670
- lines.push(this.framedBorderRow("bottom", dim(" " + this.hintRow() + " "), "", w));
679
+ lines.push(railLine(RAIL.close, "border", this.capHint(), this.theme, budget));
671
680
  return lines;
672
681
  }
673
682
 
674
- /** `╭─ title ── right ─────╮` / `╰─ hint ───────────────╯` — always exactly `w` visible cells. */
675
- private framedBorderRow(which: "top" | "bottom", title: string, right: string, w: number): string {
676
- const cornerL = which === "top" ? "╭" : "╰";
677
- const cornerR = which === "top" ? "╮" : "╯";
678
- const rule = which === "top" ? "─" : "─";
679
- const titleCells = visibleWidth(title);
680
- const rightCells = right ? visibleWidth(right) : 0;
681
- const budget = w - 2 - titleCells - rightCells;
682
- if (budget < 1) {
683
- // Degenerate narrow: title only, truncated.
684
- const t = truncate(title, w - 2);
685
- return `${cornerL}${t}${cornerR}`;
686
- }
687
- return `${cornerL}${title}${rule.repeat(budget)}${right}${cornerR}`;
688
- }
689
-
690
683
  private countAgents(): number {
691
684
  return this.cachedEntries.filter((entry) => entry.kind === "agent").length;
692
685
  }
@@ -695,14 +688,28 @@ export class AgentsJobsBrowser {
695
688
  return this.cachedEntries.filter((entry) => entry.kind === "job").length;
696
689
  }
697
690
 
691
+ /** Header hint (canopy right segment) — RAIL format: `keys label` pairs. */
698
692
  private hintRow(): string {
699
- const base = this.focus === "list" ? "[↑↓] move · [⏎] transcript · [q] close" : "[↑↓] scroll · [⏎/Esc] back";
700
- return base;
693
+ return this.focus === "list"
694
+ ? formatHint([
695
+ [["up", "down"], "move"],
696
+ ["enter", "transcript"],
697
+ ])
698
+ : formatHint([
699
+ [["up", "down"], "scroll"],
700
+ ["enter", "back"],
701
+ ]);
702
+ }
703
+
704
+ /** End cap hint — the close/cancel action, LAST (RAIL hint rule). */
705
+ private capHint(): string {
706
+ return this.focus === "list" ? formatHint([["q", "close"]]) : formatHint([["esc", "back"]]);
701
707
  }
702
708
 
703
709
  /** LEFT column: section labels + unified rows, windowed around selection. */
704
710
  private renderListColumn(listWidth: number, bodyHeight: number, nowMs: number): string[] {
705
711
  const out: string[] = [];
712
+ const dim = (text: string) => this.theme.fg("border", text);
706
713
  const push = (text: string) => out.push(truncate(sanitizeLine(text), listWidth));
707
714
  if (this.cachedEntries.length === 0) {
708
715
  push("No live agents · no scheduled jobs");
@@ -712,9 +719,9 @@ export class AgentsJobsBrowser {
712
719
  const jobs = this.cachedEntries.filter((entry): entry is AgentsBrowserJobEntry => entry.kind === "job");
713
720
  // Rows: section labels + one row per entry, in merged order.
714
721
  const rows: { entry?: AgentsJobsBrowserEntry; text: string }[] = [];
715
- if (agents.length > 0) rows.push({ text: `Live agents (${agents.length})` });
722
+ if (agents.length > 0) rows.push({ text: dim(`Live agents (${agents.length})`) });
716
723
  for (const entry of agents) rows.push({ entry, text: this.agentRow(entry, nowMs) });
717
- if (jobs.length > 0) rows.push({ text: `Scheduled jobs (${jobs.length})` });
724
+ if (jobs.length > 0) rows.push({ text: dim(`Scheduled jobs (${jobs.length})`) });
718
725
  for (const entry of jobs) rows.push({ entry, text: this.jobRow(entry) });
719
726
 
720
727
  // Windowing: keep the selected entry's row inside the visible slot
@@ -727,7 +734,7 @@ export class AgentsJobsBrowser {
727
734
  }
728
735
  for (const row of rows.slice(this.listScroll, this.listScroll + slots)) {
729
736
  const isSelected = row.entry !== undefined && this.indexOf(row.entry) === this.selected;
730
- const marker = row.entry === undefined ? "" : isSelected ? "› " : " ";
737
+ const marker = row.entry === undefined ? "" : isSelected ? `${CURSOR} ` : " ";
731
738
  push(`${marker}${row.text}`);
732
739
  }
733
740
  return out;
@@ -738,9 +745,10 @@ export class AgentsJobsBrowser {
738
745
  }
739
746
 
740
747
  private agentRow(entry: AgentsBrowserAgentEntry, nowMs: number): string {
741
- const icon = iconForStatus(entry.status, { runningGlyph: spinnerFrame(entry.taskId, nowMs) });
748
+ // GUARD (M4 §4): these fields come from unvalidated disk JSON.
749
+ const icon = iconForStatus(entry.status, { runningGlyph: spinnerFrame(entry.taskId ?? "", nowMs) });
742
750
  const tok = entry.tokPerSec !== undefined && entry.status === "running" ? ` · ${entry.tokPerSec} tok/s` : "";
743
- return `${icon} ${entry.role}${tok} · ${entry.taskId.slice(-8)}`;
751
+ return `${icon} ${entry.role ?? "?"}${tok} · ${(entry.taskId ?? "").slice(-8)}`;
744
752
  }
745
753
 
746
754
  private jobRow(entry: AgentsBrowserJobEntry): string {
@@ -765,16 +773,14 @@ export class AgentsJobsBrowser {
765
773
  const maxScroll = Math.max(0, detail.length - (bodyHeight - 1));
766
774
  this.detailScroll = Math.max(0, Math.min(this.detailScroll, maxScroll));
767
775
  const windowed = detail.slice(this.detailScroll, this.detailScroll + bodyHeight - 1);
768
- const title = entry.kind === "agent" ? `▸ ${entry.role} · ${entry.taskId.slice(-8)}` : `▸ ${sanitizeLine(entry.job.name)}`;
776
+ const title =
777
+ entry.kind === "agent" ? `▸ ${entry.role ?? "?"} · ${(entry.taskId ?? "").slice(-8)}` : `▸ ${sanitizeLine(entry.job.name)}`;
769
778
  const out = [truncate(sanitizeLine(title), detailWidth)];
770
779
  for (const line of windowed) out.push(truncate(sanitizeLine(line), detailWidth));
771
780
  if (this.detailScroll > 0 || this.detailScroll + windowed.length < detail.length) {
772
- out.push(
773
- truncate(
774
- `… ${this.detailScroll} above · ${Math.max(0, detail.length - this.detailScroll - windowed.length)} below`,
775
- detailWidth,
776
- ),
777
- );
781
+ // RAIL overflow dialect only (`▲ n above · ▼ m below`).
782
+ const below = Math.max(0, detail.length - this.detailScroll - windowed.length);
783
+ out.push(truncate(overflowHint(this.detailScroll, below, this.theme), detailWidth));
778
784
  }
779
785
  return out;
780
786
  }
@@ -55,6 +55,35 @@ export function mixBg(base: Rgb, accent: Rgb, intensity: number): string {
55
55
 
56
56
  const BLACK: Rgb = { r: 0, g: 0, b: 0 };
57
57
 
58
+ /**
59
+ * The background slots Pi's Theme actually exposes (`ThemeBg` in
60
+ * node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/theme.d.ts).
61
+ *
62
+ * P1-6 (2026-09-15): this probe used to pass "background", which is NOT a
63
+ * ThemeBg member. Pi's `getBgAnsi` throws `Unknown theme background color:
64
+ * background` for it, the surrounding catch swallowed the throw, and `base`
65
+ * stayed BLACK on every theme — so crew card tints were derived from the wrong
66
+ * base (visibly wrong on light themes). Never pass a slot outside this union.
67
+ * `selectedBg` is the slot whose value is the surrounding card/selection
68
+ * background; it is the one both Pi's `ThemeBg` and pi-crew's `CrewThemeBg`
69
+ * (theme-adapter.ts) define. Enforced by
70
+ * test/unit/round23-ui-unicode.test.ts (a fake theme records every slot).
71
+ */
72
+ type ThemeBgSlot =
73
+ | "selectedBg"
74
+ | "scrollbarThumb"
75
+ | "userMessageBg"
76
+ | "customMessageBg"
77
+ | "toolPendingBg"
78
+ | "toolSuccessBg"
79
+ | "toolErrorBg";
80
+
81
+ /** Duck-typed view of Pi's Theme methods (CrewTheme does not declare them). */
82
+ type ThemeProbe = {
83
+ getBgAnsi?: (slot: ThemeBgSlot) => string | undefined;
84
+ getFgAnsi?: (slot: string) => string | undefined;
85
+ };
86
+
58
87
  // ── Theme → background derivation ───────────────────────────────────────
59
88
 
60
89
  /**
@@ -76,17 +105,17 @@ export function deriveCardBackground(
76
105
  ): string {
77
106
  // Prefer the theme's bg color as base (matches the card's surroundings).
78
107
  let base = BLACK;
79
- const themeAny = theme as unknown as {
80
- getBgAnsi?: (slot: string) => string | undefined;
81
- getFgAnsi?: (slot: string) => string | undefined;
82
- };
108
+ const themeAny = theme as unknown as ThemeProbe;
83
109
  if (themeAny.getBgAnsi) {
84
110
  try {
85
- const bg = themeAny.getBgAnsi("background");
86
- const parsed = parseAnsiRgb(bg);
111
+ // "selectedBg" is a valid ThemeBg slot (P1-6). The catch only guards
112
+ // third-party theme shims that throw on the probe; it can no longer
113
+ // hide a permanently-invalid slot, because the slot type and the
114
+ // regression test both pin it to the ThemeBg union.
115
+ const parsed = parseAnsiRgb(themeAny.getBgAnsi("selectedBg"));
87
116
  if (parsed) base = parsed;
88
117
  } catch {
89
- /* fall back to black */
118
+ /* shim threw — fall back to black */
90
119
  }
91
120
  }
92
121
 
@@ -2,11 +2,14 @@ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
2
2
  import { type LiveAgentHandle, listLiveAgents, listLiveAgentsByWorkspace } from "../../runtime/live-session/live-agent-manager.ts";
3
3
  import { formatCost } from "../../state/usage.ts";
4
4
  import { visibleWidth } from "../../utils/visual.ts";
5
+ import { formatCount, formatDuration } from "../format-helpers.ts";
5
6
  import { computeLiveDurationMs } from "../live-duration.ts";
7
+ import { overflowHint, statusIcon } from "../rail.ts";
6
8
  import type { RunDashboardOptions } from "../run-dashboard.ts";
7
9
  import type { RunUiSnapshot } from "../snapshot-types.ts";
8
10
  import { spinnerFrame } from "../spinner.ts";
9
11
  import { iconForStatus } from "../status-colors.ts";
12
+ import { PANE_THEME } from "./pane-theme.ts";
10
13
 
11
14
  /**
12
15
  * Fixed visible widths for the per-agent numeric metrics (finding V-1).
@@ -16,7 +19,7 @@ import { iconForStatus } from "../status-colors.ts";
16
19
  */
17
20
  const TOKENS_METRIC_WIDTH = 5; // `1.2k`, `12.3k`, `123`
18
21
  const COST_METRIC_WIDTH = 7; // `$0.0500`, `$1.50`
19
- const DURATION_METRIC_WIDTH = 6; // `3.0s`, `59.9s`, `120.0s`
22
+ const DURATION_METRIC_WIDTH = 6; // `3.0s`, `59.9s`, `5m14s` (formatDuration)
20
23
 
21
24
  /**
22
25
  * Width-aware right-align (padStart) for a numeric metric so its column stays
@@ -29,6 +32,30 @@ function alignMetric(value: string, width: number): string {
29
32
  return gap > 0 ? " ".repeat(gap) + value : value;
30
33
  }
31
34
 
35
+ /**
36
+ * Agent status glyph, from the shared RAIL vocabulary (`statusIcon`). Two
37
+ * deliberate exceptions, both kept because rail.ts cannot express them:
38
+ * - `running` → the animated braille spinner (a live activity frame, not a
39
+ * static status icon);
40
+ * - `waiting`/`needs_attention`/`blocked` → ⏳/⚠/⏸: `statusIcon` has only
41
+ * four outputs (✓ ✗ ⟳ ○) and would collapse all three into `○`, losing
42
+ * the signal; the shared `status-colors` vocabulary is used for exactly
43
+ * these.
44
+ * `queued` now renders rail's `○` instead of the legacy `◦`.
45
+ */
46
+ function agentGlyph(status: string | undefined, taskId: string | undefined): string {
47
+ switch (status) {
48
+ case "running":
49
+ return spinnerFrame(taskId ?? "");
50
+ case "waiting":
51
+ case "needs_attention":
52
+ case "blocked":
53
+ return iconForStatus(status);
54
+ default:
55
+ return statusIcon(status ?? "unknown", PANE_THEME);
56
+ }
57
+ }
58
+
32
59
  /**
33
60
  * Returns true if this agent did real work (LLM call, tool use, or non-trivial duration).
34
61
  * Scaffold-only agents (no tokens, no tools, no turns) are skipped in the agents pane —
@@ -99,17 +126,18 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
99
126
  const lineCount = Math.min(realAgents.length, 12);
100
127
  const label =
101
128
  realAgents.length !== snapshot.agents.length
102
- ? `${realAgents.length} real agents (${snapshot.agents.length} total)`
103
- : `${realAgents.length} agents`;
129
+ ? `${formatCount(realAgents.length, "real agent")} (${formatCount(snapshot.agents.length, "agent")} total)`
130
+ : formatCount(realAgents.length, "agent");
104
131
 
132
+ // `3/3 tasks · 3 agents` — the label arrives pre-pluralised by the caller
133
+ // ("N agents"); the task tally keeps the slash form on purpose (it reads as
134
+ // progress, not as a count).
105
135
  lines.push(`${completed}/${total} tasks · ${label}`);
106
136
 
107
137
  for (const agent of realAgents.slice(0, 12)) {
108
138
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
109
- const icon = iconForStatus(agent.status, {
110
- runningGlyph: spinnerFrame(agent.taskId),
111
- });
112
- const role = `${agent.role}`;
139
+ const icon = agentGlyph(agent.status, agent.taskId);
140
+ const role = `${agent.role ?? "?"}`;
113
141
 
114
142
  // Compact activity line
115
143
  const activity = liveHandle
@@ -145,23 +173,36 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
145
173
  // fired for EVERY running live agent in the dashboard. Use the shared,
146
174
  // validated computeLiveDurationMs (mirrors widget-formatters.ts).
147
175
  const ms = computeLiveDurationMs(liveHandle.activity, nowMs);
148
- stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
176
+ stats.push(alignMetric(formatDuration(ms), DURATION_METRIC_WIDTH));
149
177
  if (options.showModel !== false && liveHandle.modelName && liveHandle.modelName !== "default") {
150
178
  stats.push(liveHandle.modelName);
151
179
  }
152
180
  } else if (agent.startedAt) {
153
- const ms = nowMs - new Date(agent.startedAt).getTime();
154
- if (Number.isFinite(ms)) stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
181
+ // Tier 13 (2026-09-21): a FINISHED agent's span must end at completedAt,
182
+ // never at the wall clock. The naive `nowMs - startedAt` made every
183
+ // completed agent of an old run inflate forever — the dashboard showed
184
+ // `01_explore … 29m39s` while the tool card showed the real `8m22s` for
185
+ // the same agent. Mirror computeLiveDurationMs: prefer a sane completedAt,
186
+ // else fall back to now; never emit a negative or absurd span.
187
+ const startedMs = new Date(agent.startedAt).getTime();
188
+ const rawCompleted = agent.completedAt ? new Date(agent.completedAt).getTime() : Number.NaN;
189
+ const completedMs = Number.isFinite(rawCompleted) && rawCompleted >= startedMs && rawCompleted <= nowMs ? rawCompleted : nowMs;
190
+ const ms = completedMs - startedMs;
191
+ if (Number.isFinite(ms) && ms >= 0) stats.push(alignMetric(formatDuration(ms), DURATION_METRIC_WIDTH));
155
192
  }
156
193
 
157
194
  const statsStr = stats.length ? ` · ${stats.join(" ")}` : "";
158
- lines.push(` ${icon} ${agent.taskId} ${role}${statsStr}`);
195
+ lines.push(` ${icon} ${agent.taskId ?? "?"} ${role}${statsStr}`);
159
196
  lines.push(` ${activity}`);
160
197
  }
161
198
 
162
- if (snapshot.agents.length > 12) {
163
- lines.push(` … +${snapshot.agents.length - 12} more`);
164
- }
199
+ // Overflow in the ONE rail dialect (`▲ n above` / `▼ m below`), replacing
200
+ // the legacy `… +N more`. The count is derived from the SAME list the cap
201
+ // slices (`realAgents`), not from `snapshot.agents` — the pre-migration
202
+ // line could report rows that were never hidden (scaffold agents filtered
203
+ // out above).
204
+ const hiddenBelow = realAgents.length - 12;
205
+ if (hiddenBelow > 0) lines.push(` ${overflowHint(0, hiddenBelow, PANE_THEME)}`);
165
206
 
166
207
  return lines;
167
208
  }
@@ -1,47 +1,5 @@
1
1
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
2
2
 
3
- export interface CancellationPaneOptions {
4
- maxReasons?: number;
5
- }
6
-
7
- export function renderCancellationPane(manifest: TeamRunManifest, tasks: TeamTaskState[], opts: CancellationPaneOptions = {}): string[] {
8
- const maxReasons = opts.maxReasons ?? 5;
9
- if (manifest.status !== "cancelled" && manifest.status !== "blocked") {
10
- const cancellingTasks = tasks.filter((t) => t.status === "cancelled");
11
- if (cancellingTasks.length === 0) return ["Cancellation pane: no active cancellations"];
12
- }
13
-
14
- const lines: string[] = ["Cancellation pane"];
15
-
16
- if (manifest.status === "cancelled") {
17
- lines.push(` Run status: cancelled`);
18
- } else if (manifest.status === "blocked") {
19
- lines.push(` Run status: blocked`);
20
- }
21
-
22
- const cancelledTasks = tasks.filter((t) => t.status === "cancelled");
23
- if (cancelledTasks.length > 0) {
24
- lines.push(` Cancelled tasks (${cancelledTasks.length}):`);
25
- for (const task of cancelledTasks.slice(0, maxReasons)) {
26
- const reason = task.error ?? "unknown";
27
- lines.push(` ✗ ${task.id}: ${reason}`);
28
- }
29
- if (cancelledTasks.length > maxReasons) {
30
- lines.push(` ... and ${cancelledTasks.length - maxReasons} more`);
31
- }
32
- }
33
-
34
- if (manifest.policyDecisions?.length) {
35
- const decisions = manifest.policyDecisions.slice(0, maxReasons);
36
- lines.push(` Policy decisions (${manifest.policyDecisions.length}):`);
37
- for (const d of decisions) {
38
- lines.push(` ${d.action}: ${d.message}`);
39
- }
40
- }
41
-
42
- return lines;
43
- }
44
-
45
3
  /**
46
4
  * D-1 / L-2 — a one-line terminal-run reason for the dashboard detail row.
47
5
  *
@@ -1,4 +1,5 @@
1
1
  import { summarizeHeartbeats } from "../heartbeat-aggregator.ts";
2
+ import { formatHint } from "../rail.ts";
2
3
  import type { RunUiSnapshot } from "../snapshot-types.ts";
3
4
 
4
5
  export interface HealthPaneOptions {
@@ -19,12 +20,13 @@ export function renderHealthPane(snapshot: RunUiSnapshot | undefined, opts: Heal
19
20
  `Health pane: ${summary.healthy}/${summary.totalTasks} healthy · stale=${summary.stale} · dead=${summary.dead} · missing=${summary.missing}`,
20
21
  ];
21
22
  if (summary.worstStaleMs > 0) lines.push(`Worst stale: ${seconds(summary.worstStaleMs)} ago`);
22
- const hints: string[] = [];
23
+ // One hint format (rail.ts `formatHint`): keys `label` joined by ` · `.
24
+ const hints: Array<readonly [string, string]> = [];
23
25
  const foreground = opts.isForeground !== false;
24
- if ((summary.dead > 0 || summary.missing > 0) && foreground) hints.push("R recovery");
25
- if ((summary.dead > 0 || summary.stale > 0) && foreground) hints.push("K kill stale");
26
- hints.push("D diagnostic export");
27
- lines.push(`Actions: ${hints.join(" · ")}`);
26
+ if ((summary.dead > 0 || summary.missing > 0) && foreground) hints.push(["R", "recovery"]);
27
+ if ((summary.dead > 0 || summary.stale > 0) && foreground) hints.push(["K", "kill stale"]);
28
+ hints.push(["D", "diagnostic export"]);
29
+ lines.push(`Actions: ${formatHint(hints)}`);
28
30
  if (!foreground && (summary.dead > 0 || summary.missing > 0 || summary.stale > 0))
29
31
  lines.push("Async run: R/K disabled — inspect process manually or use /team-api.");
30
32
  return lines;
@@ -1,12 +1,14 @@
1
+ import { formatHint } from "../rail.ts";
1
2
  import type { RunUiSnapshot } from "../snapshot-types.ts";
2
3
 
3
4
  export function renderMailboxPane(snapshot: RunUiSnapshot | undefined): string[] {
4
5
  if (!snapshot) return ["Mailbox pane: snapshot unavailable"];
5
6
  const mailbox = snapshot.mailbox;
6
7
  const approx = mailbox.approximate ? " · approximate (tail)" : "";
7
- const lines: string[] = [
8
- `Mailbox pane: inbox unread=${mailbox.inboxUnread} · outbox pending=${mailbox.outboxPending} · attention=${mailbox.needsAttention}${approx}`,
9
- ];
8
+ const unread = mailbox.inboxUnread ?? 0;
9
+ const outgoing = mailbox.outboxPending ?? 0;
10
+ const attention = mailbox.needsAttention ?? 0;
11
+ const lines: string[] = [`Mailbox pane: inbox unread=${unread} · outbox pending=${outgoing} · attention=${attention}${approx}`];
10
12
  // Kind-separated breakdown
11
13
  const kindParts: string[] = [];
12
14
  const steer = mailbox.steerUnread ?? 0;
@@ -26,10 +28,24 @@ export function renderMailboxPane(snapshot: RunUiSnapshot | undefined): string[]
26
28
  lines.push(` 📋 ${followUp} follow-up(s) pending review.`);
27
29
  }
28
30
  }
29
- if (mailbox.needsAttention > 0) {
30
- lines.push("Needs attention: press Enter for detail · A ack · N nudge · C compose · X ack all.");
31
+ if (attention > 0) {
32
+ // One hint format (rail.ts `formatHint`): keys `label` joined by ` · `.
33
+ lines.push(
34
+ `Needs attention: press ${formatHint([
35
+ ["Enter", "for detail"],
36
+ ["A", "ack"],
37
+ ["N", "nudge"],
38
+ ["C", "compose"],
39
+ ["X", "ack all"],
40
+ ])}.`,
41
+ );
31
42
  } else {
32
- lines.push("No mailbox items need attention. Press Enter for detail or C compose.");
43
+ lines.push(
44
+ `No mailbox items need attention. Press ${formatHint([
45
+ ["Enter", "for detail"],
46
+ ["C", "compose"],
47
+ ])}.`,
48
+ );
33
49
  }
34
50
  return lines;
35
51
  }
@@ -1,5 +1,5 @@
1
1
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
2
- import type { HistogramPoint, MetricLabels, MetricPoint } from "../../observability/metrics-primitives.ts";
2
+ import type { HistogramPoint, MetricLabels, MetricLabelValue, MetricPoint } from "../../observability/metrics-primitives.ts";
3
3
  import type { RunUiSnapshot } from "../snapshot-types.ts";
4
4
 
5
5
  export interface MetricsPaneOptions {
@@ -7,9 +7,17 @@ export interface MetricsPaneOptions {
7
7
  maxCounters?: number;
8
8
  }
9
9
 
10
- function labelsText(labels: MetricLabels): string {
11
- const entries = Object.entries(labels);
12
- return entries.length ? `{${entries.map(([key, value]) => `${key}=${value}`).join(",")}}` : "";
10
+ // Metric label values come from an unvalidated `Record` (disk/producer fed) —
11
+ // every interpolation is guarded so `undefined` can never reach the pane.
12
+ function labelValue(value: MetricLabelValue | undefined): string {
13
+ if (value === undefined || value === null) return "?";
14
+ if (typeof value === "number" && !Number.isFinite(value)) return "?";
15
+ return `${value}`;
16
+ }
17
+
18
+ function labelsText(labels: MetricLabels | undefined): string {
19
+ const entries = Object.entries(labels ?? {});
20
+ return entries.length ? `{${entries.map(([key, value]) => `${key}=${labelValue(value)}`).join(",")}}` : "";
13
21
  }
14
22
 
15
23
  function isHistogramPoint(point: MetricPoint | HistogramPoint): point is HistogramPoint {
@@ -24,14 +32,14 @@ export function renderMetricsPane(_snapshot: RunUiSnapshot | undefined, opts: Me
24
32
  for (const snapshot of snapshots.slice(0, opts.maxCounters ?? 10)) {
25
33
  const first = snapshot.values[0];
26
34
  if (!first) {
27
- lines.push(`${snapshot.name}: empty`);
35
+ lines.push(`${snapshot.name ?? "?"}: empty`);
28
36
  continue;
29
37
  }
30
38
  if (isHistogramPoint(first))
31
39
  lines.push(
32
- `${snapshot.name}${labelsText(first.labels)} count=${first.count} p95=${Number.isFinite(first.quantiles.p95) ? Math.round(first.quantiles.p95) : "n/a"}`,
40
+ `${snapshot.name ?? "?"}${labelsText(first.labels)} count=${first.count ?? 0} p95=${Number.isFinite(first.quantiles?.p95) ? Math.round(first.quantiles.p95) : "n/a"}`,
33
41
  );
34
- else lines.push(`${snapshot.name}${labelsText(first.labels)} ${first.value}`);
42
+ else lines.push(`${snapshot.name ?? "?"}${labelsText(first.labels)} ${labelValue(first.value)}`);
35
43
  }
36
44
  return lines;
37
45
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * pane-theme.ts — the theme binding dashboard panes render RAIL primitives with.
3
+ *
4
+ * Contract (§2.G of docs/UI-DESIGN-SYSTEM.md): a pane returns CONTENT LINES
5
+ * ONLY and stays uncolored — the frame owner (run-dashboard.ts) prefixes the
6
+ * `┃ ` rail and colorizes embedded status glyphs through
7
+ * `colorizeStatusGlyphs`. The RAIL primitives in `src/ui/rail.ts`
8
+ * (`statusIcon`, `statusSlot`, `overflowHint`, `railLine`, `formatHint`, …)
9
+ * all take a `CrewTheme`, so panes hand them this identity theme: every glyph,
10
+ * overflow dialect and hint still comes from the one shared vocabulary, while
11
+ * no ANSI escapes leak into pane content.
12
+ *
13
+ * Not a fork of a rail primitive — a no-op `CrewTheme` adapter
14
+ * (`asCrewTheme(undefined)` → identity `fg`/`bold`) so uncolored producers can
15
+ * call the colored primitives.
16
+ */
17
+
18
+ import { asCrewTheme } from "../theme-adapter.ts";
19
+
20
+ /** Identity theme: `fg(color, text) === text`, `bold(text) === text`. */
21
+ export const PANE_THEME = asCrewTheme(undefined);