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
@@ -17,22 +17,45 @@
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 { ACTIVE, formatHint, statusIcon } from "../rail.ts";
21
+
22
+ /**
23
+ * The ONE plan-approval hint line (keys are case-SENSITIVE per
24
+ * keybinding-map.ts:75 — `A` approves, `n` denies, `X` shows the revision
25
+ * diff). `exactKeys` keeps the printed letters truthful; mapping them through
26
+ * the default `keyToken` would advertise `N`, which is not bound.
27
+ */
28
+ export const PLAN_APPROVAL_HINT = formatHint(
29
+ [
30
+ ["A", "approve"],
31
+ ["n", "deny"],
32
+ ],
33
+ { exactKeys: true },
34
+ );
35
+
20
36
  import type { RunUiSnapshot } from "../snapshot-types.ts";
37
+ import { PANE_THEME } from "./pane-theme.ts";
21
38
 
39
+ /**
40
+ * Glyph vocabulary — RAIL (`src/ui/rail.ts`) owns it (design system §2.G).
41
+ * `statusIcon()` covers every state rail names; the four task states it cannot
42
+ * express — `◷` waiting, `⚠` attention, `⊘` cancelled, `·` skipped: rail
43
+ * collapses all four into `○` — stay pane-local ON PURPOSE.
44
+ */
22
45
  const ITEM_GLYPH: Record<PlanItemStatus, string> = {
23
- pending: "○",
24
- active: "▸",
25
- done: "✓",
26
- dropped: "✗",
46
+ pending: statusIcon("queued", PANE_THEME),
47
+ active: ACTIVE,
48
+ done: statusIcon("completed", PANE_THEME),
49
+ dropped: statusIcon("failed", PANE_THEME),
27
50
  };
28
51
 
29
52
  const TASK_GLYPH: Record<string, string> = {
30
- queued: "○",
31
- running: "▸",
53
+ queued: statusIcon("queued", PANE_THEME),
54
+ running: ACTIVE,
32
55
  waiting: "◷",
33
56
  needs_attention: "⚠",
34
- completed: "✓",
35
- failed: "✗",
57
+ completed: statusIcon("completed", PANE_THEME),
58
+ failed: statusIcon("failed", PANE_THEME),
36
59
  cancelled: "⊘",
37
60
  skipped: "·",
38
61
  };
@@ -42,8 +65,8 @@ function taskLine(task: TeamTaskState, indent: string): string {
42
65
  // T3/R5 depth badge: grandchildren (depth 2+) surface explicitly so the
43
66
  // tree shows delegation nesting without another pane.
44
67
  const depth = typeof task.depth === "number" && task.depth > 1 ? ` d${task.depth}` : "";
45
- const role = task.displayName ?? task.role;
46
- return `${indent}${glyph} ${task.id}${depth} ${role} [${task.status}]`;
68
+ const role = task.displayName ?? task.role ?? "?";
69
+ return `${indent}${glyph} ${task.id ?? "?"}${depth} ${role} [${task.status}]`;
47
70
  }
48
71
 
