pi-crew 0.10.1 → 0.10.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 (64) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/executor.md +1 -1
  5. package/agents/test-engineer.md +1 -1
  6. package/dist/index.mjs +2781 -949
  7. package/package.json +1 -1
  8. package/schema.json +492 -94
  9. package/src/config/config-validation.ts +7 -1
  10. package/src/config/defaults.ts +8 -1
  11. package/src/config/role-tools.ts +4 -2
  12. package/src/config/types.ts +19 -1
  13. package/src/extension/crew-cleanup.ts +13 -0
  14. package/src/extension/crew-vibes/footer.ts +19 -0
  15. package/src/extension/crew-vibes/index.ts +11 -1
  16. package/src/extension/register.ts +8 -0
  17. package/src/extension/registration/command-registration.ts +1 -0
  18. package/src/extension/registration/commands/run.ts +15 -1
  19. package/src/extension/registration/commands/shared.ts +8 -0
  20. package/src/extension/registration/foreground-run-controller.ts +10 -2
  21. package/src/extension/registration/lifecycle-handlers.ts +52 -2
  22. package/src/extension/registration/runtime-cleanup.ts +23 -5
  23. package/src/extension/registration/team-tool.ts +5 -1
  24. package/src/extension/registration/ui.ts +5 -4
  25. package/src/extension/team-tool/run-deadline.ts +20 -3
  26. package/src/extension/team-tool/run.ts +26 -2
  27. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  28. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  29. package/src/runtime/detached-run-results.ts +90 -0
  30. package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
  31. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
  32. package/src/runtime/merge-gate.ts +25 -9
  33. package/src/runtime/model/model-fallback.ts +7 -3
  34. package/src/runtime/process-status.ts +16 -1
  35. package/src/runtime/recovery/crash-recovery.ts +16 -0
  36. package/src/runtime/run-tracker.ts +77 -10
  37. package/src/runtime/team-runner.ts +3 -2
  38. package/src/schema/config-schema.ts +1 -1
  39. package/src/state/stores/plan-store.ts +1 -1
  40. package/src/state/stores/state-store.ts +73 -3
  41. package/src/state/types.ts +2 -0
  42. package/src/ui/dock-footer.ts +49 -0
  43. package/src/ui/inline-panel/agent-pane.ts +375 -0
  44. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  45. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  46. package/src/ui/inline-panel/crew-editor.ts +192 -0
  47. package/src/ui/inline-panel/index.ts +290 -0
  48. package/src/ui/inline-panel/panel-rows.ts +37 -0
  49. package/src/ui/inline-panel/panel-selection.ts +157 -0
  50. package/src/ui/inline-panel/panel-store.ts +111 -0
  51. package/src/ui/inline-panel/view-session-store.ts +36 -0
  52. package/src/ui/pi-ui-compat.ts +9 -0
  53. package/src/ui/widget/index.ts +176 -22
  54. package/src/ui/widget/task-list.ts +198 -0
  55. package/src/ui/widget/widget-formatters.ts +240 -4
  56. package/src/ui/widget/widget-renderer.ts +234 -37
  57. package/src/ui/widget/widget-types.ts +11 -0
  58. package/src/utils/child-process-shield.ts +106 -0
  59. package/src/utils/redaction.ts +7 -0
  60. package/src/utils/safe-abort.ts +45 -0
  61. package/src/workflows/discover-workflows.ts +1 -0
  62. package/src/workflows/workflow-config.ts +7 -0
  63. package/workflows/default.workflow.md +36 -26
  64. package/workflows/strict-fast-fix.workflow.md +26 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * task-list.ts — the run's plan, painted above the editor (pi-tasks /
