pi-crew 0.6.1 → 0.6.4

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 (119) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +81 -33
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/docs/ui-optimization-plan.md +447 -0
  6. package/package.json +2 -2
  7. package/src/config/config.ts +106 -15
  8. package/src/errors.ts +107 -0
  9. package/src/extension/async-notifier.ts +6 -2
  10. package/src/extension/crew-cleanup.ts +8 -5
  11. package/src/extension/management.ts +464 -109
  12. package/src/extension/register.ts +213 -35
  13. package/src/extension/registration/brief-tool-overrides.ts +400 -0
  14. package/src/extension/registration/commands.ts +27 -2
  15. package/src/extension/registration/subagent-helpers.ts +2 -2
  16. package/src/extension/registration/subagent-tools.ts +9 -4
  17. package/src/extension/registration/team-tool.ts +17 -10
  18. package/src/extension/registration/viewers.ts +2 -2
  19. package/src/extension/team-tool/api.ts +3 -3
  20. package/src/extension/team-tool/cancel.ts +3 -3
  21. package/src/extension/team-tool/explain.ts +1 -1
  22. package/src/extension/team-tool/handle-schedule.ts +40 -0
  23. package/src/extension/team-tool/inspect.ts +3 -3
  24. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  25. package/src/extension/team-tool/respond.ts +2 -2
  26. package/src/extension/team-tool/run.ts +64 -14
  27. package/src/extension/team-tool/status.ts +1 -1
  28. package/src/extension/team-tool-types.ts +2 -0
  29. package/src/extension/team-tool.ts +173 -46
  30. package/src/hooks/registry.ts +77 -13
  31. package/src/hooks/types.ts +9 -0
  32. package/src/plugins/plugin-define.ts +6 -0
  33. package/src/plugins/plugin-registry.ts +32 -0
  34. package/src/plugins/plugins/index.ts +3 -0
  35. package/src/plugins/plugins/nextjs.ts +19 -0
  36. package/src/plugins/plugins/vite.ts +14 -0
  37. package/src/plugins/plugins/vitest.ts +14 -0
  38. package/src/runtime/async-runner.ts +35 -7
  39. package/src/runtime/background-runner.ts +529 -144
  40. package/src/runtime/chain-parser.ts +5 -1
  41. package/src/runtime/chain-runner.ts +9 -3
  42. package/src/runtime/checkpoint.ts +262 -180
  43. package/src/runtime/child-pi.ts +164 -37
  44. package/src/runtime/crash-recovery.ts +25 -14
  45. package/src/runtime/crew-agent-records.ts +2 -0
  46. package/src/runtime/diagnostic-export.ts +1 -1
  47. package/src/runtime/dynamic-script-runner.ts +10 -7
  48. package/src/runtime/foreground-watchdog.ts +1 -1
  49. package/src/runtime/heartbeat-watcher.ts +19 -2
  50. package/src/runtime/intercom-bridge.ts +7 -0
  51. package/src/runtime/iteration-hooks.ts +1 -1
  52. package/src/runtime/manifest-cache.ts +4 -0
  53. package/src/runtime/orphan-worker-registry.ts +444 -0
  54. package/src/runtime/parent-guard.ts +70 -16
  55. package/src/runtime/pi-args.ts +437 -14
  56. package/src/runtime/pi-spawn.ts +1 -0
  57. package/src/runtime/post-checks.ts +1 -1
  58. package/src/runtime/retry-runner.ts +9 -3
  59. package/src/runtime/run-tracker.ts +38 -10
  60. package/src/runtime/sandbox.ts +17 -2
  61. package/src/runtime/scheduler.ts +25 -2
  62. package/src/runtime/skill-effectiveness.ts +105 -62
  63. package/src/runtime/skill-instructions.ts +110 -32
  64. package/src/runtime/stale-reconciler.ts +310 -69
  65. package/src/runtime/subagent-manager.ts +251 -57
  66. package/src/runtime/task-health.ts +76 -0
  67. package/src/runtime/task-id.ts +20 -0
  68. package/src/runtime/task-runner/live-executor.ts +1 -1
  69. package/src/runtime/task-runner/progress.ts +1 -1
  70. package/src/runtime/task-runner/state-helpers.ts +110 -6
  71. package/src/runtime/task-runner.ts +92 -70
  72. package/src/runtime/team-runner.ts +186 -20
  73. package/src/schema/team-tool-schema.ts +27 -9
  74. package/src/skills/discover-skills.ts +9 -3
  75. package/src/state/active-run-registry.ts +170 -38
  76. package/src/state/artifact-store.ts +25 -13
  77. package/src/state/atomic-write-v2.ts +86 -0
  78. package/src/state/atomic-write.ts +346 -55
  79. package/src/state/blob-store.ts +178 -10
  80. package/src/state/crew-init.ts +161 -28
  81. package/src/state/decision-ledger.ts +172 -111
  82. package/src/state/event-log-rotation.ts +82 -52
  83. package/src/state/event-log.ts +254 -70
  84. package/src/state/health-store.ts +71 -0
  85. package/src/state/locks.ts +102 -20
  86. package/src/state/mailbox.ts +45 -7
  87. package/src/state/observation-store.ts +4 -1
  88. package/src/state/run-graph.ts +141 -130
  89. package/src/state/run-metrics.ts +24 -8
  90. package/src/state/session-state-map.ts +51 -0
  91. package/src/state/state-store.ts +330 -43
  92. package/src/ui/live-run-sidebar.ts +1 -1
  93. package/src/ui/loaders.ts +4 -0
  94. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  95. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  96. package/src/ui/powerbar-publisher.ts +1 -1
  97. package/src/ui/run-action-dispatcher.ts +2 -2
  98. package/src/ui/run-snapshot-cache.ts +1 -1
  99. package/src/ui/status-colors.ts +5 -1
  100. package/src/ui/theme-adapter.ts +80 -1
  101. package/src/ui/tool-progress-formatter.ts +9 -5
  102. package/src/ui/tool-render.ts +4 -0
  103. package/src/ui/tool-renderers/brief-mode.ts +207 -0
  104. package/src/ui/tool-renderers/index.ts +627 -0
  105. package/src/ui/widget/index.ts +224 -0
  106. package/src/ui/widget/widget-formatters.ts +148 -0
  107. package/src/ui/widget/widget-model.ts +90 -0
  108. package/src/ui/widget/widget-renderer.ts +130 -0
  109. package/src/ui/widget/widget-types.ts +37 -0
  110. package/src/utils/env-filter.ts +66 -0
  111. package/src/utils/file-coalescer.ts +9 -1
  112. package/src/utils/guards.ts +110 -0
  113. package/src/utils/paths.ts +80 -5
  114. package/src/utils/redaction.ts +6 -1
  115. package/src/utils/safe-paths.ts +281 -10
  116. package/src/worktree/cleanup.ts +112 -24
  117. package/src/worktree/worktree-manager.ts +128 -15
  118. package/test-bugs-all.mjs +10 -6
  119. package/test-integration-check.ts +4 -0
