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
@@ -109,7 +109,7 @@ export class LiveRunSidebar {
109
109
 
110
110
  render(width: number): string[] {
111
111
  const w = Math.max(36, width);
112
- const loaded = loadRunManifestById(this.cwd, this.runId);
112
+ const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
113
113
  if (!loaded) {
114
114
  return renderLines(
115
115
  [
package/src/ui/loaders.ts CHANGED
@@ -131,6 +131,10 @@ export class CountdownTimer {
131
131
  this.emitExpire();
132
132
  }
133
133
  }, 1000);
134
+ // Defense-in-depth: never let the countdown timer keep the event loop
135
+ // alive. If dispose() is missed (e.g. UI unmount race), the timer must
136
+ // not block process exit.
137
+ if (typeof this.timer.unref === "function") this.timer.unref();
134
138
  }
135
139
 
136
140
  private emitExpire(): void {
@@ -15,7 +15,7 @@ export class AgentPickerOverlay {
15
15
  private selected = 0;
16
16
 
17
17
  constructor(opts: { cwd: string; runId: string; done: (selection: AgentPickerSelection | undefined) => void; theme?: unknown }) {
18
- const loaded = loadRunManifestById(opts.cwd, opts.runId);
18
+ const loaded = loadRunManifestById(opts.cwd, opts.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
19
19
  this.agents = loaded ? readCrewAgents(loaded.manifest) : [];
20
20
  this.done = opts.done;
21
21
  this.theme = asCrewTheme(opts.theme ?? {});
@@ -32,7 +32,7 @@ export class MailboxDetailOverlay {
32
32
  }
33
33
 
34
34
  private refresh(): void {
35
- const loaded = loadRunManifestById(this.cwd, this.runId);
35
+ const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
36
36
  if (!loaded) return;
37
37
  // Track task count changes to trigger re-render
38
38
  const taskCount = loaded.tasks.length;
@@ -10,7 +10,7 @@ import { listLiveAgents } from "../runtime/live-agent-manager.ts";
10
10
  import { logInternalError } from "../utils/internal-error.ts";
11
11
  import type { ManifestCache } from "../runtime/manifest-cache.ts";
12
12
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
13
- import { notificationBadge } from "./crew-widget.ts";
13
+ import { notificationBadge } from "./widget/widget-formatters.ts";
14
14
  import { RenderCoalescer } from "./render-coalescer.ts";
15
15
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
16
16
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
@@ -81,7 +81,7 @@ export function dispatchHealthRecovery(ctx: ExtensionContext, runId: string): Pr
81
81
 
82
82
  export async function dispatchKillStaleWorkers(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
83
83
  try {
84
- const loaded = loadRunManifestById(ctx.cwd, runId);
84
+ const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
85
85
  if (!loaded) return { ok: false, message: `Run '${runId}' not found.` };
86
86
  const currentMs = Date.now();
87
87
  const staleMs = 60_000;
@@ -113,7 +113,7 @@ export async function dispatchDiagnosticExport(ctx: ExtensionContext, runId: str
113
113
 
114
114
  /** @internal */
115
115
  function defaultNudgeAgentId(ctx: Pick<ExtensionContext, "cwd">, runId: string): string | undefined {
116
- const loaded = loadRunManifestById(ctx.cwd, runId);
116
+ const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
117
117
  if (!loaded) return undefined;
118
118
  return readCrewAgents(loaded.manifest).find((agent) => agent.status === "running" || agent.status === "queued")?.taskId;
119
119
  }
@@ -652,7 +652,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
652
652
  function build(runId: string, previous?: CacheEntry): CacheEntry {
653
653
  let loaded: ReturnType<typeof loadRunManifestById>;
654
654
  try {
655
- loaded = loadRunManifestById(cwd, runId);
655
+ loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
656
656
  } catch {
657
657
  if (previous) return previous;
658
658
  throw new Error(`Run '${runId}' could not be parsed.`);
@@ -1,6 +1,6 @@
1
1
  import type { CrewTheme, CrewThemeColor } from "./theme-adapter.ts";
2
2
 
3
- export type RunStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "stopped" | "blocked" | (string & {});
3
+ export type RunStatus = "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "stopped" | "blocked" | "stale" | "needs_attention" | (string & {});
4
4
 
5
5
  export function colorForStatus(status: RunStatus): CrewThemeColor {
6
6
  switch (status) {
@@ -17,6 +17,8 @@ export function colorForStatus(status: RunStatus): CrewThemeColor {
17
17
  case "blocked":
18
18
  case "stopped":
19
19
  return "warning";
20
+ case "needs_attention":
21
+ return "warning";
20
22
  case "queued":
21
23
  default:
22
24
  return "dim";
@@ -42,6 +44,8 @@ export function iconForStatus(status: RunStatus, options?: { runningGlyph?: stri
42
44
  return "◦";
43
45
  case "blocked":
44
46
  return "⏸";
47
+ case "needs_attention":
48
+ return "⚠";
45
49
  default:
46
50
  return "·";
47
51
  }
@@ -9,9 +9,22 @@ export type CrewThemeColor =
9
9
  | "muted"
10
10
  | "dim"
11
11
  | "text"
12
+ | "thinkingText"
13
+ // Tool rendering
14
+ | "toolTitle"
15
+ | "toolOutput"
12
16
  | "toolDiffAdded"
13
17
  | "toolDiffRemoved"
14
18
  | "toolDiffContext"
19
+ // Markdown
20
+ | "mdHeading"
21
+ | "mdLink"
22
+ | "mdCode"
23
+ | "mdCodeBlock"
24
+ | "mdQuote"
25
+ | "mdHr"
26
+ | "mdListBullet"
27
+ // Syntax highlighting
15
28
  | "syntaxKeyword"
16
29
  | "syntaxString"
17
30
  | "syntaxNumber"
@@ -21,15 +34,81 @@ export type CrewThemeColor =
21
34
  | "syntaxType"
22
35
  | "syntaxOperator"
23
36
  | "syntaxPunctuation"
24
- | "mdCodeBlock";
37
+ // Message display
38
+ | "userMessageText"
39
+ | "customMessageLabel"
40
+ // Thinking gradient (6 levels, low→high intensity)
41
+ | "thinkingOff"
42
+ | "thinkingMinimal"
43
+ | "thinkingLow"
44
+ | "thinkingMedium"
45
+ | "thinkingHigh"
46
+ | "thinkingXhigh"
47
+ // Special
48
+ | "bashMode";
25
49
 
26
50
  export type CrewThemeBg =
27
51
  | "selectedBg"
28
52
  | "userMessageBg"
53
+ | "customMessageBg"
29
54
  | "toolPendingBg"
30
55
  | "toolSuccessBg"
31
56
  | "toolErrorBg";
32
57
 
58
+ /** ANSI fallback values for theme color slots when the active theme doesn't define them. */
59
+ export const THEME_COLOR_FALLBACKS: Record<CrewThemeColor, string> = {
60
+ accent: "\x1b[36m",
61
+ border: "\x1b[38;5;240m",
62
+ borderAccent: "\x1b[35m",
63
+ borderMuted: "\x1b[38;5;236m",
64
+ success: "\x1b[32m",
65
+ error: "\x1b[31m",
66
+ warning: "\x1b[33m",
67
+ muted: "\x1b[38;5;245m",
68
+ dim: "\x1b[38;5;240m",
69
+ text: "\x1b[39m",
70
+ thinkingText: "\x1b[38;5;245m",
71
+ toolTitle: "\x1b[36m",
72
+ toolOutput: "\x1b[38;5;245m",
73
+ toolDiffAdded: "\x1b[32m",
74
+ toolDiffRemoved: "\x1b[31m",
75
+ toolDiffContext: "\x1b[38;5;245m",
76
+ mdHeading: "\x1b[33m",
77
+ mdLink: "\x1b[35m",
78
+ mdCode: "\x1b[32m",
79
+ mdCodeBlock: "\x1b[39m",
80
+ mdQuote: "\x1b[38;5;245m",
81
+ mdHr: "\x1b[38;5;240m",
82
+ mdListBullet: "\x1b[36m",
83
+ syntaxKeyword: "\x1b[35m",
84
+ syntaxString: "\x1b[32m",
85
+ syntaxNumber: "\x1b[33m",
86
+ syntaxComment: "\x1b[38;5;245m",
87
+ syntaxFunction: "\x1b[36m",
88
+ syntaxVariable: "\x1b[39m",
89
+ syntaxType: "\x1b[35m",
90
+ syntaxOperator: "\x1b[35m",
91
+ syntaxPunctuation: "\x1b[35m",
92
+ userMessageText: "\x1b[39m",
93
+ customMessageLabel: "\x1b[35m",
94
+ thinkingOff: "\x1b[38;5;236m",
95
+ thinkingMinimal: "\x1b[38;5;245m",
96
+ thinkingLow: "\x1b[35m",
97
+ thinkingMedium: "\x1b[35m",
98
+ thinkingHigh: "\x1b[36m",
99
+ thinkingXhigh: "\x1b[35m",
100
+ bashMode: "\x1b[32m",
101
+ };
102
+
103
+ /** Map a thinking intensity level (0–5) to a theme color slot. */
104
+ export function thinkingColorForLevel(level: number): CrewThemeColor {
105
+ const slots: CrewThemeColor[] = [
106
+ "thinkingOff", "thinkingMinimal", "thinkingLow",
107
+ "thinkingMedium", "thinkingHigh", "thinkingXhigh",
108
+ ];
109
+ return slots[Math.min(Math.max(level, 0), 5)] ?? "thinkingOff";
110
+ }
111
+
33
112
  export interface CrewTheme {
34
113
  fg(color: CrewThemeColor, text: string): string;
35
114
  bg?(color: CrewThemeBg, text: string): string;
@@ -48,12 +48,14 @@ function trimLine(value: string): string {
48
48
 
49
49
  function taskCounts(tasks: TeamTaskState[] | undefined): string | undefined {
50
50
  if (!tasks || tasks.length === 0) return undefined;
51
+ const total = tasks.length;
52
+ const completed = tasks.filter((t) => t.status === "completed").length;
51
53
  const buckets = new Map<string, number>();
52
54
  for (const task of tasks) buckets.set(task.status, (buckets.get(task.status) ?? 0) + 1);
53
55
  const summary = [...buckets.entries()]
54
56
  .map(([status, count]) => `${status}=${count}`)
55
57
  .join(" ");
56
- return `tasks ${summary}`;
58
+ return `tasks ${completed}/${total} done ${summary}`;
57
59
  }
58
60
 
59
61
  /**
@@ -66,6 +68,9 @@ export function formatCompactToolProgress(input: ToolProgressInput): string {
66
68
  const head = input.agentId ? `agent=${input.agentId}` : "agent";
67
69
  const lines: string[] = [`${head} status=${input.status} elapsed=${elapsedSec}s`];
68
70
 
71
+ const counts = taskCounts(input.tasks);
72
+ if (counts) lines.push(` ${counts}`);
73
+
69
74
  const active = pickActiveAgent(input.agents);
70
75
  if (active) {
71
76
  const turns = active.progress?.turns ?? 0;
@@ -77,12 +82,11 @@ export function formatCompactToolProgress(input: ToolProgressInput): string {
77
82
  }
78
83
  const recent = active.progress?.recentOutput?.at(-1);
79
84
  if (recent && recent.trim()) lines.push(` ${trimLine(recent)}`);
80
- } else if (input.runId) {
81
- const counts = taskCounts(input.tasks);
82
- lines.push(counts ? ` run=${input.runId} ${counts}` : ` run=${input.runId} (starting)`);
85
+ } else if (input.runId && !counts) {
86
+ lines.push(` run=${input.runId} (starting)`);
83
87
  } else if (input.error) {
84
88
  lines.push(` error: ${trimLine(input.error)}`);
85
- } else {
89
+ } else if (!counts) {
86
90
  lines.push(" waiting for run to start");
87
91
  }
88
92
  return lines.join("\n");
@@ -1,4 +1,8 @@
1
1
  /**
2
+ * @deprecated Use tool-renderers/index.ts (teamToolRenderer/agentToolRenderer) instead.
3
+ * This file only exports shared utility functions (truncLine, formatTokens, formatDuration).
4
+ * The render functions below are kept for backward-compatible tests only.
5
+ *
2
6
  * Shared rendering for pi-crew's tool TUI display.
3
7
  * Ports logic from pi-subagent4 adapted for pi-crew's data model.
4
8
  * Uses @earendil-works/pi-tui Components (Container, Text, Spacer) directly.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Brief mode for tool result display.
3
+ *
4
+ * Inspired by @ayulab/pi-brief — shows one-line summaries instead of
5
+ * full output. Toggled via /crew-brief on|off, state persists across
6
+ * session reloads via pi.appendEntry().
7
+ */
8
+
9
+ import type { CrewTheme } from "../theme-adapter.ts";
10
+ import { formatTokens, formatDuration, truncLine } from "../tool-render.ts";
11
+ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
12
+
13
+ // ── State ──────────────────────────────────────────────────────────────
14
+
15
+ const BRIEF_ENTRY_TYPE = "pi-crew.brief-state";
16
+ let briefEnabled = false;
17
+
18
+ export function isBrief(): boolean {
19
+ return briefEnabled;
20
+ }
21
+
22
+ export function setBrief(on: boolean): void {
23
+ briefEnabled = on;
24
+ }
25
+
26
+ /** Entry type for persisting brief state across session reloads. */
27
+ export interface BriefStateEntry {
28
+ readonly type: "custom";
29
+ readonly customType: typeof BRIEF_ENTRY_TYPE;
30
+ readonly data: { enabled: boolean };
31
+ }
32
+
33
+ export function makeBriefEntry(enabled: boolean): BriefStateEntry {
34
+ return { type: "custom", customType: BRIEF_ENTRY_TYPE, data: { enabled } };
35
+ }
36
+
37
+ export function restoreBriefState(entries: Iterable<unknown>): void {
38
+ for (const entry of entries) {
39
+ if (
40
+ typeof entry === "object" && entry !== null &&
41
+ "type" in entry && (entry as Record<string, unknown>).type === "custom" &&
42
+ "customType" in entry && (entry as Record<string, unknown>).customType === BRIEF_ENTRY_TYPE &&
43
+ "data" in entry
44
+ ) {
45
+ const data = (entry as Record<string, unknown>).data;
46
+ if (typeof data === "object" && data !== null && "enabled" in data) {
47
+ briefEnabled = !!(data as Record<string, unknown>).enabled;
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ export { BRIEF_ENTRY_TYPE };
54
+
55
+ // ── Brief renderers ────────────────────────────────────────────────────
56
+
57
+ /** Brief summary for a single tool result. */
58
+ export function briefToolResult(toolName: string, result: { content?: unknown[] }, theme: CrewTheme): string {
59
+ const text = extractText(result?.content);
60
+ switch (toolName) {
61
+ case "read": return briefRead(text, theme);
62
+ case "bash": return briefBash(text, theme);
63
+ case "edit": return briefEdit(text, theme);
64
+ case "write": return briefWrite(text, theme);
65
+ case "find": return briefFind(text, theme);
66
+ case "grep": return briefGrep(text, theme);
67
+ case "ls": return briefLs(text, theme);
68
+ case "team": return briefTeam(result, theme);
69
+ case "agent": return briefAgent(result, theme);
70
+ default: return briefDefault(text, theme);
71
+ }
72
+ }
73
+
74
+ function briefRead(text: string, theme: CrewTheme): string {
75
+ if (!text) return theme.fg("dim", "→ empty");
76
+ const count = text.trim().split("\n").filter(Boolean).length;
77
+ return theme.fg("muted", `→ ${count} lines`);
78
+ }
79
+
80
+ function briefBash(text: string, theme: CrewTheme): string {
81
+ if (!text?.trim()) return theme.fg("dim", "→ done");
82
+ const lines = text.trim().split("\n");
83
+ if (lines.length === 1 && lines[0]!.length < 40) {
84
+ return theme.fg("muted", `→ ${lines[0]}`);
85
+ }
86
+ return theme.fg("muted", `→ ${lines.length} lines`);
87
+ }
88
+
89
+ function briefEdit(text: string, theme: CrewTheme): string {
90
+ if (!text) return theme.fg("dim", "→ edited");
91
+ if (text.includes("Error") || text.includes("error")) {
92
+ return theme.fg("error", "→ failed");
93
+ }
94
+ const added = (text.match(/^\+ /gm) ?? []).length;
95
+ const removed = (text.match(/^- /gm) ?? []).length;
96
+ if (added === 0 && removed === 0) {
97
+ return theme.fg("success", "→ edited");
98
+ }
99
+ return theme.fg("success", "→ edited ") +
100
+ theme.fg("toolDiffAdded", `+${added} `) +
101
+ theme.fg("toolDiffRemoved", `-${removed}`);
102
+ }
103
+
104
+ function briefWrite(text: string, theme: CrewTheme): string {
105
+ if (text) return theme.fg("error", `→ ${text}`);
106
+ return theme.fg("success", "→ written");
107
+ }
108
+
109
+ function briefFind(text: string, theme: CrewTheme): string {
110
+ if (!text) return theme.fg("dim", "→ none");
111
+ const count = text.trim().split("\n").filter(Boolean).length;
112
+ return theme.fg("muted", `→ ${count} files`);
113
+ }
114
+
115
+ function briefGrep(text: string, theme: CrewTheme): string {
116
+ if (!text) return theme.fg("dim", "→ none");
117
+ const count = text.trim().split("\n").filter(Boolean).length;
118
+ return theme.fg("muted", `→ ${count} matches`);
119
+ }
120
+
121
+ function briefLs(text: string, theme: CrewTheme): string {
122
+ if (!text) return theme.fg("dim", "→ empty");
123
+ const count = text.trim().split("\n").filter(Boolean).length;
124
+ return theme.fg("muted", `→ ${count} entries`);
125
+ }
126
+
127
+ function briefTeam(result: { content?: unknown[] }, theme: CrewTheme): string {
128
+ // Try to extract structured details
129
+ const details = (result as Record<string, unknown>).details ?? result;
130
+ const d = typeof details === "object" && details !== null ? details as Record<string, unknown> : {};
131
+ const status = typeof d.status === "string" ? d.status : "";
132
+ const runId = typeof d.runId === "string" ? d.runId : "";
133
+ const icon = status === "completed" ? theme.fg("success", "✓")
134
+ : status === "failed" ? theme.fg("error", "✗")
135
+ : status === "running" ? theme.fg("warning", "⟳")
136
+ : theme.fg("dim", "○");
137
+
138
+ // Agent records summary
139
+ const records = (d.agentRecords ?? d.results) as CrewAgentRecord[] | undefined;
140
+ if (records?.length) {
141
+ const completed = records.filter((r) => r.status === "completed").length;
142
+ const total = records.length;
143
+ const duration = computeTotalDuration(records);
144
+ const tokens = computeTotalTokens(records);
145
+ return `${icon} ${completed}/${total} tasks · ${formatDuration(duration)} · ${formatTokens(tokens)} tok`;
146
+ }
147
+
148
+ // Fallback: compact status line
149
+ const parts: string[] = [];
150
+ if (status) parts.push(status);
151
+ if (runId) parts.push(runId.slice(-8));
152
+ return `${icon} ${parts.join(" · ") || "done"}`;
153
+ }
154
+
155
+ function briefAgent(result: { content?: unknown[] }, theme: CrewTheme): string {
156
+ const d = (result as Record<string, unknown>).details ?? result;
157
+ const data = typeof d === "object" && d !== null ? d as Record<string, unknown> : {};
158
+ const status = typeof data.status === "string" ? data.status : "";
159
+ const agentId = typeof data.agentId === "string" ? data.agentId : "agent";
160
+ const icon = status === "completed" ? theme.fg("success", "\u2713")
161
+ : status === "failed" ? theme.fg("error", "\u2717")
162
+ : theme.fg("dim", "\u25CB");
163
+ // P3: Show duration and tokens when available
164
+ const parts: string[] = [agentId];
165
+ if (data.durationMs) parts.push(formatDuration(data.durationMs as number));
166
+ if (data.totalTokens) parts.push(`${formatTokens(data.totalTokens as number)} tok`);
167
+ return `${icon} ${parts.join(" \u00B7 ")}`;
168
+ }
169
+
170
+ function briefDefault(text: string, theme: CrewTheme): string {
171
+ if (!text) return theme.fg("dim", "→ done");
172
+ const first = text.split("\n")[0] ?? "";
173
+ return theme.fg("muted", `→ ${truncLine(first, 60)}`);
174
+ }
175
+
176
+ // ── Helpers ────────────────────────────────────────────────────────────
177
+
178
+ function extractText(content: unknown[] | undefined): string {
179
+ if (!content) return "";
180
+ if (!Array.isArray(content)) return String(content);
181
+ return content
182
+ .filter((c): c is Record<string, unknown> => typeof c === "object" && c !== null && (c as Record<string, unknown>).type === "text")
183
+ .map((c) => String((c as Record<string, unknown>).text ?? ""))
184
+ .join("\n");
185
+ }
186
+
187
+ function computeTotalDuration(records: CrewAgentRecord[]): number {
188
+ let total = 0;
189
+ for (const r of records) {
190
+ if (r.startedAt) {
191
+ const start = new Date(r.startedAt).getTime();
192
+ const end = r.completedAt ? new Date(r.completedAt).getTime() : Date.now();
193
+ if (Number.isFinite(start) && Number.isFinite(end)) total += Math.max(0, end - start);
194
+ }
195
+ }
196
+ return total;
197
+ }
198
+
199
+ function computeTotalTokens(records: CrewAgentRecord[]): number {
200
+ let total = 0;
201
+ for (const r of records) {
202
+ if (r.usage) {
203
+ total += (r.usage.input ?? 0) + (r.usage.output ?? 0) + (r.usage.cacheWrite ?? 0);
204
+ }
205
+ }
206
+ return total;
207
+ }