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
@@ -10,14 +10,14 @@ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
10
10
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
11
11
  import { getLiveAgentContextPercent } from "../runtime/live-session/live-agent-manager.ts";
12
12
  import { isPlanApprovalPending } from "../runtime/plan-approval.ts";
13
- import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
13
+ import { isActiveRunStatus, isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
14
14
  import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
15
15
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
16
16
  import { aggregateUsage } from "../state/usage.ts";
17
17
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
18
18
  import { logInternalError } from "../utils/internal-error.ts";
19
19
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
20
- import { pad, sanitizeLine, truncate, visibleWidth } from "../utils/visual.ts";
20
+ import { sanitizeLine, truncate, visibleWidth } from "../utils/visual.ts";
21
21
  import type { InteractiveComponent } from "./component.ts";
22
22
  import { renderAgentsPane } from "./dashboard-panes/agents-pane.ts";
23
23
  import { summarizeTerminalReason } from "./dashboard-panes/cancellation-pane.ts";
@@ -28,9 +28,10 @@ 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 { DynamicCrewBorder } from "./dynamic-border.ts";
31
+ import { formatCount, teamWorkflowLabel } from "./format-helpers.ts";
32
32
  import { type DashboardKeyAction, dashboardActionForKey } from "./keybinding-map.ts";
33
33
  import { HelpOverlay } from "./overlays/help-overlay.ts";
34
+ import { ACTIVE, CURSOR, canopyLine, formatHint, gaugeBar, overflowHint, RAIL, railLine, sectionLine } from "./rail.ts";
34
35
  import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts";
35
36
  import { registerOverlayScheduler } from "./shared-overlay-scheduler.ts";
36
37
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
@@ -56,7 +57,6 @@ function safeRenderPane(name: string, fn: () => string[]): string[] {
56
57
  interface DashboardComponent extends InteractiveComponent {}
57
58
 
58
59
  export interface RunDashboardOptions {
59
- placement?: "center" | "right";
60
60
  showModel?: boolean;
61
61
  showTokens?: boolean;
62
62
  showTools?: boolean;
@@ -94,6 +94,27 @@ export interface RunDashboardOptions {
94
94
  */
95
95
  let lastActivePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" | "plan" | "schedules" = "agents";
96
96
 
97
+ /**
98
+ * US-020 AC-2: the selected run SURVIVES dashboard close/reopen (the shared.ts
99
+ * action loop re-creates RunDashboard after every done() selection — enter on
100
+ * a run opens its detail, escape returns, and the cursor is where it was).
101
+ * Persisted per-process like lastActivePane; restored by runId on construct.
102
+ */
103
+ let lastSelectedRunId: string | undefined;
104
+
105
+ /** @internal — test isolation hook. US-020 AC-2 persists the cursor in
106
+ * module-level state (lastSelectedRunId) so it survives close/reopen cycles,
107
+ * but that state ALSO leaks across RunDashboard instances within one process:
108
+ * an E2E brute-force that constructs a fresh dashboard per iteration inherits
109
+ * the previous iteration's cursor, and with wrap-navigation the driven
110
+ * selection lands somewhere else entirely (measured: count=9 offset=8 restored
111
+ * run-007 → 8 'j' wraps to run-006, not run-008). Tests that need a clean
112
+ * cursor call this before constructing. */
113
+ export function __test__resetDashboardState(): void {
114
+ lastSelectedRunId = undefined;
115
+ lastActivePane = "agents";
116
+ }
117
+
97
118
  export type RunDashboardAction =
98
119
  | "status"
99
120
  | "summary"
@@ -118,7 +139,8 @@ export type RunDashboardAction =
118
139
  | "schedule-disable"
119
140
  | "schedule-run-now"
120
141
  | "schedule-remove"
121
- | "notifications-dismiss";
142
+ | "notifications-dismiss"
143
+ | "cancel";
122
144
  export interface RunDashboardSelection {
123
145
  runId: string;
124
146
  action: RunDashboardAction;
@@ -187,12 +209,6 @@ const SIGNATURE_CACHE_TTL_MS = 100;
187
209
  */
188
210
  const STALE_SNAPSHOT_MS = 15_000;
189
211
 
190
- /** Left-pad `value` to a fixed VISIBLE width (ANSI-aware). Used by V-1. */
191
- function padVis(value: string, width: number): string {
192
- const current = visibleWidth(value);
193
- return current >= width ? value : `${" ".repeat(width - current)}${value}`;
194
- }
195
-
196
212
  /**
197
213
  * L-1 — compute the run-list window (visible slots + which scroll indicators
198
214
  * render) for a given offset. Shared by `ensureRunListWindow()` and the
@@ -323,7 +339,7 @@ function agentPreviewLine(agent: CrewAgentRecord, task: TeamTaskState | undefine
323
339
  (agent.progress?.tokens !== undefined ? `tok=${agent.progress.tokens}` : undefined))
324
340
  : undefined,
325
341
  options.showTools !== false && agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined,
326
- options.showTools !== false && agent.toolUses !== undefined ? `${agent.toolUses} tools` : undefined,
342
+ options.showTools !== false && agent.toolUses !== undefined ? formatCount(agent.toolUses, "tool") : undefined,
327
343
  agent.progress?.turns !== undefined ? `${agent.progress.turns} turns` : undefined,
328
344
  agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined,
329
345
  agent.startedAt ? `age=${formatAge(agent.completedAt ?? agent.startedAt)}` : undefined,
@@ -332,8 +348,11 @@ function agentPreviewLine(agent: CrewAgentRecord, task: TeamTaskState | undefine
332
348
  const icon = iconForStatus(agent.status, {
333
349
  runningGlyph: spinnerFrame(agent.taskId),
334
350
  });
351
+ // GUARD (M4, §4): agents.json records are NOT schema-validated at read time,
352
+ // so every interpolated field needs a fallback — `${agent.role}${ACTIVE}${agent.agent}`
353
+ // printed `undefined->undefined` for a partially-written record.
335
354
  return sanitizeLine(
336
- `Agent: ${icon} ${agent.taskId} ${agent.role}->${agent.agent}${stats.length ? ` · ${stats.join(" · ")}` : ""}${recent ? ` ⎿ ${recent}` : ""}`,
355
+ `Agent: ${icon} ${agent.taskId ?? "?"} ${agent.role ?? "?"}${ACTIVE}${agent.agent ?? "?"}${stats.length ? ` · ${stats.join(" · ")}` : ""}${recent ? ` ⎿ ${recent}` : ""}`,
337
356
  );
338
357
  }
339
358
 
@@ -409,7 +428,7 @@ function runLabel(
409
428
  ? `queued ${queued.taskId}`
410
429
  : `agents ${agents.length}`;
411
430
  const status: RunStatus = stale ? "stale" : (run.status as RunStatus);
412
- const marker = selected ? "›" : " ";
431
+ const marker = selected ? CURSOR : " ";
413
432
  const icon = iconForStatus(status, {
414
433
  runningGlyph: spinnerFrame(run.runId),
415
434
  });
@@ -420,20 +439,25 @@ function runLabel(
420
439
  // enables the goal-aware truncation; legacy 3-arg callers (dev patch
421
440
  // scripts) get the untruncated full label.
422
441
  const head = `${marker} ${icon} ${run.runId.slice(-8)} ${status}`;
423
- const meta = `${run.team}/${run.workflow ?? "none"} · ${step}`;
442
+ const meta = `${teamWorkflowLabel(run.team, run.workflow)} · ${step}`;
424
443
  const goal = sanitizeLine(run.goal ?? "");
425
444
  if (maxW === undefined) return sanitizeLine(`${head} · ${meta} · ${goal}`);
426
445
  const sepW = 3; // " · "
427
446
  if (visibleWidth(head) + sepW + visibleWidth(meta) + sepW + visibleWidth(goal) <= maxW) {
428
447
  return sanitizeLine(`${head} · ${meta} · ${goal}`);
429
448
  }
430
- // Not enough room: keep head + goal; clip the goal only if head+goal alone
431
- // cannot fit, then shrink the prefix (meta clips first) to match.
432
- const goalFits = visibleWidth(head) + sepW + visibleWidth(goal) <= maxW;
433
- const goalRender = goalFits ? goal : truncate(goal, Math.max(8, maxW - visibleWidth(head) - sepW));
434
- const prefixBudget = Math.max(0, maxW - visibleWidth(goalRender) - sepW);
435
- const prefixRender = truncate(`${head} · ${meta}`, prefixBudget);
436
- return sanitizeLine(`${prefixRender} · ${goalRender}`);
449
+ // Not enough room. The STATUS token is the one that must survive — the run
450
+ // list is how a failed/cancelled run gets spotted — so meta clips FIRST,
451
+ // then the goal; the head itself is only clipped if it cannot fit alone.
452
+ // (Live bug 2026-09-16: the old fallback truncated `head · meta` as ONE
453
+ // string, so a 118-column render painted `› ✓ 122ab0dd complete…` — the
454
+ // status cut in half while the meta it should have sacrificed stayed put.)
455
+ const headW = visibleWidth(head);
456
+ if (headW + sepW + 8 <= maxW) {
457
+ const goalBudget = Math.max(8, maxW - headW - sepW);
458
+ return sanitizeLine(`${head} · ${truncate(goal, goalBudget)}`);
459
+ }
460
+ return sanitizeLine(truncate(head, maxW));
437
461
  }
438
462
 
439
463
  interface ResolvedRun {
@@ -482,17 +506,9 @@ function selectedRunFromGrouped(
482
506
  return groupedRuns(runs, snapshotCache, resolve).filter((row) => row.run)[selected]?.run;
483
507
  }
484
508
 
485
- function countByStatus(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): string {
486
- const resolved = resolveRuns(runs, snapshotCache);
487
- const counts = new Map<RunStatus, number>();
488
- for (const r of resolved.values()) counts.set(r.status, (counts.get(r.status) ?? 0) + 1);
489
- return [...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none";
490
- }
491
-
492
509
  export class RunDashboard implements DashboardComponent {
493
510
  private selected = 0;
494
511
  private runScrollOffset = 0;
495
- private showFullProgress = false;
496
512
  private showHelp = false;
497
513
  private activePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" | "plan" | "schedules" = lastActivePane;
498
514
  /** WP-7 (R7): pane-scoped revision-diff toggle (X). */
@@ -502,6 +518,11 @@ export class RunDashboard implements DashboardComponent {
502
518
  private scheduleSelected = 0;
503
519
  private scheduleDetails = false;
504
520
  private scheduleDeleteArmed = false;
521
+ /** US-020 (run cancel): 2-step confirm-gate + refusal status line —
522
+ * mirrors the schedules delete gate (first x arms, second x dispatches,
523
+ * any other key disarms; a non-running selection refuses with a line). */
524
+ private runCancelArmed = false;
525
+ private runCancelRefused = false;
505
526
  private scheduleJobsCache: { at: number; jobs: ScheduledJob[]; hiddenCount: number } | undefined;
506
527
  private runs: TeamRunManifest[];
507
528
  private readonly done: (selection: RunDashboardSelection | undefined) => void;
@@ -529,6 +550,15 @@ export class RunDashboard implements DashboardComponent {
529
550
  ? runs.filter((run) => !run.ownerSessionId || run.ownerSessionId === options.workspaceId)
530
551
  : runs;
531
552
  this.runs = filteredRuns;
553
+ // US-020 AC-2: restore the cursor to the previously selected run (if it
554
+ // is still in the list) so escape-return keeps the user's place.
555
+ if (lastSelectedRunId) {
556
+ // groupedRuns rows include GROUP LABELS ("Active"/"Recent") — index
557
+ // into the SELECTABLE rows only (the same projection navigation uses).
558
+ const selectable = groupedRuns(filteredRuns, options.snapshotCache).filter((rowItem) => rowItem.run);
559
+ const idx = selectable.findIndex((rowItem) => rowItem.run?.runId === lastSelectedRunId);
560
+ if (idx >= 0) this.selected = idx;
561
+ }
532
562
  this.done = done;
533
563
  this.theme = asCrewTheme(theme);
534
564
  this.options = options;
@@ -683,7 +713,7 @@ export class RunDashboard implements DashboardComponent {
683
713
  const metricsSig =
684
714
  this.activePane === "metrics" ? `:metrics=${this.options.registry?.snapshot().length ?? 0}:${spinnerBucket()}` : "";
685
715
  const schedulesSig = this.activePane === "schedules" ? `:sched=${this.schedulesSignatureFragment()}` : "";
686
- const sig = `${this.selected}:${this.showHelp ? 1 : 0}:${this.showFullProgress ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}${schedulesSig}`;
716
+ const sig = `${this.selected}:${this.showHelp ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}${schedulesSig}`;
687
717
  this.cachedSignature = sig;
688
718
  this.cachedSignatureAt = now;
689
719
  return sig;
@@ -801,17 +831,15 @@ export class RunDashboard implements DashboardComponent {
801
831
  this.refreshRuns(snapshotOnce);
802
832
  const signature = this.buildSignature(snapshotOnce);
803
833
  if (signature !== this.cachedVersion || this.cachedWidth !== width) {
804
- const innerWidth = Math.max(20, width - 4);
805
- const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
834
+ // RAIL frame (M4, 2026-09-16) — the rounded box is retired. The frame
835
+ // owner prefixes EVERY body row with `┃` (railLine owns the glyph + space
836
+ // and pads/truncates to `budget`, so no row can spill past the terminal),
837
+ // sections open with `┣` (sectionLine), the header hint rides the
838
+ // canopy's right segment (dot-led) and the surface caps with `┗ <hint>`.
839
+ const budget = Math.max(0, width - 2);
806
840
  const fg = (color: Parameters<CrewTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
807
- // PERF (2026-08-24): DynamicCrewBorder caches the rendered fill per
808
- // instance — a new instance per line defeated it. One per render
809
- // pass; every border()/sep() call below reuses the cached line.
810
- const crewBorder = new DynamicCrewBorder(this.theme);
811
- const borderFill = (count: number) => crewBorder.render(count)[0];
812
- const border = (left: string, right: string) => `${fg("border", left)}${borderFill(borderWidth)}${fg("border", right)}`;
813
- const row = (text: string) => `│ ${pad(truncate(text, innerWidth - 1), innerWidth - 1)}│`;
814
- const sep = () => border("├", "┤");
841
+ const row = (text: string) => railLine(RAIL.body, "border", text, this.theme, budget);
842
+ const section = (name: string, subject?: string) => sectionLine({ name, subject, theme: this.theme, budget });
815
843
 
816
844
  const lines: string[] = [];
817
845
  if (this.showHelp) {
@@ -819,17 +847,48 @@ export class RunDashboard implements DashboardComponent {
819
847
  lines.push(...new HelpOverlay(this.theme).render(width));
820
848
  } else {
821
849
  lines.push(
822
- border("╭", "╮"),
823
- row(
824
- `${fg("accent", "▐")} ${this.theme.bold("pi-crew")} · ${this.runs.length} runs ${fg("dim", "1-8 pane · ↑↓ · Enter · ? help · Esc")}`,
825
- ),
826
- sep(),
850
+ canopyLine({
851
+ word: "DASHBOARD",
852
+ subject: formatCount(this.runs.length, "run"),
853
+ // US-020: the cancel hint joins the canopy only when the budget
854
+ // has room — at narrow widths the canopy prefers the run-count
855
+ // subject over hints, and the 5th hint ellipsized it (regression
856
+ // caught by run-dashboard.test.ts's 80-col canonical canopy).
857
+ right: formatHint([
858
+ ["1-8", "pane"],
859
+ [["up", "down"], "move"],
860
+ ["enter", "select"],
861
+ ...(budget >= 96 ? ([["x", "cancel"]] as const) : []),
862
+ ["?", "help"],
863
+ ]),
864
+ theme: this.theme,
865
+ budget,
866
+ }),
827
867
  );
868
+ // US-020: run-cancel confirm gate / refusal status line —
869
+ // rendered right under the canopy so it is visible in EVERY pane
870
+ // (schedule-delete's warning is pane-8-only by design; run cancel
871
+ // is a root action).
872
+ if (this.runCancelArmed) {
873
+ lines.push(row(fg("warning", `⚠ x again to CANCEL '${this.selectedRunId() ?? "(none)"}' · any other key disarms`)));
874
+ } else if (this.runCancelRefused) {
875
+ lines.push(row(fg("dim", "nothing to cancel — the selected run is not active")));
876
+ }
828
877
 
829
878
  if (this.runs.length === 0) {
830
879
  // F-7: actionable empty state instead of a bare "No runs.".
831
880
  lines.push(row(fg("dim", "No runs yet.")));
832
- lines.push(row(fg("dim", "Start one: team action='run' · r reload · Esc close")));
881
+ lines.push(
882
+ row(
883
+ fg(
884
+ "dim",
885
+ `Start one: team action='run' · ${formatHint([
886
+ ["r", "reload"],
887
+ ["esc", "close"],
888
+ ])}`,
889
+ ),
890
+ ),
891
+ );
833
892
  } else {
834
893
  // L-1: windowed run list so the selection can never scroll off-screen.
835
894
  const allGrouped = groupedRuns(this.runs, this.options.snapshotCache, snapshotOnce);
@@ -839,9 +898,12 @@ export class RunDashboard implements DashboardComponent {
839
898
  this.ensureRunListWindow(selectableCount);
840
899
  if (selectableCount <= RUN_LIST_MAX) {
841
900
  // Common case (≤8 runs): keep the Active/Recent group headers.
842
- for (const rowItem of allGrouped) {
901
+ for (const [gi, rowItem] of allGrouped.entries()) {
843
902
  if (!rowItem.run) {
844
- lines.push(row(fg("dim", `── ${rowItem.label} ──`)));
903
+ // `┣ ACTIVE ▸ 2` — the group label IS its run count.
904
+ let groupCount = 0;
905
+ for (let j = gi + 1; j < allGrouped.length && allGrouped[j]?.run; j++) groupCount++;
906
+ lines.push(section(rowItem.label, `${groupCount}`));
845
907
  continue;
846
908
  }
847
909
  const idx = selectable.findIndex((c) => c.run?.runId === rowItem.run?.runId);
@@ -849,13 +911,13 @@ export class RunDashboard implements DashboardComponent {
849
911
  const run = snap?.manifest ?? rowItem.run;
850
912
  const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce);
851
913
  const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
852
- const label = runLabel(run, idx === this.selected, this.options.snapshotCache, innerWidth - 2, snapshotOnce);
914
+ const label = runLabel(run, idx === this.selected, this.options.snapshotCache, budget, snapshotOnce);
853
915
  lines.push(row(applyStatusColor(this.theme, status, label)));
854
916
  }
855
917
  } else {
856
918
  // >8 runs: windowed list (group headers omitted to maximise run rows).
857
919
  const win = runListWindow(this.runScrollOffset, selectableCount);
858
- if (win.hasTop) lines.push(row(fg("dim", `↑ ${this.runScrollOffset} more above`)));
920
+ if (win.hasTop) lines.push(row(overflowHint(this.runScrollOffset, 0, this.theme)));
859
921
  for (let gi = this.runScrollOffset; gi < Math.min(this.runScrollOffset + win.slots, selectableCount); gi++) {
860
922
  const rowItem = selectable[gi];
861
923
  if (!rowItem?.run) continue;
@@ -863,11 +925,11 @@ export class RunDashboard implements DashboardComponent {
863
925
  const run = snap?.manifest ?? rowItem.run;
864
926
  const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce);
865
927
  const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
866
- const label = runLabel(run, gi === this.selected, this.options.snapshotCache, innerWidth - 2, snapshotOnce);
928
+ const label = runLabel(run, gi === this.selected, this.options.snapshotCache, budget, snapshotOnce);
867
929
  lines.push(row(applyStatusColor(this.theme, status, label)));
868
930
  }
869
931
  if (win.hasBottom)
870
- lines.push(row(fg("dim", `↓ ${selectableCount - (this.runScrollOffset + win.slots)} more below`)));
932
+ lines.push(row(overflowHint(0, selectableCount - (this.runScrollOffset + win.slots), this.theme)));
871
933
  }
872
934
 
873
935
  // Selected run detail — compact. PERF (2026-08-24): reuse the
@@ -883,8 +945,8 @@ export class RunDashboard implements DashboardComponent {
883
945
  const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache, snapshotOnce);
884
946
  const statusStr: RunStatus = isLikelyOrphanedActiveRun(r, agents) ? "stale" : (r.status as RunStatus);
885
947
  const selectedTasks = snap?.tasks ?? readRunTasks(r, this.options.snapshotCache, snapshotOnce);
886
- lines.push(sep());
887
- lines.push(row(`${fg("accent", "▸")} ${truncate(sanitizeLine(r.goal), innerWidth - 6)}`));
948
+ lines.push(section("run"));
949
+ lines.push(row(`${fg("accent", "▸")} ${truncate(sanitizeLine(r.goal), budget - 4)}`));
888
950
  // L-2: surface the failure/cancellation reason inline for terminal runs.
889
951
  const isTerminal = statusStr === "failed" || statusStr === "cancelled" || statusStr === "stopped";
890
952
  const reason = isTerminal ? summarizeTerminalReason(r, selectedTasks, snap?.cancellationReason) : undefined;
@@ -894,7 +956,7 @@ export class RunDashboard implements DashboardComponent {
894
956
  fg(
895
957
  "dim",
896
958
  sanitizeLine(
897
- ` ${r.team}/${r.workflow ?? "default"} · ${statusStr} · ${r.runId.slice(-10)}${reasonSuffix}`,
959
+ ` ${teamWorkflowLabel(r.team, r.workflow)} · ${statusStr} · ${r.runId.slice(-8)}${reasonSuffix}`,
898
960
  ),
899
961
  ),
900
962
  ),
@@ -929,9 +991,9 @@ export class RunDashboard implements DashboardComponent {
929
991
  ];
930
992
  const filteredPane = paneLines.filter((l) => l && !l.includes("(none)") && l.trim() !== "");
931
993
  if (filteredPane.length > 0) {
932
- lines.push(row(fg("dim", `── ${this.activePane} ──`)));
994
+ lines.push(section(this.activePane));
933
995
  for (const line of filteredPane.slice(0, 8)) {
934
- lines.push(colorizeStatusGlyphs(row(truncate(sanitizeLine(line), innerWidth - 2)), this.theme));
996
+ lines.push(colorizeStatusGlyphs(row(truncate(sanitizeLine(line), budget)), this.theme));
935
997
  }
936
998
  }
937
999
 
@@ -961,11 +1023,16 @@ export class RunDashboard implements DashboardComponent {
961
1023
  }
962
1024
  }
963
1025
  }
964
- const ctxStr = ctxPct != null ? `${Math.round(ctxPct)}% ctx` : "";
1026
+ // Footer: tokens + a RAIL gauge for the context window. The number
1027
+ // stays next to the bar — the bar reads the trend, the number is
1028
+ // what the user quotes.
965
1029
  const footerFields: string[] = [];
966
- if (tokStr) footerFields.push(padVis(tokStr, 10));
967
- if (ctxStr) footerFields.push(padVis(ctxStr, 9));
968
- if (footerFields.length) lines.push(row(fg("dim", footerFields.join(" · "))));
1030
+ if (tokStr) footerFields.push(fg("dim", tokStr));
1031
+ if (ctxPct != null && Number.isFinite(ctxPct)) {
1032
+ const ratio = Math.max(0, Math.min(1, ctxPct / 100));
1033
+ footerFields.push(`${gaugeBar(ratio, 10, this.theme)} ${fg("muted", `${Math.round(ctxPct)}% ctx`)}`);
1034
+ }
1035
+ if (footerFields.length) lines.push(row(footerFields.join(fg("dim", " · "))));
969
1036
  }
970
1037
  }
971
1038
 
@@ -976,8 +1043,7 @@ export class RunDashboard implements DashboardComponent {
976
1043
  if (this.activePane === "schedules") {
977
1044
  const jobs = this.scheduleJobs();
978
1045
  const now = this.scheduleNow();
979
- lines.push(sep());
980
- lines.push(row(fg("dim", "── schedules ──")));
1046
+ lines.push(section("schedules", `${jobs.length}`));
981
1047
  const paneLines = safeRenderPane("schedules", () =>
982
1048
  this.scheduleDetails && jobs.length > 0
983
1049
  ? renderScheduleDetails(jobs[Math.min(this.scheduleSelected, jobs.length - 1)], now)
@@ -987,7 +1053,7 @@ export class RunDashboard implements DashboardComponent {
987
1053
  }),
988
1054
  );
989
1055
  for (const line of paneLines.filter((l) => l && l.trim() !== "").slice(0, SCHEDULES_PANE_MAX_LINES)) {
990
- lines.push(colorizeStatusGlyphs(row(truncate(sanitizeLine(line), innerWidth - 2)), this.theme));
1056
+ lines.push(colorizeStatusGlyphs(row(truncate(sanitizeLine(line), budget)), this.theme));
991
1057
  }
992
1058
  if (this.scheduleDeleteArmed) {
993
1059
  const victim = jobs[Math.min(this.scheduleSelected, jobs.length - 1)];
@@ -995,16 +1061,26 @@ export class RunDashboard implements DashboardComponent {
995
1061
  lines.push(row(fg("warning", `⚠ X again to DELETE '${name}' · any other key cancels`)));
996
1062
  }
997
1063
  }
998
- lines.push(border("╰", "╯"));
1064
+ // Cap: the end of the surface + the close action (LAST, RAIL hint rule).
1065
+ lines.push(
1066
+ railLine(
1067
+ RAIL.close,
1068
+ "border",
1069
+ formatHint([
1070
+ ["r", "reload"],
1071
+ ["esc", "close"],
1072
+ ]),
1073
+ this.theme,
1074
+ budget,
1075
+ ),
1076
+ );
999
1077
  }
1000
1078
 
1001
1079
  const target = this.targetHeight();
1002
1080
  if (lines.length < target) {
1003
- const innerWidth = Math.max(20, width - 4);
1004
- const fg = (color: Parameters<CrewTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
1005
- const blankRow = `│ ${pad("", innerWidth - 1)}│`;
1081
+ const blankRow = railLine(RAIL.body, "border", "", this.theme, budget);
1006
1082
  const bottom = lines.pop();
1007
- while (lines.length < target - 1) lines.push(fg("border", blankRow));
1083
+ while (lines.length < target - 1) lines.push(blankRow);
1008
1084
  if (bottom) lines.push(bottom);
1009
1085
  } else if (lines.length > target) {
1010
1086
  const bottom = lines[lines.length - 1];
@@ -1050,7 +1126,16 @@ export class RunDashboard implements DashboardComponent {
1050
1126
  this.scheduleDeleteArmed = false;
1051
1127
  this.invalidate();
1052
1128
  }
1129
+ // US-020: the run-cancel confirm gate disarms on any non-cancel key
1130
+ // (schedule-delete precedent), and a stale refusal line clears.
1131
+ if (this.runCancelArmed && action !== "cancel") {
1132
+ this.runCancelArmed = false;
1133
+ this.invalidate();
1134
+ }
1135
+ if (this.runCancelRefused && action !== "cancel") this.runCancelRefused = false;
1053
1136
  const selectedRunId = this.selectedRunId();
1137
+ // US-020 AC-2: keep the cursor across close/reopen cycles.
1138
+ if (selectedRunId) lastSelectedRunId = selectedRunId;
1054
1139
  if (action === "close") {
1055
1140
  this.done(undefined);
1056
1141
  return;
@@ -1059,6 +1144,36 @@ export class RunDashboard implements DashboardComponent {
1059
1144
  this.done(selectedRunId ? { runId: selectedRunId, action: "status" } : undefined);
1060
1145
  return;
1061
1146
  }
1147
+ // US-020: cancel the SELECTED run — dedicated branch (plan-approve
1148
+ // precedent) so a stray keystroke can never close the dashboard via
1149
+ // done(undefined). 2-step confirm gate: first x arms (render shows the
1150
+ // warning line), second x dispatches the existing cancel channel; a
1151
+ // non-running selection refuses with a status line (AC-3) — the guard
1152
+ // mutation target.
1153
+ if (action === "cancel") {
1154
+ const run = selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache);
1155
+ const manifest = run ? (snapshotFor(run, this.options.snapshotCache)?.manifest ?? run) : undefined;
1156
+ if (this.runCancelArmed) {
1157
+ this.runCancelArmed = false;
1158
+ if (manifest && isActiveRunStatus(manifest.status)) {
1159
+ this.done({ runId: manifest.runId, action: "cancel" });
1160
+ } else {
1161
+ this.runCancelRefused = true;
1162
+ this.invalidate();
1163
+ }
1164
+ return;
1165
+ }
1166
+ this.runCancelRefused = manifest ? !isActiveRunStatus(manifest.status) : false;
1167
+ if (this.runCancelRefused) {
1168
+ this.invalidate();
1169
+ return;
1170
+ }
1171
+ if (manifest) {
1172
+ this.runCancelArmed = true;
1173
+ this.invalidate();
1174
+ }
1175
+ return;
1176
+ }
1062
1177
  // WP-3 (H4-subset): plan approval keys. Deliberately a DEDICATED branch
1063
1178
  // (not the generic union block below) because plan actions must gate on
1064
1179
  // the selected run actually being parked on a pending approval — a
@@ -1139,11 +1254,6 @@ export class RunDashboard implements DashboardComponent {
1139
1254
  this.done(selectedRunId ? { runId: selectedRunId, action: "browser-open" } : { runId: "", action: "browser-open" });
1140
1255
  return;
1141
1256
  }
1142
- if (action === "progressToggle") {
1143
- this.showFullProgress = !this.showFullProgress;
1144
- this.invalidate();
1145
- return;
1146
- }
1147
1257
  if (action === "pane-agents") this.activePane = "agents";
1148
1258
  else if (action === "pane-progress") this.activePane = "progress";
1149
1259
  else if (action === "pane-mailbox") this.activePane = "mailbox";
@@ -1156,10 +1266,14 @@ export class RunDashboard implements DashboardComponent {
1156
1266
  this.planDiff = !this.planDiff;
1157
1267
  this.invalidate();
1158
1268
  return;
1159
- } else if (action === "up") this.selected = Math.max(0, this.selected - 1);
1160
- else if (action === "down") {
1269
+ } else if (action === "up" || action === "down") {
1270
+ // US-020 AC-1: WRAP at the list edges (was: hard clamp). An empty list
1271
+ // must not divide by zero — stay at 0.
1161
1272
  const selectableCount = groupedRuns(this.runs, this.options.snapshotCache).filter((row) => row.run).length;
1162
- this.selected = Math.min(Math.max(0, selectableCount - 1), this.selected + 1);
1273
+ if (selectableCount > 0) {
1274
+ this.selected =
1275
+ action === "up" ? (this.selected - 1 + selectableCount) % selectableCount : (this.selected + 1) % selectableCount;
1276
+ }
1163
1277
  }
1164
1278
  if (action) {
1165
1279
  lastActivePane = this.activePane;
@@ -1173,7 +1287,7 @@ export class RunDashboard implements DashboardComponent {
1173
1287
  * actions + jobId for the extension layer to route through
1174
1288
  * handle-schedule.ts subActions — the UI layer never mutates jobs itself. */
1175
1289
  private handleSchedulePaneAction(action: DashboardKeyAction): void {
1176
- // Pane-local state toggles follow the progressToggle/plan-diff precedent:
1290
+ // Pane-local state toggles follow the plan-diff precedent:
1177
1291
  // invalidate() so the next render (sync in tests, host-tick in the TUI)
1178
1292
  // recomputes immediately.
1179
1293
  if (action === "schedule-details") {
@@ -27,6 +27,13 @@ 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
+ * F13 (RR-018): true once dispose() has run. Callers that retain the cache
32
+ * (RegistrationContext.getManifestCache/getRunSnapshotCache) use this to
33
+ * recreate a FRESH instance even when the cwd is unchanged — a disposed
34
+ * instance must never be handed back just because `cacheCwd` still matches.
35
+ */
36
+ isDisposed(): boolean;
30
37
  }
31
38
 
32
39
  /** WP-7 (R7): the plans slice + Plan pane load only when this flag is set —
@@ -840,6 +847,9 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
840
847
  const recentEventsLimit = options.recentEvents ?? DEFAULT_RECENT_EVENTS;
841
848
  const recentOutputLimit = options.recentOutputLines ?? DEFAULT_RECENT_OUTPUT_LINES;
842
849
  const entries = new Map<string, CacheEntry>();
850
+ // F13 (RR-018): liveness flag — dispose() flips it so cache holders can
851
+ // detect a disposed instance and recreate instead of reusing it.
852
+ let disposed = false;
843
853
 
844
854
  function touch(runId: string, entry: CacheEntry): RunUiSnapshot {
845
855
  entry.lastAccessMs = Date.now();
@@ -883,7 +893,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
883
893
  let tasks: TeamTaskState[];
884
894
  let agents: CrewAgentRecord[];
885
895
  try {
886
- // R10-4 (docs/refactor-plan.review.md §ROUND 10): sync/async parity.
896
+ // R10-4 (docs/archive/refactor-plan.review.md §ROUND 10): sync/async parity.
887
897
  // loadRunManifestById already returns tasks validated against the
888
898
  // current tasks.json (mtime+size+generation check in state-store),
889
899
  // so the old readTasks() re-read only doubled tasks.json I/O per
@@ -1143,9 +1153,16 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
1143
1153
  return new Map([...entries.entries()].map(([key, entry]) => [key, entry.snapshot]));
1144
1154
  },
1145
1155
  dispose(): void {
1156
+ // F13: idempotent — a second dispose must be a no-op, never a throw
1157
+ // (cleanup paths can race a lazy cache swap).
1158
+ if (disposed) return;
1159
+ disposed = true;
1146
1160
  unsubscribe();
1147
1161
  inFlightRefreshes.clear();
1148
1162
  entries.clear();
1149
1163
  },
1164
+ isDisposed(): boolean {
1165
+ return disposed;
1166
+ },
1150
1167
  };
1151
1168
  }