3
+ * Claude Code style): a `● N tasks (…)` header, then one row per task in
4
+ * PLAN ORDER with task numbers (#1, #2, …) instead of technical ids.
5
+ * Completed rows are dimmed and struck through; the task actively executing
6
+ * carries a spinner frame with elapsed time and token counts; queued rows
7
+ * name the dependencies they wait on.
8
+ *
9
+ * Example:
10
+ * ```
11
+ * ● 4 tasks (1 done, 1 in progress, 2 open)
12
+ * ✔ #1 Design the flux capacitor
13
+ * ⠙ #2 Wire the overlay scroll (2m 49s · ↑ 4.1k ↓ 1.2k)
14
+ * ◻ #3 Run the suite › blocked by #2
15
+ * ◻ #4 Report the result
16
+ * ```
17
+ *
18
+ * Pure plan surface: which worker/agent/model runs a task is the dock's
19
+ * story below the editor, never the plan's. Pure display — the interactive
20
+ * surface stays that dock. Rows come from the run snapshot's `tasks` slice
21
+ * (plan order), so the list repaints on task transitions only; the running
22
+ * row's spinner/elapsed/token suffix rides the render signature's spinner
23
+ * bucket while any worker is alive.
24
+ */
25
+
26
+ import type { TeamTaskState } from "../../state/types.ts";
27
+ import { truncate } from "../../utils/visual.ts";
28
+ import { spinnerFrame } from "../spinner.ts";
29
+ import { shortRunLabel } from "./widget-model.ts";
30
+ import type { WidgetRun } from "./widget-types.ts";
31
+
32
+ /** Task rows painted before the "… and N more" overflow line (pi-tasks
33
+ * defaults its `maxVisible` to 10). */
34
+ const MAX_TASK_ROWS = 10;
35
+
36
+ const ACTIVE_TASK_STATUSES = new Set(["running", "queued", "waiting", "needs_attention"]);
37
+
38
+ // pi-tasks-style strikethrough + dim for completed rows: raw SGR because the
39
+ // theme adapter has no strike; the truncate/colorize paths are ANSI-aware.
40
+ const DIM_ON = "\x1b[2m";
41
+ const DIM_OFF = "\x1b[22m";
42
+ const STRIKE_ON = "\x1b[9m";
43
+ const STRIKE_OFF = "\x1b[29m";
44
+
45
+ function isDoneStatus(status: string): boolean {
46
+ return status === "completed" || status === "failed" || status === "cancelled" || status === "skipped";
47
+ }
48
+
49
+ function taskTitle(task: TeamTaskState): string {
50
+ return (task.displayName ?? task.title ?? "").replace(/\s+/g, " ").trim();
51
+ }
52
+
53
+ /** pi-tasks status glyph per row: ✔ done (struck through), spinner for the
54
+ * actively executing task, ◼ parked/needs input, ◻ queued, ✗ dead. */
55
+ function taskStatusIcon(task: TeamTaskState): string {
56
+ switch (task.status) {
57
+ case "running":
58
+ return spinnerFrame("crew-task-list");
59
+ case "queued":
60
+ return "◻";
61
+ case "waiting":
62
+ case "needs_attention":
63
+ return "◼";
64
+ case "completed":
65
+ return "✔";
66
+ case "failed":
67
+ case "cancelled":
68
+ case "skipped":
69
+ return "✗";
70
+ default:
71
+ return "?";
72
+ }
73
+ }
74
+
75
+ /** `45s` / `2m 49s` / `1h 03m`. */
76
+ function formatDuration(ms: number): string {
77
+ if (!Number.isFinite(ms) || ms < 0) return "";
78
+ const totalSeconds = Math.floor(ms / 1000);
79
+ if (totalSeconds < 60) return `${totalSeconds}s`;
80
+ const minutes = Math.floor(totalSeconds / 60);
81
+ if (minutes < 60) return `${minutes}m ${String(totalSeconds % 60).padStart(2, "0")}s`;
82
+ const hours = Math.floor(minutes / 60);
83
+ return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
84
+ }
85
+
86
+ /** `4.1k` / `1.2M` / `918` — no unit suffix; the arrow pair carries it. */
87
+ function compactTokens(count: number): string {
88
+ if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) return "";
89
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
90
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
91
+ return `${count}`;
92
+ }
93
+
94
+ /** `(2m 49s · ↑ 4.1k ↓ 1.2k)` — elapsed plus tokens, pi-tasks style. Live
95
+ * totals come from agentProgress until the final usage lands. */
96
+ function runningSuffix(task: TeamTaskState): string {
97
+ const parts: string[] = [];
98
+ const start = Date.parse(task.startedAt ?? "");
99
+ if (Number.isFinite(start) && start > 0) {
100
+ const end = task.finishedAt ? Date.parse(task.finishedAt) : Date.now();
101
+ const elapsed = formatDuration(end - start);
102
+ if (elapsed) parts.push(elapsed);
103
+ }
104
+ const input = task.usage?.input ?? 0;
105
+ const output = task.usage?.output ?? 0;
106
+ if (input > 0 || output > 0) {
107
+ parts.push(`↑ ${compactTokens(input) || "0"} ↓ ${compactTokens(output) || "0"}`);
108
+ } else {
109
+ const live = compactTokens(task.agentProgress?.tokens ?? 0);
110
+ if (live) parts.push(live);
111
+ }
112
+ return parts.length ? ` (${parts.join(" · ")})` : "";
113
+ }
114
+
115
+ /** `› blocked by #2, #3` for queued tasks — open (non-completed)
116
+ * dependencies only, named by task number (pi-tasks display rule). */
117
+ function blockedSuffix(task: TeamTaskState, tasks: readonly TeamTaskState[], numberById: Map<string, number>): string {
118
+ if (task.status !== "queued") return "";
119
+ const openDeps = (task.dependsOn ?? [])
120
+ .map((depId) => tasks.find((entry) => entry.id === depId))
121
+ .filter((dep): dep is TeamTaskState => dep !== undefined && dep.status !== "completed");
122
+ if (openDeps.length === 0) return "";
123
+ const labels = openDeps.map((dep) => `#${numberById.get(dep.id) ?? "?"}`);
124
+ return ` › blocked by ${labels.join(", ")}`;
125
+ }
126
+
127
+ /** One line per task: `#n title` behind its status glyph — pi-tasks style.
128
+ * Completed titles are dimmed and struck through. */
129
+ function taskRow(
130
+ task: TeamTaskState,
131
+ taskNumber: number,
132
+ tasks: readonly TeamTaskState[],
133
+ numberById: Map<string, number>,
134
+ width: number,
135
+ ): string {
136
+ const title = taskTitle(task);
137
+ const suffix = task.status === "running" ? runningSuffix(task) : blockedSuffix(task, tasks, numberById);
138
+ if (task.status === "completed") {
139
+ const struck = `${STRIKE_ON}${DIM_ON}#${taskNumber} ${title}${DIM_OFF}${STRIKE_OFF}`;
140
+ return truncate(` ✔ ${struck}`.trimEnd(), width);
141
+ }
142
+ return truncate(` ${taskStatusIcon(task)} #${taskNumber} ${title}${suffix}`.trimEnd(), width);
143
+ }
144
+
145
+ /** The run whose plan is shown: the first with unfinished work, else the first. */
146
+ function primaryRun(runs: readonly WidgetRun[]): WidgetRun | undefined {
147
+ return runs.find((entry) => (entry.snapshot?.tasks ?? []).some((task) => ACTIVE_TASK_STATUSES.has(task.status))) ?? runs[0];
148
+ }
149
+
150
+ /** Which rows fit the cap: plan order, but never drop an unfinished task —
151
+ * if active work falls outside the first MAX_TASK_ROWS rows it stays and
152
+ * finished rows give way instead. */
153
+ function visibleTasks(tasks: readonly TeamTaskState[]): TeamTaskState[] {
154
+ if (tasks.length <= MAX_TASK_ROWS) return [...tasks];
155
+ const active = tasks.filter((task) => ACTIVE_TASK_STATUSES.has(task.status));
156
+ if (active.length >= MAX_TASK_ROWS) return active.slice(0, MAX_TASK_ROWS);
157
+ const keep = new Set(active.map((task) => task.id));
158
+ for (const task of tasks) {
159
+ if (keep.size >= MAX_TASK_ROWS) break;
160
+ keep.add(task.id);
161
+ }
162
+ return tasks.filter((task) => keep.has(task.id));
163
+ }
164
+
165
+ /**
166
+ * Build the task-list lines for the current runs. Empty when no run carries a
167
+ * tasks slice (nothing to say — the dock alone stays).
168
+ */
169
+ export function buildTaskListLines(runs: readonly WidgetRun[], width: number): string[] {
170
+ const entry = primaryRun(runs);
171
+ if (!entry) return [];
172
+ const tasks = entry.snapshot?.tasks ?? [];
173
+ if (tasks.length === 0) return [];
174
+
175
+ const numberById = new Map(tasks.map((task, index) => [task.id, index + 1]));
176
+ const done = tasks.filter((task) => task.status === "completed").length;
177
+ const dead = tasks.filter((task) => isDoneStatus(task.status) && task.status !== "completed").length;
178
+ const inProgress = tasks.filter(
179
+ (task) => task.status === "running" || task.status === "waiting" || task.status === "needs_attention",
180
+ ).length;
181
+ const open = tasks.filter((task) => task.status === "queued").length;
182
+
183
+ const counts = [`${done} done`];
184
+ if (dead) counts.push(`${dead} failed`);
185
+ counts.push(`${inProgress} in progress`, `${open} open`);
186
+ const headerParts = [`● ${tasks.length} tasks (${counts.join(", ")})`];
187
+ if (runs.length > 1) headerParts.push(shortRunLabel(entry.run));
188
+ const lines = [truncate(headerParts.join(" · "), width)];
189
+
190
+ const visible = visibleTasks(tasks);
191
+ for (const task of visible) {
192
+ lines.push(taskRow(task, numberById.get(task.id) ?? 0, tasks, numberById, width));
193
+ }
194
+ if (tasks.length > visible.length) {
195
+ lines.push(truncate(` … and ${tasks.length - visible.length} more`, width));
196
+ }
197
+ return lines;
198
+ }
@@ -7,7 +7,7 @@
7
7
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
8
8
  import type { LiveAgentHandle } from "../../runtime/live-session/live-agent-manager.ts";