@@ -0,0 +1,627 @@
1
+ /**
2
+ * Tool renderer registry — visually rich rendering for team & agent tools.
3
+ *
4
+ * Uses box-drawing chars, progress bars, colored badges, and structured
5
+ * layouts to create a distinctly different TUI appearance.
6
+ * Uses visibleWidth() for ANSI-aware padding so borders align correctly.
7
+ */
8
+
9
+ import { Container, Text } from "@earendil-works/pi-tui";
10
+ import { visibleWidth } from "@earendil-works/pi-tui";
11
+ import type { CrewTheme } from "../theme-adapter.ts";
12
+ import { truncLine, formatTokens, formatDuration } from "../tool-render.ts";
13
+ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
14
+ import { isBrief, briefToolResult } from "./brief-mode.ts";
15
+
16
+ // ── Types ──────────────────────────────────────────────────────────────
17
+
18
+ export interface ToolRenderContext {
19
+ expanded: boolean;
20
+ lastComponent?: Container;
21
+ width?: number;
22
+ isPartial?: boolean;
23
+ isError?: boolean;
24
+ }
25
+
26
+ export interface ToolRenderer {
27
+ renderCall(args: Record<string, unknown>, theme: CrewTheme, ctx: ToolRenderContext): Component;
28
+ renderResult(result: Record<string, unknown>, options: unknown, theme: CrewTheme, ctx: ToolRenderContext): Component;
29
+ }
30
+
31
+ export type Component = Container | Text;
32
+
33
+ // ── ANSI-aware padding ─────────────────────────────────────────────────
34
+
35
+ /** Pad a string (which may contain ANSI codes) to a target VISUAL width. */
36
+ function padVisual(str: string, targetWidth: number): string {
37
+ const vw = visibleWidth(str);
38
+ if (vw >= targetWidth) return str;
39
+ return str + " ".repeat(targetWidth - vw);
40
+ }
41
+
42
+ /** Truncate a string (which may contain ANSI codes) to a target VISUAL width. */
43
+ function truncVisual(str: string, maxWidth: number): string {
44
+ if (visibleWidth(str) <= maxWidth) return str;
45
+ // Strip ANSI to truncate safely, then caller re-colors
46
+ const stripped = str.replace(/\x1b\[[0-9;]*m/g, "");
47
+ return stripped.slice(0, maxWidth);
48
+ }
49
+
50
+ // ── Visual primitives ──────────────────────────────────────────────────
51
+
52
+ /** Status icon with color */
53
+ function statusBadge(status: string, theme: CrewTheme): string {
54
+ switch (status) {
55
+ case "completed": return theme.fg("success", "●");
56
+ case "failed":
57
+ case "cancelled": return theme.fg("error", "✖");
58
+ case "running": return theme.fg("warning", "◉");
59
+ default: return theme.fg("dim", "○");
60
+ }
61
+ }
62
+
63
+ /** Status icon — compact */
64
+ export function statusIcon(status: string, theme: CrewTheme): string {
65
+ switch (status) {
66
+ case "completed": return theme.fg("success", "✓");
67
+ case "failed":
68
+ case "cancelled": return theme.fg("error", "✗");
69
+ case "running": return theme.fg("warning", "⟳");
70
+ default: return theme.fg("dim", "○");
71
+ }
72
+ }
73
+
74
+ /** Short run ID */
75
+ function shortId(id: string | undefined): string {
76
+ return id ? id.slice(-8) : "????????";
77
+ }
78
+
79
+ /** Border color based on status */
80
+ function borderColorForStatus(status: string): "success" | "error" | "border" {
81
+ switch (status) {
82
+ case "completed": return "success";
83
+ case "failed":
84
+ case "cancelled": return "error";
85
+ default: return "border";
86
+ }
87
+ }
88
+
89
+ /** Build a visual progress bar: ██████░░░░░░ 60% */
90
+ function progressBar(ratio: number, barWidth: number, theme: CrewTheme): string {
91
+ const clamped = Math.max(0, Math.min(1, ratio));
92
+ const filled = Math.round(clamped * barWidth);
93
+ const empty = barWidth - filled;
94
+ const bar = theme.fg("success", "█".repeat(filled)) + theme.fg("dim", "░".repeat(empty));
95
+ const pct = theme.fg("text", ` ${Math.round(clamped * 100)}%`.padStart(5));
96
+ return bar + pct;
97
+ }
98
+
99
+ // ── Frame builder ──────────────────────────────────────────────────────
100
+
101
+ /** Create a rounded-corner framed card.
102
+ * With renderShell="self", Pi no longer wraps in Box(1,1).
103
+ * Frame uses full totalWidth.
104
+ */
105
+ function buildFrame(contentLines: string[], totalWidth: number, theme: CrewTheme, borderSlot: "success" | "error" | "border" | "borderAccent" = "border"): string {
106
+ const frameW = totalWidth - 2; // available after Box(1,1) padding
107
+ const innerW = frameW - 2; // │ chars
108
+ const top = theme.fg(borderSlot, `╭${"─".repeat(innerW)}╮`);
109
+ const bottom = theme.fg(borderSlot, `╰${"─".repeat(innerW)}╯`);
110
+ const v = theme.fg(borderSlot, "│");
111
+
112
+ const lines: string[] = [top];
113
+ for (const line of contentLines) {
114
+ const padded = padVisual(line, innerW);
115
+ lines.push(v + padded + v);
116
+ }
117
+ lines.push(bottom);
118
+ return lines.join("\n");
119
+ }
120
+
121
+ /** Build frame TOP: top border + content lines, NO bottom border.
122
+ * Pairs with buildFrameBottom() so renderCall + renderResult merge into ONE frame. */
123
+ function buildFrameTop(contentLines: string[], totalWidth: number, theme: CrewTheme, borderSlot: "success" | "error" | "border" | "borderAccent" = "border"): string {
124
+ const frameW = totalWidth - 2;
125
+ const innerW = frameW - 2;
126
+ const top = theme.fg(borderSlot, `╭${"─".repeat(innerW)}╮`);
127
+ const v = theme.fg(borderSlot, "│");
128
+ const lines: string[] = [top];
129
+ for (const line of contentLines) {
130
+ lines.push(v + padVisual(line, innerW) + v);
131
+ }
132
+ return lines.join("\n");
133
+ }
134
+
135
+ /** Build frame BOTTOM: content lines + bottom border, NO top border.
136
+ * Pairs with buildFrameTop() so renderCall + renderResult merge into ONE frame. */
137
+ function buildFrameBottom(contentLines: string[], totalWidth: number, theme: CrewTheme, borderSlot: "success" | "error" | "border" | "borderAccent" = "border"): string {
138
+ const frameW = totalWidth - 2;
139
+ const innerW = frameW - 2;
140
+ const v = theme.fg(borderSlot, "│");
141
+ const bottom = theme.fg(borderSlot, `╰${"─".repeat(innerW)}╯`);
142
+ const lines: string[] = [];
143
+ for (const line of contentLines) {
144
+ lines.push(v + padVisual(line, innerW) + v);
145
+ }
146
+ lines.push(bottom);
147
+ return lines.join("\n");
148
+ }
149
+
150
+ /** Derive border color from context so renderCall's top matches renderResult's bottom. */
151
+ function borderFromContext(ctx: ToolRenderContext): "success" | "error" | "borderAccent" {
152
+ if (ctx.isPartial) return "borderAccent";
153
+ if (ctx.isError) return "error";
154
+ return "success";
155
+ }
156
+
157
+ // ── Team Tool Renderer ─────────────────────────────────────────────────
158
+
159
+ export const teamToolRenderer: ToolRenderer = {
160
+ renderCall(args, theme, ctx) {
161
+ const action = args.action as string ?? "";
162
+ const goal = args.goal as string ?? "";
163
+ const team = args.team as string | undefined;
164
+ const w = (ctx.width || process.stdout.columns || 116);
165
+ const innerW = w - 4;
166
+
167
+ const contentLines: string[] = [];
168
+
169
+ // Header: action badge + team name
170
+ const actionBadge = theme.fg("accent", `◀ ${action.toUpperCase()} ▶`);
171
+ const teamLabel = team ? ` ${theme.fg("dim", `via ${team}`)}` : "";
172
+ const header = ` ${actionBadge}${teamLabel}`;
173
+ contentLines.push(padVisual(header, innerW));
174
+
175
+ // Goal preview (P5: shorten paths)
176
+ if (goal) {
177
+ const maxLen = innerW - 2;
178
+ const preview = shortenPath(goal.replace(/\n/g, " "));
179
+ const previewText = visibleWidth(preview) > maxLen ? truncVisual(preview, maxLen - 1) + "…" : preview;
180
+ contentLines.push(padVisual(` ${theme.fg("dim", previewText)}`, innerW));
181
+ }
182
+
183
+ return new Text(buildFrameTop(contentLines, w, theme, borderFromContext(ctx)), 0, 0);
184
+ },
185
+
186
+ renderResult(result, _options, theme, ctx) {
187
+ try {
188
+ const text = renderTeamResult(result, _options, theme, ctx);
189
+ return new Text(text, 0, 0);
190
+ } catch {
191
+ return new Text(statusIcon("completed", theme) + " done", 0, 0);
192
+ }
193
+ },
194
+ };
195
+
196
+ function renderTeamResult(result: Record<string, unknown>, options: unknown, theme: CrewTheme, ctx: ToolRenderContext): string {
197
+ const d = (result.details ?? result) as Record<string, unknown>;
198
+ const records = (d.agentRecords ?? d.results) as CrewAgentRecord[] | undefined;
199
+ const action = typeof d.action === "string" ? d.action : "";
200
+ const status = typeof d.status === "string" ? d.status : "";
201
+ const runId = typeof d.runId === "string" ? d.runId : "";
202
+ const w = (ctx.width || process.stdout.columns || 116);
203
+ const innerW = w - 4;
204
+ const bColor = borderColorForStatus(status);
205
+ const contentLines: string[] = [];
206
+
207
+ // isPartial = tool still streaming — show LIVE progress
208
+ const isPartial = (options as Record<string, unknown>)?.isPartial === true;
209
+ if (isPartial && !ctx.expanded) {
210
+ const content = extractContentText(result?.content);
211
+ const parsed = parseStreamingProgress(content);
212
+
213
+ if (parsed) {
214
+ const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
215
+ const frameIdx = Math.floor(Date.now() / 80) % spinnerFrames.length;
216
+ const spinner = theme.fg("accent", spinnerFrames[frameIdx]!);
217
+ const elapsed = formatDuration(parsed.elapsedMs);
218
+
219
+ if (parsed.completed != null && parsed.total != null && parsed.total > 0) {
220
+ // ── Real task progress ──
221
+ const ratio = parsed.completed / parsed.total;
222
+ const barW = Math.min(innerW - 22, 30);
223
+ const bar = progressBar(ratio, barW, theme);
224
+ const count = theme.fg("muted", ` ${parsed.completed}/${parsed.total}`);
225
+ contentLines.push(padVisual(` ${spinner} ${theme.fg("toolTitle", theme.bold("crew run"))} ${theme.fg("dim", elapsed)}`, innerW));
226
+ contentLines.push(padVisual(` ${bar}${count}`, innerW));
227
+ if (parsed.activeAgent) contentLines.push(padVisual(` ${theme.fg("dim", parsed.activeAgent)}`, innerW));
228
+ } else {
229
+ // ── Starting phase — animated indeterminate bar ──
230
+ const barW = Math.min(innerW - 20, 30);
231
+ const scanBar = renderScanBar(barW, parsed.elapsedMs, theme);
232
+ contentLines.push(padVisual(` ${spinner} ${theme.fg("muted", "crew starting")} ${theme.fg("dim", elapsed)}`, innerW));
233
+ contentLines.push(padVisual(` ${scanBar}`, innerW));
234
+ }
235
+ } else if (content) {
236
+ const preview = truncLine(content.split("\n").filter(Boolean).pop() ?? "", innerW - 4);
237
+ contentLines.push(padVisual(` ${theme.fg("warning", "◉")} ${theme.fg("dim", preview)}`, innerW));
238
+ }
239
+
240
+ if (contentLines.length > 0) {
241
+ return buildFrameBottom(contentLines, w, theme, "borderAccent");
242
+ }
243
+ }
244
+
245
+ // Brief mode: compact summary (still framed)
246
+ // But NOT for action=run — those always get progress bar + metrics
247
+ if (isBrief() && !ctx.expanded && action !== "run") {
248
+ const briefText = briefToolResult("team", result as { content?: unknown[] }, theme);
249
+ contentLines.push(padVisual(` ${briefText}`, innerW));
250
+ return buildFrameBottom(contentLines, w, theme, bColor);
251
+ }
252
+
253
+ if (!ctx.expanded) {
254
+ // ── Collapsed: compact framed card ──
255
+ if (action === "run" && records?.length) {
256
+ appendRunCard(contentLines, records, status, runId, theme, innerW);
257
+ } else if (action === "run") {
258
+ const m = d.metrics as Metrics | undefined;
259
+ if (m) {
260
+ appendMetricsCard(contentLines, m, status, runId, theme, innerW);
261
+ } else {
262
+ appendSimpleCard(contentLines, status, runId, theme, innerW);
263
+ }
264
+ } else {
265
+ appendSimpleCard(contentLines, status, runId, theme, innerW);
266
+ }
267
+ } else {
268
+ // ── Expanded: detailed card ──
269
+ if (action === "run" && records?.length) {
270
+ appendExpandedRun(contentLines, records, status, runId, theme, w, innerW);
271
+ } else if (action === "run") {
272
+ const m = d.metrics as Metrics | undefined;
273
+ appendExpandedMetrics(contentLines, m, status, runId, theme, w, innerW);
274
+ } else {
275
+ const text = extractContentText(result?.content);
276
+ contentLines.push(padVisual(` ${text.slice(0, innerW - 2)}`, innerW));
277
+ }
278
+ }
279
+
280
+ return buildFrameBottom(contentLines, w, theme, bColor);
281
+ }
282
+
283
+ // ── Agent Tool Renderer ────────────────────────────────────────────────
284
+
285
+ export const agentToolRenderer: ToolRenderer = {
286
+ renderCall(args, theme, ctx) {
287
+ const agentName = args.agent as string ?? args.subagent_type as string ?? "";
288
+ const prompt = (args.prompt ?? args.task ?? "") as string;
289
+ const w = (ctx.width || process.stdout.columns || 116);
290
+ const innerW = w - 4;
291
+
292
+ const contentLines: string[] = [];
293
+ const badge = theme.fg("accent", `◀ AGENT ▶`);
294
+ const nameTag = theme.fg("toolTitle", theme.bold(agentName));
295
+ contentLines.push(padVisual(` ${badge} ${nameTag}`, innerW));
296
+
297
+ if (prompt) {
298
+ const maxLen = innerW - 2;
299
+ const preview = prompt.replace(/\n/g, " ");
300
+ const previewText = visibleWidth(preview) > maxLen ? truncVisual(preview, maxLen - 1) + "…" : preview;
301
+ contentLines.push(padVisual(` ${theme.fg("dim", previewText)}`, innerW));
302
+ }
303
+
304
+ return new Text(buildFrameTop(contentLines, w, theme, borderFromContext(ctx)), 0, 0);
305
+ },
306
+
307
+ renderResult(result, _options, theme, ctx) {
308
+ try {
309
+ const text = renderAgentResult(result, _options, theme, ctx);
310
+ return new Text(text, 0, 0);
311
+ } catch {
312
+ return new Text(statusIcon("completed", theme) + " agent done", 0, 0);
313
+ }
314
+ },
315
+ };
316
+
317
+ function renderAgentResult(result: Record<string, unknown>, options: unknown, theme: CrewTheme, ctx: ToolRenderContext): string {
318
+ const d = (result.details ?? result) as Record<string, unknown>;
319
+ const results = d.results as Array<Record<string, unknown>> | undefined;
320
+ const w = (ctx.width || process.stdout.columns || 116);
321
+ const innerW = w - 4;
322
+ const status = ((d.status ?? (results?.[0] as Record<string, unknown>)?.status ?? "") as string) || "completed";
323
+ const bColor: "success" | "error" | "border" = status === "completed" ? "success" : status === "failed" ? "error" : "border";
324
+
325
+ const contentLines: string[] = [];
326
+
327
+ // isPartial = agent still running
328
+ const isPartial = (options as Record<string, unknown>)?.isPartial === true;
329
+ if (isPartial && !ctx.expanded) {
330
+ const spinner = theme.fg("warning", "◉");
331
+ const label = theme.fg("muted", "agent working...");
332
+ contentLines.push(padVisual(` ${spinner} ${label}`, innerW));
333
+ return buildFrameBottom(contentLines, w, theme, "borderAccent");
334
+ }
335
+
336
+ // Brief mode: non-agent results get brief treatment
337
+ if (!results?.length && !d.agentId) {
338
+ const briefText = briefToolResult("agent", result as { content?: unknown[] }, theme);
339
+ contentLines.push(padVisual(` ${briefText}`, innerW));
340
+ return buildFrameBottom(contentLines, w, theme, bColor);
341
+ }
342
+
343
+ if (!ctx.expanded) {
344
+ // Collapsed: compact card
345
+ const badge = statusBadge(status, theme);
346
+ const agentId = (d.agentId as string) ?? (results?.[0] as Record<string, unknown>)?.agentId as string ?? "agent";
347
+ const nameTag = theme.fg("toolTitle", theme.bold(agentId));
348
+ contentLines.push(padVisual(` ${badge} ${nameTag}`, innerW));
349
+
350
+ // Error or output preview
351
+ if (d.error) {
352
+ contentLines.push(padVisual(` ${theme.fg("error", truncLine(String(d.error), innerW - 4))}`, innerW));
353
+ } else if (results?.length) {
354
+ const output = (results[0] as Record<string, unknown>).output as string | undefined;
355
+ if (output) {
356
+ const preview = truncLine(output.split("\n")[0] ?? "", innerW - 4);
357
+ contentLines.push(padVisual(` ${theme.fg("muted", preview)}`, innerW));
358
+ }
359
+ }
360
+ } else {
361
+ // Expanded: detailed rows
362
+ if (results?.length) {
363
+ for (let i = 0; i < results.length; i++) {
364
+ const item = results[i]!;
365
+ const icon = statusIcon(item.status as string ?? "", theme);
366
+ const label = theme.fg("toolTitle", theme.bold((item.agentId as string) ?? "agent"));
367
+ contentLines.push(padVisual(` ${icon} ${label}`, innerW));
368
+
369
+ if (item.error) {
370
+ contentLines.push(padVisual(` ${theme.fg("error", truncLine(String(item.error), innerW - 6))}`, innerW));
371
+ } else if (item.output) {
372
+ const outputLines = String(item.output).split("\n").slice(0, 5);
373
+ for (const line of outputLines) {
374
+ contentLines.push(padVisual(` ${theme.fg("dim", truncLine(line, innerW - 6))}`, innerW));
375
+ }
376
+ }
377
+ // Separator between agents
378
+ if (i < results.length - 1) {
379
+ contentLines.push(padVisual(theme.fg("borderMuted", "─".repeat(innerW - 2)), innerW));
380
+ }
381
+ }
382
+ } else if (d.agentId) {
383
+ const icon = statusIcon(d.status as string ?? "", theme);
384
+ contentLines.push(padVisual(` ${icon} ${theme.fg("toolTitle", theme.bold(d.agentId as string))}`, innerW));
385
+ if (d.error) {
386
+ contentLines.push(padVisual(` ${theme.fg("error", truncLine(String(d.error), innerW - 6))}`, innerW));
387
+ }
388
+ } else {
389
+ const text = extractContentText(result?.content);
390
+ if (text) contentLines.push(padVisual(` ${theme.fg("dim", truncLine(text, innerW - 4))}`, innerW));
391
+ }
392
+ }
393
+
394
+ return buildFrameBottom(contentLines, w, theme, bColor);
395
+ }
396
+
397
+ // ── Card builders ──────────────────────────────────────────────────────
398
+
399
+ interface Metrics {
400
+ taskCount?: number;
401
+ completedCount?: number;
402
+ totalTokens?: number;
403
+ totalCost?: number;
404
+ durationMs?: number;
405
+ }
406
+
407
+ function appendRunCard(lines: string[], records: CrewAgentRecord[], status: string, runId: string, theme: CrewTheme, innerW: number): void {
408
+ const completed = records.filter((r) => r.status === "completed").length;
409
+ const total = records.length;
410
+ const duration = computeTotalDuration(records);
411
+ const tokens = computeTotalTokens(records);
412
+ const cost = computeTotalCost(records);
413
+
414
+ // Line 1: badge + summary + expand hint (P2)
415
+ const badge = statusBadge(status, theme);
416
+ const title = theme.fg("toolTitle", theme.bold("crew run"));
417
+ const idTag = theme.fg("dim", shortId(runId));
418
+ const hint = theme.fg("dim", "⌘E");
419
+ const left = ` ${badge} ${title} ${idTag}`;
420
+ const right = `${hint}`;
421
+ const gap = innerW - visibleWidth(left) - visibleWidth(right);
422
+ lines.push(padVisual(left + (gap > 1 ? " ".repeat(gap) : " ") + right, innerW));
423
+
424
+ // Line 2: compact metrics + cost (P1)
425
+ const parts: string[] = [];
426
+ parts.push(`${completed}/${total} done`);
427
+ if (duration > 0) parts.push(formatDuration(duration));
428
+ if (tokens > 0) parts.push(`${formatTokens(tokens)} tok`);
429
+ if (cost > 0) parts.push(`$${cost.toFixed(3)}`);
430
+ lines.push(padVisual(` ${theme.fg("dim", parts.join(" · "))}`, innerW));
431
+ }
432
+
433
+ function appendMetricsCard(lines: string[], m: Metrics, status: string, runId: string, theme: CrewTheme, innerW: number): void {
434
+ // Line 1: badge + title + expand hint (P2)
435
+ const badge = statusBadge(status, theme);
436
+ const title = theme.fg("toolTitle", theme.bold("crew run"));
437
+ const idTag = theme.fg("dim", shortId(runId));
438
+ const hint = theme.fg("dim", "⌘E");
439
+ const left = ` ${badge} ${title} ${idTag}`;
440
+ const right = `${hint}`;
441
+ const gap = innerW - visibleWidth(left) - visibleWidth(right);
442
+ lines.push(padVisual(left + (gap > 1 ? " ".repeat(gap) : " ") + right, innerW));
443
+
444
+ // Line 2: compact metrics + cost (P1)
445
+ const parts: string[] = [];
446
+ if (m.completedCount != null && m.taskCount) parts.push(`${m.completedCount}/${m.taskCount} done`);
447
+ if (m.durationMs) parts.push(formatDuration(m.durationMs));
448
+ if (m.totalTokens) parts.push(`${formatTokens(m.totalTokens)} tok`);
449
+ if (m.totalCost) parts.push(`$${m.totalCost.toFixed(3)}`);
450
+ if (parts.length) lines.push(padVisual(` ${theme.fg("dim", parts.join(" · "))}`, innerW));
451
+ }
452
+
453
+ function appendSimpleCard(lines: string[], status: string, runId: string, theme: CrewTheme, innerW: number): void {
454
+ const badge = statusBadge(status, theme);
455
+ const parts: string[] = [status];
456
+ if (runId) parts.push(shortId(runId));
457
+ lines.push(padVisual(` ${badge} ${theme.fg("text", parts.join(" · ") || "done")}`, innerW));
458
+ }
459
+
460
+ function appendExpandedRun(lines: string[], records: CrewAgentRecord[], status: string, runId: string, theme: CrewTheme, w: number, innerW: number): void {
461
+ const completed = records.filter((r) => r.status === "completed").length;
462
+ const total = records.length;
463
+ const ratio = total > 0 ? completed / total : 0;
464
+ const duration = computeTotalDuration(records);
465
+ const tokens = computeTotalTokens(records);
466
+
467
+ // Header
468
+ lines.push(padVisual(` ${theme.fg("toolTitle", theme.bold("CREW RUN RESULT"))} ${theme.fg("dim", shortId(runId))}`, innerW));
469
+ lines.push(padVisual(theme.fg("borderMuted", "─".repeat(innerW - 2)), innerW));
470
+
471
+ // Progress bar
472
+ const barW = Math.min(innerW - 22, 40);
473
+ const bar = progressBar(ratio, barW, theme);
474
+ const count = theme.fg("muted", ` ${completed}/${total}`);
475
+ lines.push(padVisual(` ${theme.fg("muted", "Progress")} ${bar}${count}`, innerW));
476
+
477
+ // Metrics
478
+ const metricLine = [
479
+ theme.fg("muted", formatDuration(duration)),
480
+ theme.fg("muted", `${formatTokens(tokens)} tok`),
481
+ ].join(theme.fg("dim", " · "));
482
+ lines.push(padVisual(` ${metricLine}`, innerW));
483
+ lines.push(padVisual(theme.fg("borderMuted", "─".repeat(innerW - 2)), innerW));
484
+
485
+ // Agent rows
486
+ for (const r of records) {
487
+ const icon = statusIcon(r.status, theme);
488
+ const role = theme.fg("toolTitle", theme.bold(r.role || r.agent || "agent"));
489
+ const model = r.model ? theme.fg("dim", ` (${r.model.split("/").at(-1)})`) : "";
490
+ const dur = r.startedAt ? formatDuration(computeRecordDuration(r)) : "";
491
+ const toolCount = `${r.toolUses ?? r.progress?.toolCount ?? 0} tools`;
492
+ lines.push(padVisual(` ${icon} ${role}${model} ${theme.fg("dim", `${toolCount} · ${dur}`)}`, innerW));
493
+
494
+ // Usage
495
+ const usage = r.usage;
496
+ const usageParts: string[] = [];
497
+ if (usage?.input) usageParts.push(theme.fg("dim", `↑${formatTokens(usage.input)}`));
498
+ if (usage?.output) usageParts.push(theme.fg("dim", `↓${formatTokens(usage.output)}`));
499
+ if (usage?.cost) usageParts.push(theme.fg("dim", `$${usage.cost.toFixed(3)}`));
500
+ if (usageParts.length) lines.push(padVisual(` ${usageParts.join(" ")}`, innerW));
501
+ }
502
+ }
503
+
504
+ function appendExpandedMetrics(lines: string[], m: Metrics | undefined, status: string, runId: string, theme: CrewTheme, w: number, innerW: number): void {
505
+ lines.push(padVisual(` ${theme.fg("toolTitle", theme.bold("CREW RUN RESULT"))} ${theme.fg("dim", shortId(runId))}`, innerW));
506
+ lines.push(padVisual(theme.fg("borderMuted", "─".repeat(innerW - 2)), innerW));
507
+ if (m) {
508
+ const ratio = m.taskCount ? (m.completedCount ?? 0) / m.taskCount : 0;
509
+ const barW = Math.min(innerW - 22, 40);
510
+ const bar = progressBar(ratio, barW, theme);
511
+ const count = theme.fg("muted", ` ${m.completedCount ?? 0}/${m.taskCount ?? 0}`);
512
+ lines.push(padVisual(` ${bar}${count}`, innerW));
513
+ const parts: string[] = [];
514
+ if (m.durationMs) parts.push(formatDuration(m.durationMs));
515
+ if (m.totalTokens) parts.push(`${formatTokens(m.totalTokens)} tok`);
516
+ if (parts.length) lines.push(padVisual(` ${theme.fg("dim", parts.join(" · "))}`, innerW));
517
+ }
518
+ }
519
+
520
+ // ── Helpers ────────────────────────────────────────────────────────────
521
+
522
+ function extractContentText(content: unknown): string {
523
+ if (!Array.isArray(content)) return typeof content === "string" ? content : "";
524
+ // onUpdate appends text blocks — only use the LAST one to avoid stacking
525
+ const texts = content
526
+ .filter((c): c is Record<string, unknown> => typeof c === "object" && c !== null && (c as Record<string, unknown>).type === "text");
527
+ if (texts.length === 0) return "";
528
+ return String((texts[texts.length - 1]! as Record<string, unknown>).text ?? "");
529
+ }
530
+
531
+ /** Parse streaming progress text from team-tool progress binder. */
532
+ interface StreamingProgress {
533
+ elapsedMs: number;
534
+ completed: number | null;
535
+ total: number | null;
536
+ status: string | null;
537
+ activeAgent: string | null;
538
+ }
539
+
540
+ function parseStreamingProgress(text: string): StreamingProgress | null {
541
+ if (!text) return null;
542
+
543
+ // Format: "team status=starting elapsed=11s"
544
+ const elapsedMatch = text.match(/elapsed=(\d+)s/);
545
+ const elapsedMs = elapsedMatch ? parseInt(elapsedMatch[1]!, 10) * 1000 : 0;
546
+
547
+ // Format from formatCompactToolProgress: "tasks completed=2 running=1"
548
+ const taskMatch = text.match(/tasks[^\n]*(?:completed|done)=(\d+)[^\n]*(?:running|waiting|queued)=(\d+)/);
549
+ const doneMatch = text.match(/(\d+)\/(\d+)\s+done/);
550
+
551
+ // Active agent: " explorer->explorer turn=5 tokens=1234"
552
+ const agentMatch = text.match(/\s+(\w+)->(\w+)\s+turn=/);
553
+ const activeAgent = agentMatch ? `${agentMatch[1]}/${agentMatch[2]}` : null;
554
+
555
+ // Current tool: " tool: bash (#3)"
556
+ const toolMatch = text.match(/tool:\s+(\S+)/);
557
+ const toolInfo = toolMatch ? ` · ${toolMatch[1]}` : "";
558
+
559
+ if (doneMatch) {
560
+ return { elapsedMs, completed: parseInt(doneMatch[1]!, 10), total: parseInt(doneMatch[2]!, 10), status: null, activeAgent: (activeAgent ?? "") + toolInfo };
561
+ }
562
+ if (taskMatch) {
563
+ const done = parseInt(taskMatch[1]!, 10);
564
+ const running = parseInt(taskMatch[2]!, 10);
565
+ const total = done + running;
566
+ return { elapsedMs, completed: done, total, status: null, activeAgent: (activeAgent ?? "") + toolInfo };
567
+ }
568
+ if (elapsedMs > 0) {
569
+ const statusMatch = text.match(/status=(\w+)/);
570
+ return { elapsedMs, completed: null, total: null, status: statusMatch?.[1] ?? null, activeAgent };
571
+ }
572
+
573
+ return null;
574
+ }
575
+
576
+ /** Animated scanning bar for indeterminate progress. */
577
+ function renderScanBar(barWidth: number, elapsedMs: number, theme: CrewTheme): string {
578
+ const pos = Math.floor((elapsedMs / 400) % (barWidth + 6)) - 3; // bounce range
579
+ const segW = Math.max(3, Math.floor(barWidth * 0.3));
580
+ let bar = "";
581
+ for (let i = 0; i < barWidth; i++) {
582
+ const inSeg = i >= pos && i < pos + segW;
583
+ bar += inSeg ? theme.fg("accent", "█") : theme.fg("dim", "░");
584
+ }
585
+ return bar;
586
+ }
587
+
588
+ function computeTotalDuration(records: CrewAgentRecord[]): number {
589
+ let total = 0;
590
+ for (const r of records) total += computeRecordDuration(r);
591
+ return total;
592
+ }
593
+
594
+ function computeRecordDuration(r: CrewAgentRecord): number {
595
+ if (!r.startedAt) return 0;
596
+ const start = new Date(r.startedAt).getTime();
597
+ const end = r.completedAt ? new Date(r.completedAt).getTime() : Date.now();
598
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
599
+ return Math.max(0, end - start);
600
+ }
601
+
602
+ function computeTotalCost(records: CrewAgentRecord[]): number {
603
+ let total = 0;
604
+ for (const r of records) { if (r.usage?.cost) total += r.usage.cost; }
605
+ return total;
606
+ }
607
+
608
+ function computeTotalTokens(records: CrewAgentRecord[]): number {
609
+ let total = 0;
610
+ for (const r of records) {
611
+ if (r.usage) total += (r.usage.input ?? 0) + (r.usage.output ?? 0) + (r.usage.cacheWrite ?? 0);
612
+ }
613
+ return total;
614
+ }
615
+
616
+ /** P5: Shorten file path by replacing $HOME with ~ */
617
+ function shortenPath(p: string): string {
618
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
619
+ if (home && p.startsWith(home)) return "~" + p.slice(home.length);
620
+ return p;
621
+ }
622
+
623
+ /** P8: Create clickable file hyperlink via OSC 8 */
624
+ function linkPath(p: string, label?: string): string {
625
+ const display = label ?? shortenPath(p);
626
+ return `\x1b]8;;file://${p}\x1b\\${display}\x1b]8;;\x1b\\`;
627
+ }