49
72
  export interface PlanPaneOptions {
@@ -63,23 +86,25 @@ export function planRevisionDiff(snapshot: RunUiSnapshot): string[] {
63
86
  if (!previous) {
64
87
  return [`Plan diff: v${current.version} has no prior revision`];
65
88
  }
66
- const prevItems = new Map(previous.items.map((i) => [i.id, i]));
89
+ // Disk-sourced plan records are NOT schema-validated at read time — every
90
+ // array/field below is guarded (`?? []` / `?? "?"`).
91
+ const prevItems = new Map((previous.items ?? []).map((i) => [i.id, i]));
67
92
  const lines = [`Plan diff: v${previous.version} → v${current.version}`];
68
- for (const item of current.items) {
93
+ for (const item of current.items ?? []) {
69
94
  const before = prevItems.get(item.id);
70
95
  if (!before) {
71
- lines.push(` + ${item.id} ${item.title} [${item.status}]`);
96
+ lines.push(` + ${item.id} ${item.title ?? "?"} [${item.status}]`);
72
97
  continue;
73
98
  }
74
- if (before.status !== item.status || before.taskIds.length !== item.taskIds.length) {
75
- lines.push(
76
- ` ~ ${item.id} ${item.title} [${before.status}→${item.status} · ${before.taskIds.length}→${item.taskIds.length} tasks]`,
77
- );
99
+ const beforeTasks = before.taskIds?.length ?? 0;
100
+ const currentTasks = item.taskIds?.length ?? 0;
101
+ if (before.status !== item.status || beforeTasks !== currentTasks) {
102
+ lines.push(` ~ ${item.id} ${item.title ?? "?"} [${before.status}→${item.status} · ${beforeTasks}→${currentTasks} tasks]`);
78
103
  }
79
104
  prevItems.delete(item.id);
80
105
  }
81
106
  for (const dropped of prevItems.values()) {
82
- lines.push(` - ${dropped.id} ${dropped.title} (dropped in v${current.version})`);
107
+ lines.push(` - ${dropped.id} ${dropped.title ?? "?"} (dropped in v${current.version})`);
83
108
  }
84
109
  return lines;
85
110
  }
@@ -97,40 +122,48 @@ export function renderPlanPane(snapshot: RunUiSnapshot, options: PlanPaneOptions
97
122
  if (!current) return ["Plan pane: no plan records"];
98
123
  const progress = deriveItemProgress(current, snapshot.tasks);
99
124
  const tasksById = new Map(snapshot.tasks.map((t) => [t.id, t]));
100
- const itemById = new Map(current.items.map((i) => [i.id, i]));
125
+ // Disk-sourced plan records are NOT schema-validated at read time.
126
+ const phases = current.phases ?? [];
127
+ const items = current.items ?? [];
128
+ const itemById = new Map(items.map((i) => [i.id, i]));
101
129
 
102
130
  const pending = isPlanApprovalPending(snapshot.manifest);
103
- const header = `Plan pane: ${current.title} @v${current.version} (${current.phases.length} phases · ${current.items.length} items)`;
104
- const approval = pending ? ["⚠ plan approval pending — A approve · n deny"] : [];
131
+ const header = `Plan pane: ${current.title ?? "?"} @v${current.version} (${phases.length} phases · ${items.length} items)`;
132
+ // Hint join format is the rail one (` · `, close/deny last). NOT routed
133
+ // through `formatHint` (yet): its `keyToken` upper-cases a bare letter
134
+ // (`n` → `N`) and the locked pane test
135
+ // (test/unit/ui/plan-ui-slice.test.ts:159) asserts the lowercase spelling —
136
+ // see the E3 report / leader decision needed.
137
+ const approval = pending ? [`⚠ plan approval pending — ${PLAN_APPROVAL_HINT}`] : [];
105
138
 
106
139
  const lines: string[] = [header, ...approval];
107
- for (const phase of current.phases) {
140
+ for (const phase of phases) {
108
141
  const glyph = ITEM_GLYPH[phase.status] ?? "?";
109
- lines.push(`${glyph} ${phase.title}`);
110
- for (const itemId of phase.itemIds) {
142
+ lines.push(`${glyph} ${phase.title ?? "?"}`);
143
+ for (const itemId of phase.itemIds ?? []) {
111
144
  const item = itemById.get(itemId);
112
145
  if (!item) continue;
113
146
  const p = progress.get(itemId);
114
147
  const counts = p ? ` ${p.done}/${p.total}${p.failed ? ` ✗${p.failed}` : ""}${p.running ? ` ▸${p.running}` : ""}` : "";
115
148
  const droppedTag = item.status === "dropped" ? " ✗ dropped" : "";
116
- lines.push(` ${ITEM_GLYPH[item.status] ?? "?"} ${item.title}${counts}${droppedTag}`);
117
- for (const taskId of item.taskIds) {
149
+ lines.push(` ${ITEM_GLYPH[item.status] ?? "?"} ${item.title ?? "?"}${counts}${droppedTag}`);
150
+ for (const taskId of item.taskIds ?? []) {
118
151
  const task = tasksById.get(taskId);
119
152
  if (task) lines.push(taskLine(task, " "));
120
153
  }
121
154
  }
122
155
  }
123
156
  // Items not linked to any phase (producer-free-format plans) — still visible.
124
- const phased = new Set(current.phases.flatMap((p) => p.itemIds));
125
- const orphans = current.items.filter((i) => !phased.has(i.id));
157
+ const phased = new Set(phases.flatMap((p) => p.itemIds ?? []));
158
+ const orphans = items.filter((i) => !phased.has(i.id));
126
159
  if (orphans.length) {
127
160
  lines.push("(unphased)");
128
161
  for (const item of orphans) {
129
162
  const p = progress.get(item.id);
130
163
  const droppedTag = item.status === "dropped" ? " ✗ dropped" : "";
131
- lines.push(` ${ITEM_GLYPH[item.status] ?? "?"} ${item.title}${p ? ` ${p.done}/${p.total}` : ""}${droppedTag}`);
164
+ lines.push(` ${ITEM_GLYPH[item.status] ?? "?"} ${item.title ?? "?"}${p ? ` ${p.done}/${p.total}` : ""}${droppedTag}`);
132
165
  }
133
166
  }
134
- lines.push("X revision diff");
167
+ lines.push(formatHint([["X", "revision diff"]]));
135
168
  return lines;
136
169
  }
@@ -2,6 +2,7 @@ import { computePhaseProgress, formatPhaseProgressLine } from "../../runtime/pha
2
2
  import { isPlanApprovalPending } from "../../runtime/plan-approval.ts";
3
3
  import { renderDwfPhaseLines } from "../dwf-phase-display.ts";
4
4
  import type { RunUiSnapshot } from "../snapshot-types.ts";
5
+ import { PLAN_APPROVAL_HINT } from "./plan-pane.ts";
5
6
 
6
7
  export function renderProgressPane(snapshot: RunUiSnapshot | undefined): string[] {
7
8
  if (!snapshot) return ["Progress pane: snapshot unavailable"];
@@ -17,7 +18,7 @@ export function renderProgressPane(snapshot: RunUiSnapshot | undefined): string[
17
18
  ? runProgress.phases.map((p) => {
18
19
  const done = p.completed + p.failed;
19
20
  const status = p.running > 0 ? "running" : p.queued > 0 ? "queued" : done >= p.total ? "done" : "waiting";
20
- return ` Phase ${p.index + 1} ${p.phase}: ${p.percentage}% (${done}/${p.total}) [${status}]`;
21
+ return ` Phase ${p.index + 1} ${p.phase ?? "?"}: ${p.percentage}% (${done}/${p.total}) [${status}]`;
21
22
  })
22
23
  : [];
23
24
  const phaseHeader = phaseLines.length > 0 ? [formatPhaseProgressLine(runProgress), ...phaseLines] : [];
@@ -27,7 +28,7 @@ export function renderProgressPane(snapshot: RunUiSnapshot | undefined): string[
27
28
  // WP-3 (H4-subset): plan-approval gate banner. Mirrors the health-pane
28
29
  // hint pattern — plain foreground text, no color codes (pane output is
29
30
  // uncolored by design). One line while the run is parked on approval.
30
- const planBanner = isPlanApprovalPending(snapshot.manifest) ? ["⚠ plan approval pending — A approve / n deny"] : [];
31
+ const planBanner = isPlanApprovalPending(snapshot.manifest) ? [`⚠ plan approval pending — ${PLAN_APPROVAL_HINT}`] : [];
31
32
  return [
32
33
  `Progress pane: ${progress.completed}/${progress.total} completed · running=${progress.running} queued=${progress.queued} failed=${progress.failed}`,
33
34
  ...planBanner,
@@ -1,6 +1,9 @@
1
1
  import { humanizeSchedule, type ScheduledJob } from "../../runtime/scheduling/scheduler.ts";
2
2
  import { formatRelativeTime } from "../../utils/relative-time.ts";
3
3
  import { sanitizeLine, truncate } from "../../utils/visual.ts";
4
+ import { formatCount } from "../format-helpers.ts";
5
+ import { ACTIVE, CURSOR, formatHint, statusIcon } from "../rail.ts";
6
+ import { PANE_THEME } from "./pane-theme.ts";
4
7
 
5
8
  /**
6
9
  * Schedules pane (tier A foundation) — pure string[] renderer following
@@ -32,6 +35,16 @@ export interface SchedulesPaneOptions {
32
35
 
33
36
  export const SCHEDULES_EMPTY_STATE = "No scheduled jobs — create via team tool action='schedule'";
34
37
 
38
+ /** Pane-8 actions in the ONE rail hint format (`formatHint`: keys `label`
39
+ * pairs joined by ` · `). Order preserved from the pre-migration hint. */
40
+ const ACTION_HINTS: ReadonlyArray<readonly [string, string]> = [
41
+ ["T", "toggle"],
42
+ ["N", "run now"],
43
+ ["V", "details"],
44
+ ["X", "delete"],
45
+ ["R", "refresh"],
46
+ ];
47
+
35
48
  /** P2-1 hint line (single source of truth — pane table, empty state, and the
36
49
  * headless /schedules text block all render THIS text). Empty string when
37
50
  * there is nothing to hint (callers render no line). Pure: no clock, no I/O;
@@ -61,7 +74,7 @@ export function renderSchedulesPane(jobs: ScheduledJob[], now: Date, opts: Sched
61
74
  }
62
75
  if (hint) lines.push(hint);
63
76
  if (opts.foreground !== false) {
64
- lines.push("Actions: T toggle · N run now · V details · X delete · R refresh");
77
+ lines.push(`Actions: ${formatHint(ACTION_HINTS)}`);
65
78
  }
66
79
  return lines;
67
80
  }
@@ -82,26 +95,32 @@ export function renderSchedulesTextBlock(jobs: ScheduledJob[], now: Date, opts:
82
95
 
83
96
  function renderJobLines(job: ScheduledJob, now: Date, opts: SchedulesPaneOptions, selected: boolean): string[] {
84
97
  const glyph = job.enabled ? "●" : "○";
85
- const name = truncate(sanitizeLine(job.name), opts.nameWidth ?? 28);
98
+ const name = truncate(sanitizeLine(job.name ?? "?"), opts.nameWidth ?? 28);
86
99
  // The humanized schedule embeds the RAW job.schedule ("cron …"/"once at …")
87
100
  // — same untrusted persisted field the details view sanitizes, so the
88
101
  // main line gets the identical treatment (headless /schedules notify path
89
102
  // applies NO downstream sanitization — security review F-1).
90
- const schedule = sanitizeLine(humanizeSchedule({ kind: job.scheduleType, spec: job.schedule }));
103
+ const schedule = sanitizeLine(humanizeSchedule({ kind: job.scheduleType ?? "once", spec: job.schedule ?? "?" }));
91
104
  const next = job.nextRun ? formatRelativeTime(now, new Date(job.nextRun)) : "—";
92
- // Cursor marker (dashboard pane 8): `›` on the selected job, two spaces on
93
- // the others — mirroring runLabel's `›`/space marker. Callers that pass NO
94
- // selectedIndex (headless text block, foundation tests) get the marker-free
95
- // legacy layout byte-for-byte.
96
- const marker = opts.selectedIndex === undefined ? "" : selected ? "› " : " ";
97
- const main = `${marker}${glyph} ${name} ${schedule} · ${next} · ${statusGlyph(job.lastStatus)} · ${job.runCount} runs`;
105
+ // Cursor marker (dashboard pane 8): the RAIL `CURSOR` glyph on the selected
106
+ // job, two spaces on the others — mirroring runLabel's marker. Callers that
107
+ // pass NO selectedIndex (headless text block, foundation tests) get the
108
+ // marker-free legacy layout byte-for-byte.
109
+ const marker = opts.selectedIndex === undefined ? "" : selected ? `${CURSOR} ` : " ";
110
+ const main = `${marker}${glyph} ${name} ${schedule} · ${next} · ${statusGlyph(job.lastStatus)} · ${formatCount(job.runCount ?? 0, "run")}`;
98
111
  return [main, renderSubLine(job, now, opts)];
99
112
  }
100
113
 
114
+ /**
115
+ * Last-run glyph from the shared RAIL vocabulary. `lastStatus` spells
116
+ * success/error while `statusSlot`/`statusIcon` key off completed/failed, so
117
+ * the two are translated instead of forked; "never ran" keeps the pane's own
118
+ * `·` (rail has no such slot and would render `○`, the disabled/none glyph).
119
+ */
101
120
  function statusGlyph(status: ScheduledJob["lastStatus"]): string {
102
- if (status === "success") return "✓";
103
- if (status === "error") return "✗";
104
- if (status === "running") return "⟳";
121
+ if (status === "success") return statusIcon("completed", PANE_THEME);
122
+ if (status === "error") return statusIcon("failed", PANE_THEME);
123
+ if (status === "running") return statusIcon("running", PANE_THEME);
105
124
  return "·";
106
125
  }
107
126
 
@@ -110,12 +129,13 @@ function renderSubLine(job: ScheduledJob, now: Date, opts: SchedulesPaneOptions)
110
129
  // the dashboard emit pipeline sanitizes whole lines, but the headless
111
130
  // /schedules text block goes to ui.notify verbatim, and subagentType/id
112
131
  // from a crafted settings entry must not inject line breaks there.
113
- const parts = [sanitizeLine(job.subagentType)];
132
+ // Persisted jobs are NOT schema-validated at read time, hence the `?? "?"`.
133
+ const parts = [sanitizeLine(job.subagentType ?? "?")];
114
134
  parts.push(job.lastRun ? `last: ${formatRelativeTime(now, new Date(job.lastRun))}` : "last: never");
115
135
  // Duration: rendered only when the job model carries one ("duration if
116
136
  // available" in the approved design). ScheduledJob has no duration field
117
137
  // today, so nothing is invented here — a future field slots in below.
118
- if (opts.includeIds) parts.push(`id: ${sanitizeLine(job.id)}`);
138
+ if (opts.includeIds) parts.push(`id: ${sanitizeLine(job.id ?? "?")}`);
119
139
  return ` ◦ ${parts.join(" · ")}`;
120
140
  }
121
141
 
@@ -124,14 +144,14 @@ function renderSubLine(job: ScheduledJob, now: Date, opts: SchedulesPaneOptions)
124
144
  * carry free text — fall back to description, then name. */
125
145
  function scheduleGoalOf(job: ScheduledJob): string {
126
146
  try {
127
- const parsed = JSON.parse(job.prompt) as { goal?: unknown };
147
+ const parsed = JSON.parse(job.prompt ?? "") as { goal?: unknown };
128
148
  if (parsed && typeof parsed === "object" && typeof parsed.goal === "string" && parsed.goal.length > 0) {
129
149
  return parsed.goal;
130
150
  }
131
151
  } catch {
132
152
  /* prompt is not JSON — fall through */
133
153
  }
134
- return job.description || job.name;
154
+ return job.description || job.name || "?";
135
155
  }
136
156
 
137
157
  /**
@@ -141,14 +161,17 @@ function scheduleGoalOf(job: ScheduledJob): string {
141
161
  * dashboard emit pipeline truncates/sanitizes per line.
142
162
  */
143
163
  export function renderScheduleDetails(job: ScheduledJob, now: Date): string[] {
144
- const schedule = humanizeSchedule({ kind: job.scheduleType, spec: job.schedule });
164
+ // Persisted jobs are NOT schema-validated at read time (design system §2.G:
165
+ // "guard every optional field") — sanitize AFTER the `?? "?"` fallback so a
166
+ // missing field renders `?`, never `undefined`.
167
+ const schedule = humanizeSchedule({ kind: job.scheduleType ?? "once", spec: job.schedule ?? "?" });
145
168
  const next = job.nextRun ? formatRelativeTime(now, new Date(job.nextRun)) : "—";
146
169
  const last = job.lastRun ? formatRelativeTime(now, new Date(job.lastRun)) : "never";
147
170
  return [
148
- `▸ ${sanitizeLine(job.name)} — id ${sanitizeLine(job.id)}`,
171
+ `${ACTIVE} ${sanitizeLine(job.name ?? "?")} — id ${sanitizeLine(job.id ?? "?")}`,
149
172
  ` goal: ${sanitizeLine(scheduleGoalOf(job))}`,
150
- ` schedule: ${sanitizeLine(job.schedule)} (${sanitizeLine(job.scheduleType)}) · humanized: ${sanitizeLine(schedule)} · next: ${next}`,
151
- ` agent: ${sanitizeLine(job.subagentType)} · runs: ${job.runCount} · last: ${last} · status: ${statusGlyph(job.lastStatus)}`,
152
- ` spawned runs: ${job.spawnedRunIds?.length ? job.spawnedRunIds.map(sanitizeLine).join(", ") : "none"}`,
173
+ ` schedule: ${sanitizeLine(job.schedule ?? "?")} (${sanitizeLine(job.scheduleType ?? "?")}) · humanized: ${sanitizeLine(schedule)} · next: ${next}`,
174
+ ` agent: ${sanitizeLine(job.subagentType ?? "?")} · runs: ${job.runCount ?? 0} · last: ${last} · status: ${statusGlyph(job.lastStatus)}`,
175
+ ` spawned runs: ${job.spawnedRunIds?.length ? job.spawnedRunIds.map((runId) => sanitizeLine(String(runId ?? "?"))).join(", ") : "none"}`,
153
176
  ];
154
177
  }
@@ -1,3 +1,4 @@
1
+ import { formatHint } from "../rail.ts";
1
2
  import type { RunUiSnapshot } from "../snapshot-types.ts";
2
3
 
3
4
  /**
@@ -21,21 +22,26 @@ function modelAttemptLines(snapshot: RunUiSnapshot): string[] {
21
22
  const attempts = (task.modelAttempts ?? [])
22
23
  .map(
23
24
  (attempt) =>
24
- `${attempt.model} ${attempt.success ? "✓" : `✗${attempt.exitCode !== undefined ? `(${attempt.exitCode})` : ""}`}`,
25
+ `${attempt.model ?? "?"} ${attempt.success ? "✓" : `✗${attempt.exitCode !== undefined ? `(${attempt.exitCode})` : ""}`}`,
25
26
  )
26
27
  .join(" → ");
27
28
  const resolved = task.modelRouting?.resolved ? ` · resolved ${task.modelRouting.resolved}` : "";
28
- lines.push(` ${task.id} (${task.role})${resolved}: ${attempts}`);
29
+ lines.push(` ${task.id ?? "?"} (${task.role ?? "?"})${resolved}: ${attempts}`);
29
30
  }
30
31
  return lines;
31
32
  }
32
33
 
33
34
  export function renderTranscriptPane(snapshot: RunUiSnapshot | undefined): string[] {
34
35
  if (!snapshot) return ["Output pane: snapshot unavailable"];
36
+ const lines = snapshot.recentOutputLines ?? [];
35
37
  return [
36
- `Output pane: ${snapshot.recentOutputLines.length} recent lines · press v for transcript viewer · o for raw output`,
38
+ // Hint format owned by rail.ts (`formatHint`): keys `label` joined by ` · `.
39
+ `Output pane: ${lines.length} recent lines · press ${formatHint([
40
+ ["v", "for transcript viewer"],
41
+ ["o", "for raw output"],
42
+ ])}`,
37
43
  ...modelAttemptLines(snapshot),
38
- ...snapshot.recentOutputLines.slice(-12).map((line) => `⎿ ${line}`),
39
- ...(snapshot.recentOutputLines.length ? [] : ["No recent output"]),
44
+ ...lines.slice(-12).map((line) => `⎿ ${line}`),
45
+ ...(lines.length ? [] : ["No recent output"]),
40
46
  ];
41
47
  }
@@ -30,11 +30,6 @@ export interface DwfPhaseState {
30
30
  currentPhase: string | null;
31
31
  }
32
32
 
33
- export interface RenderDwfPhaseOptions {
34
- /** When true, render ASCII fallback markers instead of Unicode glyphs. */
35
- ascii?: boolean;
36
- }
37
-
38
33
  // ---------------------------------------------------------------------------
39
34
  // Markers
40
35
  // ---------------------------------------------------------------------------
@@ -44,19 +39,9 @@ const MARKER_RUNNING = "▶";
44
39
  const MARKER_COMPLETED = "✓";
45
40
  const MARKER_PENDING = "⏸";
46
41
 
47
- // ASCII fallbacks for terminals that mis-render the Unicode glyphs above.
48
- const MARKER_RUNNING_ASCII = "[>]";
49
- const MARKER_COMPLETED_ASCII = "[v]";
50
- const MARKER_PENDING_ASCII = "[ ]";
51
-
52
42
  const DWF_PHASE_HEADER = " ── DWF Phases ──";
53
43
 
54
- function markerFor(status: DwfPhaseStatus, ascii: boolean): string {
55
- if (ascii) {
56
- if (status === "running") return MARKER_RUNNING_ASCII;
57
- if (status === "completed") return MARKER_COMPLETED_ASCII;
58
- return MARKER_PENDING_ASCII;
59
- }
44
+ function markerFor(status: DwfPhaseStatus): string {
60
45
  if (status === "running") return MARKER_RUNNING;
61
46
  if (status === "completed") return MARKER_COMPLETED;
62
47
  return MARKER_PENDING;
@@ -136,16 +121,14 @@ export function extractDwfPhaseState(events: TeamEvent[]): DwfPhaseState | null
136
121
  *
137
122
  * - One line per phase: ` ▶ Phase: Scan`, ` ✓ Phase: Scan`, ` ⏸ Phase: Review`.
138
123
  * - A grouping header is emitted only when more than one phase is present.
139
- * - When `options.ascii` is true, ASCII fallback markers are used.
140
124
  *
141
125
  * Always returns a non-empty array (the caller guarantees a non-null state).
142
126
  */
143
- export function renderDwfPhaseLines(state: DwfPhaseState, options?: RenderDwfPhaseOptions): string[] {
144
- const ascii = options?.ascii === true;
127
+ export function renderDwfPhaseLines(state: DwfPhaseState): string[] {
145
128
  const lines: string[] = [];
146
129
  if (state.phases.length > 1) lines.push(DWF_PHASE_HEADER);
147
130
  for (const entry of state.phases) {
148
- lines.push(` ${markerFor(entry.status, ascii)} Phase: ${entry.name}`);
131
+ lines.push(` ${markerFor(entry.status)} Phase: ${entry.name}`);
149
132
  }
150
133
  return lines;
151
134
  }
@@ -29,3 +29,25 @@ export function truncLine(text: string, maxWidth: number): string {
29
29
  // respects double-wide CJK + surrogate pairs, adds the '…' ellipsis).
30
30
  return truncateToWidth(text, maxWidth);
31
31
  }
32
+
33
+ /**
34
+ * `1 tool` / `3 tools` — count label with correct singular/plural.
35
+ *
36
+ * Live-run data surfaced `1 tools` in five different surfaces (tool card,
37
+ * dashboard agent rows, live-run sidebar, live conversation overlay): every
38
+ * call site hard-coded the plural. One helper, one rule.
39
+ */
40
+ export function formatCount(count: number, singular: string, plural?: string): string {
41
+ return `${count} ${count === 1 ? singular : (plural ?? `${singular}s`)}`;
42
+ }
43
+
44
+ /**
45
+ * `team` / `team/workflow` — collapse the workflow half when it repeats the
46
+ * team. Live runs carry both (`fast-fix` + `fast-fix`) and every surface that
47
+ * printed them naively rendered `fast-fix/fast-fix` (sidebar, dashboard).
48
+ */
49
+ export function teamWorkflowLabel(team: string | undefined, workflow: string | undefined): string {
50
+ const t = team && team.length > 0 ? team : "?";
51
+ const w = workflow && workflow.length > 0 ? workflow : undefined;
52
+ return !w || w === t ? t : `${t}/${w}`;
53
+ }
@@ -32,6 +32,32 @@ function isActiveTask(task: TeamTaskState): boolean {
32
32
  return task.status === "running";
33
33
  }
34
34
 
35
+ /**
36
+ * FINDING 5 (2026-09-23 battery): health ticks must count task statuses from
37
+ * DISK truth, not the (possibly lagging) snapshot cache. A worker parked on
38
+ * `ask` transitions running → waiting on disk while the cached snapshot can
39
+ * still show `running` — in that window the parked worker counted as
40
+ * active-without-heartbeat and fired a false "dead worker" notification (live:
41
+ * run team_20260923100114, 01_explore parked on ask, later answered+resumed).
42
+ *
43
+ * Overlay FRESH task statuses (and heartbeats) from disk onto the cached
44
+ * snapshot before summarizing. Returns the ORIGINAL snapshot object when
45
+ * nothing diverged (cheap identity check — callers use it to decide cache
46
+ * invalidation), or a shallow copy with the diverging tasks replaced.
47
+ */
48
+ export function overlayFreshTaskStatuses(snapshot: RunUiSnapshot, freshTasks: TeamTaskState[]): RunUiSnapshot {
49
+ if (freshTasks.length === 0) return snapshot;
50
+ const freshById = new Map(freshTasks.map((task) => [task.id, task]));
51
+ let diverged = false;
52
+ const tasks = snapshot.tasks.map((task) => {
53
+ const fresh = freshById.get(task.id);
54
+ if (!fresh || (fresh.status === task.status && fresh.heartbeat === task.heartbeat)) return task;
55
+ diverged = true;
56
+ return { ...task, status: fresh.status, heartbeat: fresh.heartbeat };
57
+ });
58
+ return diverged ? { ...snapshot, tasks } : snapshot;
59
+ }
60
+
35
61
  export function summarizeHeartbeats(snapshot: RunUiSnapshot, opts: HeartbeatSummaryOptions = {}): HeartbeatSummary {
36
62
  const staleMs = opts.staleMs ?? 60_000;
37
63
  const deadMs = opts.deadMs ?? 5 * 60_000;
@@ -55,6 +81,14 @@ export function summarizeHeartbeats(snapshot: RunUiSnapshot, opts: HeartbeatSumm
55
81
  const runTerminal = isTerminalRunStatus(snapshot.manifest.status);
56
82
  for (const task of snapshot.tasks) {
57
83
  if (runTerminal || !isActiveTask(task)) continue;
84
+ // Guest-child tasks (delegate subagents, agent === "delegate") have no
85
+ // heartbeat channel — they never write task.heartbeat and complete in
86
+ // seconds; their lifecycle is owned by delegate.requested/admitted/
87
+ // completed broker events. Counting them here fired a false "N worker(s)
88
+ // missing heartbeat" ambient for every delegate-using run (live:
89
+ // team_20260926033657_2b6c6d2610b26d9d, 2026-09-26 battery — same
90
+ // gc-blindness root shape as the heartbeat-watcher fix).
91
+ if (task.agent === "delegate") continue;
58
92
  const heartbeat = task.heartbeat;
59
93
  if (!heartbeat) {
60
94
  summary.missing += 1;
@@ -24,13 +24,23 @@
24
24
  import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
25
25
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
26
26
  import { type EditorTheme, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
27
-
27
+ import { ACTIVE } from "../rail.ts";
28
28
  import type { PanelKeys, PanelTarget } from "./panel-selection.ts";
29
29
  import { dispatchPanelKey } from "./panel-selection.ts";
30
30
  import { getPanelSelection, getViewedAgent, panelRows, setPanelSelection } from "./panel-store.ts";
31
31
 
32
32
  export const AGENT_LABEL_MAX = 24;
33
33
 
34
+ /**
35
+ * `name` as it paints in the editor border: whitespace collapsed to one line
36
+ * (the label is spliced INTO the editor's top border, so a newline would tear
37
+ * the frame) and the legacy `role->agent` separator normalized to the RAIL
38
+ * active marker (§4 consistency fix). The mechanism itself is untouched.
39
+ */
40
+ export function agentBorderLabel(name: string): string {
41
+ return truncateToWidth(name.replace(/->/g, ACTIVE).replace(/\s+/g, " ").trim(), AGENT_LABEL_MAX);
42
+ }
43
+
34
44
  export interface CrewEditorOptions {
35
45
  /** Open the transcript pane on the given agent. */
36
46
  onOpenPane: (target: PanelTarget) => void;
@@ -192,9 +202,9 @@ export class CrewInlineEditor extends CustomEditor {
192
202
  if (!viewed) return lines;
193
203
  const rows = panelRows();
194
204
  const row = rows.find((r) => r.runId === viewed.runId && r.taskId === viewed.taskId);
195
- const name = row?.name ?? viewed.taskId.slice(-AGENT_LABEL_MAX);
205
+ const name = agentBorderLabel(row?.name ?? viewed.taskId.slice(-AGENT_LABEL_MAX));
196
206
  if (name && lines.length > 0) {
197
- const label = ` @${truncateToWidth(name.replace(/\s+/g, " "), AGENT_LABEL_MAX)} `;
207
+ const label = ` @${name} `;
198
208
  const labelWidth = visibleWidth(label);
199
209
  if (visibleWidth(lines[0]) >= labelWidth + 4) {
200
210
  lines[0] = truncateToWidth(lines[0], width - labelWidth - 2, "") + label + "──";
@@ -66,6 +66,42 @@ function stopPaneTicker(): void {
66
66
  livePane = undefined;
67
67
  }
68
68
 
69
+ /**
70
+ * Tear the live view down completely: close the overlay (which also detaches
71
+ * its pane), stop the repaint ticker, and null every module-level ref.
72
+ *
73
+ * Why this must run on a session swap (P1-10): the overlay factory and its
74
+ * `close`/`steer` callbacks close over the `ctx` they were opened with. If the
75
+ * overlay survived `session_start`, those closures would keep the OLD
76
+ * session's `ctx.cwd` and its dead `ctx.ui.custom.done` — view/steer would
77
+ * quietly target the previous workspace (`openPane`'s try/catch hides the
78
+ * throw instead of surfacing it). Nothing here touches the run: only the view
79
+ * and its repaint timer.
80
+ */
81
+ function teardownLiveView(): void {
82
+ const overlay = liveOverlay;
83
+ if (overlay) {
84
+ // The overlay owns its own teardown (refs + viewed agent + host done()).
85
+ // A ctx that is already stale can throw out of done(): log, never rethrow.
86
+ try {
87
+ overlay.requestClose();
88
+ } catch (error) {
89
+ logInternalError("view.teardownOverlay", error);
90
+ }
91
+ // Belt and braces: requestClose only unmounts through the host. dispose()
92
+ // makes render()/handleInput() inert, so even a host that ignored done()
93
+ // cannot route another keystroke into the old session's ctx.
94
+ try {
95
+ overlay.dispose();
96
+ } catch {
97
+ /* already disposed by the host */
98
+ }
99
+ }
100
+ liveOverlay = undefined;
101
+ // Also clears `livePane`, so a ticker that somehow survived has no target.
102
+ stopPaneTicker();
103
+ }
104
+
69
105
  async function runTeamTool(params: Record<string, unknown>, ctx: ExtensionContext): Promise<PiTeamsToolResult> {
70
106
  // LAZY: team-tool.ts pulls in the entire runtime chain (same boundary as
71
107
  // run-action-dispatcher.ts).
@@ -275,11 +311,13 @@ export function installInlinePanel(pi: ExtensionAPI, ctx: ExtensionContext, uiCo
275
311
  pi.on("session_start", () => {
276
312
  // pi may have replaced or dropped its editor between sessions; the
277
313
  // "installed" flag is reset so the next install re-registers the
278
- // factory. The open overlay outlives the session swap (it belongs
279
- // to the interactive-mode UI, not the session's ctx) — keep it, but
280
- // stop ticking until it is re-targeted.
314
+ // factory. The open overlay does NOT outlive the swap: its
315
+ // close/steer callbacks close over the previous session's ctx
316
+ // (cwd / ui.custom.done), so keeping it would make view and steer
317
+ // target the OLD workspace behind a try/catch. Close + dispose it
318
+ // and null the refs instead.
281
319
  editorInstalled = false;
282
- stopPaneTicker();
320
+ teardownLiveView();
283
321
  });
284
322
  }
285
323
  }
@@ -291,6 +329,24 @@ export function __resetInlinePanelForTest(): void {
291
329
  stopPaneTicker();
292
330
  }
293
331
 
332
+ /**
333
+ * Test seam: the live-view module state. Exposes the raw refs so a test can
334
+ * assert that a session swap left no `ctx`-holding closure behind, and whether
335
+ * the repaint ticker is still armed.
336
+ */
337
+ export function __test__liveViewState(): {
338
+ liveOverlay: CrewAgentOverlay | undefined;
339
+ livePane: CrewAgentPane | undefined;
340
+ ticking: boolean;
341
+ } {
342
+ return { liveOverlay, livePane, ticking: paneTickTimer !== undefined };
343
+ }
344
+
345
+ /** Test seam: run the session-swap teardown directly (same code the hook runs). */
346
+ export function __test__teardownLiveView(): void {
347
+ teardownLiveView();
348
+ }
349
+
294
350
  /** Test seam for openPane (the panel wires it as the dock's Enter action). */
295
351
  /** Test seam for closePane. */
296
352
  export { closePane as __test__closePane, openPane as __test__openPane };