9
9
  import { getTaskUsage } from "../../runtime/usage-tracker.ts";
10
- import { visibleWidth } from "../../utils/visual.ts";
10
+ import { truncateToWidth, visibleWidth } from "../../utils/visual.ts";
11
11
  import { computeLiveDurationMs } from "../live-duration.ts";
12
12
 
13
13
  // ── No-color mode (UI-10) ─────────────────────────────────────────────
@@ -75,6 +75,7 @@ const TOKENS_METRIC_WIDTH = 10; // "1.2k tok", "12.3M tok"
75
75
  const TPS_METRIC_WIDTH = 9; // "411 tok/s"
76
76
  const CTX_METRIC_WIDTH = 7; // "100% ctx"
77
77
  const DURATION_METRIC_WIDTH = 6; // "120.0s"
78
+ const COST_METRIC_WIDTH = 9; // "$0.001234"
78
79
 
79
80
  function alignMetric(value: string, width: number): string {
80
81
  const pad = Math.max(0, width - visibleWidth(value));
@@ -82,6 +83,11 @@ function alignMetric(value: string, width: number): string {
82
83
  }
83
84
 
84
85
  export function formatTokensCompact(count: number): string {
86
+ // Display-layer guard: state records have at least once carried the
87
+ // literal "***" in a numeric field (redaction false-positive, fixed at
88
+ // the source). A string here would print `*** tok` verbatim, so non-
89
+ // numeric/undefined input renders as an empty metric instead.
90
+ if (typeof count !== "number" || !Number.isFinite(count)) return "";
85
91
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M tok`;
86
92
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k tok`;
87
93
  return `${count} tok`;
@@ -99,6 +105,13 @@ export function elapsed(iso: string | undefined, now = Date.now()): string | und
99
105
  return `${Math.floor(ms / 3_600_000)}h`;
100
106
  }
101
107
 
108
+ /** pi-subtask's always-on elapsed tail: `0s` from the start (its fork rows
109
+ * flatten sub-second ages to 0 — not the legacy widget's "now"). */
110
+ export function dockElapsed(iso: string | undefined, now = Date.now()): string {
111
+ const value = elapsed(iso, now);
112
+ return value === undefined ? "" : value === "now" ? "0s" : value;
113
+ }
114
+
102
115
  // ── Agent activity description ────────────────────────────────────────
103
116
 
104
117
  const TOOL_LABELS: Record<string, string> = {
@@ -176,6 +189,218 @@ export function agentActivity(agent: CrewAgentRecord, liveHandle?: LiveAgentHand
176
189
  return "done";
177
190
  }
178
191
 
192
+ // ── Per-agent cost ────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Compact per-agent spend for widget rows, or "" when there is nothing to
196
+ * show. `formatCost`'s 6-decimal sub-cent output (`$0.001000`) wastes a third
197
+ * of a one-line row, so the widget uses a short form: cent precision in
198
+ * dollars, milli-precision below, and a `< $0.001` floor.
199
+ */
200
+ export function formatCostCompact(cost: number): string {
201
+ if (cost >= 1) return `$${cost.toFixed(2)}`;
202
+ if (cost >= 0.01) return `$${cost.toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}`;
203
+ if (cost >= 0.001) return `$${cost.toFixed(4).replace(/0+$/, "").replace(/\.$/, "")}`;
204
+ return "< $0.001";
205
+ }
206
+
207
+ /**
208
+ * Formatted per-agent spend, or "" when there is nothing to show. The value
209
+ * already lives on the durable task record and the dashboard agents pane has
210
+ * shown it since Round 17; the widget omitted it only by oversight.
211
+ */
212
+ export function agentCost(agent: CrewAgentRecord): string {
213
+ const cost = agent.usage?.cost;
214
+ if (typeof cost !== "number" || !Number.isFinite(cost) || cost <= 0) return "";
215
+ return formatCostCompact(cost);
216
+ }
217
+
218
+ // ── pi-subtask dock formatters ─────────────────────────────────────────
219
+
220
+ /**
221
+ * pi-subtask's FIXED per-status glyphs (`statusIcon` in source/pi-subtask:
222
+ * starting ○, running ✻, done ✓, failed ✗, stopped ■). The compact dock
223
+ * deliberately does NOT spin the running marker like the legacy widget —
224
+ * the row set is stable across ticks, which is what makes the keyboard
225
+ * cursor feel anchored.
226
+ */
227
+ export function dockStatusIcon(status: string): string {
228
+ switch (status) {
229
+ case "running":
230
+ return "✻";
231
+ case "queued":
232
+ case "waiting":
233
+ return "○";
234
+ case "completed":
235
+ return "✓";
236
+ case "failed":
237
+ return "✗";
238
+ case "needs_attention":
239
+ return "⚠";
240
+ case "cancelled":
241
+ case "stopped":
242
+ return "■";
243
+ default:
244
+ return "?";
245
+ }
246
+ }
247
+
248
+ /** pi-subtask uses the status word as a finished row's activity. */
249
+ export function dockStatusLabel(status: string): string {
250
+ switch (status) {
251
+ case "completed":
252
+ return "done";
253
+ case "failed":
254
+ return "failed";
255
+ case "cancelled":
256
+ case "stopped":
257
+ return "stopped";
258
+ case "needs_attention":
259
+ return "needs attention";
260
+ case "queued":
261
+ return "queued";
262
+ case "waiting":
263
+ return "waiting";
264
+ default:
265
+ return status;
266
+ }
267
+ }
268
+
269
+ /** pi-subtask's formatTokens: raw under 1k, `Nk` under 1M, `N.M` above. */
270
+ export function tokenCountShort(count: number): string {
271
+ if (count < 1_000) return `${count}`;
272
+ if (count < 1_000_000) return `${Math.round(count / 1_000)}k`;
273
+ return `${(count / 1_000_000).toFixed(1)}M`;
274
+ }
275
+
276
+ export interface DockUsageOptions {
277
+ /** While the agent's pane is open: add tok/s + context % like pi-subtask. */
278
+ viewed?: boolean;
279
+ /** Model context window for the `P% / N` gauge; omitted when unknown. */
280
+ contextWindow?: number;
281
+ }
282
+
283
+ /**
284
+ * pi-subtask's footer-style usage (formatUsage): `↑in ↓out Rcache CH% $cost`,
285
+ * plus `N tok/s` and `P% / window` while viewed. Live agents have split
286
+ * input/output/cacheWrite via the usage tracker; non-live ones show the
287
+ * durable token total + cost.
288
+ */
289
+ export function dockUsageText(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle, options: DockUsageOptions = {}): string {
290
+ const parts: string[] = [];
291
+ if (liveHandle) {
292
+ const usage = getTaskUsage(liveHandle.taskId);
293
+ const input = usage.input ?? 0;
294
+ const output = usage.output ?? 0;
295
+ const cacheWrite = usage.cacheWrite ?? 0;
296
+ if (input > 0) parts.push(`↑${tokenCountShort(input)}`);
297
+ if (output > 0) parts.push(`↓${tokenCountShort(output)}`);
298
+ if (cacheWrite > 0) parts.push(`R${tokenCountShort(cacheWrite)}`);
299
+ const promptTokens = input + cacheWrite;
300
+ if (cacheWrite > 0 && promptTokens > 0) {
301
+ parts.push(`CH${((cacheWrite / promptTokens) * 100).toFixed(1)}%`);
302
+ }
303
+ const cost = agent.usage?.cost;
304
+ if (typeof cost === "number" && Number.isFinite(cost) && cost > 0) parts.push(`$${cost.toFixed(4)}`);
305
+ if (options.viewed) {
306
+ const act = liveHandle.activity;
307
+ const ms = computeLiveDurationMs(act);
308
+ const totalTokens = input + output + cacheWrite;
309
+ if (totalTokens > 0 && ms > 1000) {
310
+ const tps = Math.round(totalTokens / (ms / 1000));
311
+ if (tps > 0) parts.push(`${tps} tok/s`);
312
+ }
313
+ try {
314
+ const ctxPct = liveHandle.session.getSessionStats?.()?.contextUsage?.percent;
315
+ if (ctxPct != null) {
316
+ const window =
317
+ options.contextWindow && options.contextWindow >= 1_000_000
318
+ ? `${(options.contextWindow / 1_000_000).toFixed(1)}M`
319
+ : options.contextWindow
320
+ ? tokenCountShort(options.contextWindow)
321
+ : "";
322
+ parts.push(`${Math.round(ctxPct)}%${window ? ` / ${window}` : ""}`);
323
+ }
324
+ } catch {
325
+ /* ignore */
326
+ }
327
+ }
328
+ return parts.join(" ");
329
+ }
330
+ const tokens = agent.progress?.tokens;
331
+ const tokenCount = typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
332
+ if (tokenCount > 0) parts.push(`${tokenCountShort(tokenCount)} tok`);
333
+ const cost = agent.usage?.cost;
334
+ if (typeof cost === "number" && Number.isFinite(cost) && cost > 0) parts.push(`$${cost.toFixed(4)}`);
335
+ return parts.join(" ");
336
+ }
337
+
338
+ // ── Adaptive single-line row ───────────────────────────────────────────
339
+
340
+ export interface BudgetedRowParts {
341
+ /** Marker + glyph prefix; never trimmed. */
342
+ lead: string;
343
+ /** Agent label; grows into whatever the activity leaves over. */
344
+ name: string;
345
+ /** Current activity; shrinks first but keeps a readable floor. */
346
+ activity: string;
347
+ /** Metrics tail; never trimmed, so numbers stay comparable across ticks. */
348
+ suffix: string;
349
+ separator?: string;
350
+ }
351
+
352
+ /** Smallest activity/name slice still worth showing before we drop the field. */
353
+ const MIN_FIELD_WIDTH = 12;
354
+
355
+ /**
356
+ * Assemble one row that fills `width` without wrapping.
357
+ *
358
+ * `lead` and `suffix` are fixed costs; the remaining budget is split between
359
+ * `name` and `activity`. The activity absorbs the trimming first (it changes
360
+ * every tick anyway) but keeps a MIN_FIELD_WIDTH floor, and the name expands
361
+ * into the rest up to its natural length — so a 200-column terminal shows the
362
+ * full description instead of the same clip an 80-column one gets.
363
+ *
364
+ * The closing truncate is a hard guard, not an optimisation: pi's renderer
365
+ * throws on a line wider than the terminal.
366
+ */
367
+ export function budgetedRow(parts: BudgetedRowParts, width: number): string {
368
+ const sep = parts.separator ?? " · ";
369
+ const { lead, suffix } = parts;
370
+ const name = parts.name.replace(/\s+/g, " ").trim();
371
+ const activity = parts.activity.replace(/\s+/g, " ").trim();
372
+ if (!name && !activity) return truncateToWidth(lead + suffix, width);
373
+ if (!activity) return fitNameOnly(lead, name, suffix, width);
374
+ if (!name) return fitNameOnly(lead, activity, suffix, width);
375
+
376
+ const budget = width - visibleWidth(lead) - visibleWidth(suffix) - visibleWidth(sep);
377
+ // Too narrow to hold name + activity above their floors: keep the name
378
+ // only, and never let the metrics tail eat the clipping.
379
+ if (budget < MIN_FIELD_WIDTH * 2) return fitNameOnly(lead, name, suffix, width);
380
+
381
+ const nameNatural = visibleWidth(name);
382
+ const activityNatural = visibleWidth(activity);
383
+ const activityRoom = Math.min(activityNatural, Math.max(MIN_FIELD_WIDTH, budget - nameNatural));
384
+ const nameRoom = Math.max(MIN_FIELD_WIDTH, budget - activityRoom);
385
+ const assembled = lead + truncateToWidth(name, nameRoom) + sep + truncateToWidth(activity, activityRoom) + suffix;
386
+ // The room arithmetic keeps this ≤ width by construction (nameRoom +
387
+ // activityRoom ≤ budget in every branch); this guard is the last line of
388
+ // defense because pi's renderer throws on over-width lines.
389
+ if (visibleWidth(assembled) <= width) return assembled;
390
+ return fitNameOnly(lead, name, suffix, width);
391
+ }
392
+
393
+ /**
394
+ * Narrow-terminal fallback: fixed lead + suffix, the name absorbs every
395
+ * remaining column. The metrics tail is sacred — it must stay comparable
396
+ * across ticks — so clipping always happens in the middle fields.
397
+ */
398
+ function fitNameOnly(lead: string, name: string, suffix: string, width: number): string {
399
+ const fixed = visibleWidth(lead) + visibleWidth(suffix);
400
+ if (fixed >= width) return truncateToWidth(lead + suffix, width);
401
+ return lead + truncateToWidth(name, width - fixed) + suffix;
402
+ }
403
+
179
404
  // ── Agent stats line ──────────────────────────────────────────────────
180
405
 
181
406
  export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle): string {
@@ -186,6 +411,10 @@ export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle)
186
411
  const usage = getTaskUsage(liveHandle.taskId);
187
412
  const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheWrite ?? 0);
188
413
  if (total > 0) parts.push(alignMetric(formatTokensCompact(total), TOKENS_METRIC_WIDTH));
414
+ // The live usage tracker carries tokens only (LifetimeUsage has no cost
415
+ // field), so cost always comes off the durable task record.
416
+ const liveCost = agentCost(agent);
417
+ if (liveCost) parts.push(alignMetric(liveCost, COST_METRIC_WIDTH));
189
418
  try {
190
419
  const stats = liveHandle.session.getSessionStats?.();
191
420
  const ctxPct = stats?.contextUsage?.percent;
@@ -200,11 +429,18 @@ export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle)
200
429
  }
201
430
  parts.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
202
431
  } else {
432
+ // Type-narrowed: state has carried the literal "***" (redaction
433
+ // false-positive) in progress.tokens, which is truthy but not a count.
434
+ // Only real numbers produce metrics; formatTokensCompact guards too.
435
+ const tokens = agent.progress?.tokens;
436
+ const tokenCount = typeof tokens === "number" ? tokens : undefined;
203
437
  if (agent.toolUses) parts.push(alignMetric(`${agent.toolUses} tools`, TOOLS_METRIC_WIDTH));
204
- if (agent.progress?.tokens) parts.push(alignMetric(formatTokensCompact(agent.progress.tokens), TOKENS_METRIC_WIDTH));
438
+ if (tokenCount && tokenCount > 0) parts.push(alignMetric(formatTokensCompact(tokenCount), TOKENS_METRIC_WIDTH));
439
+ const cost = agentCost(agent);
440
+ if (cost) parts.push(alignMetric(cost, COST_METRIC_WIDTH));
205
441
  const ageMs = agent.startedAt ? Math.max(0, Date.now() - new Date(agent.startedAt).getTime()) : 0;
206
- if (agent.progress?.tokens && ageMs > 1000) {
207
- const tps = Math.round(agent.progress.tokens / (ageMs / 1000));
442
+ if (tokenCount && tokenCount > 0 && ageMs > 1000) {
443
+ const tps = Math.round(tokenCount / (ageMs / 1000));
208
444
  if (tps > 0) parts.push(alignMetric(`${formatTokensCompact(tps)}/s`, TPS_METRIC_WIDTH));
209
445
  }
210
446
  const age = elapsed(agent.completedAt ?? agent.startedAt);