pi-crew 0.10.1 → 0.10.3

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 (127) hide show
  1. package/CHANGELOG.md +347 -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 +2555 -932
  7. package/package.json +2 -1
  8. package/schema.json +503 -94
  9. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  10. package/skills/real-test-pi-crew/SKILL.md +278 -79
  11. package/src/config/config-merge.ts +11 -1
  12. package/src/config/config-validation.ts +47 -2
  13. package/src/config/config.ts +28 -6
  14. package/src/config/defaults.ts +43 -11
  15. package/src/config/env-vars.ts +27 -2
  16. package/src/config/role-tools.ts +4 -2
  17. package/src/config/types.ts +55 -1
  18. package/src/extension/crew-cleanup.ts +13 -0
  19. package/src/extension/crew-vibes/footer.ts +19 -0
  20. package/src/extension/crew-vibes/index.ts +11 -1
  21. package/src/extension/register.ts +8 -0
  22. package/src/extension/registration/command-registration.ts +1 -0
  23. package/src/extension/registration/commands/run.ts +15 -1
  24. package/src/extension/registration/commands/shared.ts +8 -0
  25. package/src/extension/registration/foreground-run-controller.ts +10 -2
  26. package/src/extension/registration/lifecycle-handlers.ts +92 -11
  27. package/src/extension/registration/runtime-cleanup.ts +23 -5
  28. package/src/extension/registration/team-tool.ts +58 -6
  29. package/src/extension/registration/ui.ts +5 -4
  30. package/src/extension/team-tool/doctor.ts +364 -7
  31. package/src/extension/team-tool/handle-settings.ts +19 -0
  32. package/src/extension/team-tool/inspect.ts +10 -2
  33. package/src/extension/team-tool/run-deadline.ts +20 -3
  34. package/src/extension/team-tool/run.ts +26 -2
  35. package/src/extension/team-tool/status.ts +7 -0
  36. package/src/extension/team-tool.ts +35 -2
  37. package/src/hooks/registry.ts +59 -56
  38. package/src/prompt/inbox-poll.ts +90 -0
  39. package/src/prompt/message-tool.ts +166 -0
  40. package/src/prompt/prompt-runtime.ts +201 -18
  41. package/src/prompt/surface-worker.ts +720 -0
  42. package/src/prompt/worker-events-channel.ts +49 -3
  43. package/src/runtime/async-runner.ts +29 -1
  44. package/src/runtime/background-runner.ts +13 -7
  45. package/src/runtime/broker/broker-issuer.ts +27 -2
  46. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  47. package/src/runtime/broker/crew-broker.ts +261 -41
  48. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  49. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  50. package/src/runtime/child-pi/child-pi-streams.ts +30 -2
  51. package/src/runtime/child-pi/child-pi.ts +353 -5
  52. package/src/runtime/crew-agent-records.ts +13 -1
  53. package/src/runtime/detached-run-results.ts +90 -0
  54. package/src/runtime/dispatch-batch.ts +12 -1
  55. package/src/runtime/event-log-tail-source.ts +374 -0
  56. package/src/runtime/finalize-run.ts +4 -0
  57. package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
  58. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
  59. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  60. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  61. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  62. package/src/runtime/manifest-cache.ts +128 -17
  63. package/src/runtime/merge-gate.ts +25 -9
  64. package/src/runtime/model/model-fallback.ts +7 -3
  65. package/src/runtime/model/pi-args.ts +54 -65
  66. package/src/runtime/output/sidechain-output.ts +61 -6
  67. package/src/runtime/process/proc-stat.ts +46 -0
  68. package/src/runtime/process/zombie-scanner.ts +32 -19
  69. package/src/runtime/process-status.ts +16 -1
  70. package/src/runtime/recovery/crash-recovery.ts +16 -0
  71. package/src/runtime/run-tracker.ts +77 -10
  72. package/src/runtime/spawn-policy.ts +27 -41
  73. package/src/runtime/surface/degrade.ts +776 -0
  74. package/src/runtime/surface/herdr-provider.ts +546 -0
  75. package/src/runtime/surface/launch-script.ts +172 -0
  76. package/src/runtime/surface/resolve-surface.ts +274 -0
  77. package/src/runtime/surface/surface-provider.ts +129 -0
  78. package/src/runtime/surface/surface-spawn.ts +475 -0
  79. package/src/runtime/surface/tmux-provider.ts +400 -0
  80. package/src/runtime/task-runner/child-executor.ts +47 -0
  81. package/src/runtime/task-runner/post-execution.ts +57 -2
  82. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  83. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  84. package/src/runtime/task-runner/state-helpers.ts +54 -30
  85. package/src/runtime/task-runner.ts +4 -2
  86. package/src/runtime/team-runner.ts +104 -2
  87. package/src/schema/config-schema.ts +25 -1
  88. package/src/state/atomic-write.ts +219 -40
  89. package/src/state/coordination/locks.ts +7 -5
  90. package/src/state/coordination/mailbox.ts +56 -10
  91. package/src/state/event-log/cursor.ts +413 -23
  92. package/src/state/event-log/event-log.ts +120 -113
  93. package/src/state/event-log/sequence-cache.ts +21 -3
  94. package/src/state/stores/plan-store.ts +1 -1
  95. package/src/state/stores/state-store.ts +171 -9
  96. package/src/state/types.ts +53 -0
  97. package/src/ui/dock-footer.ts +49 -0
  98. package/src/ui/inline-panel/agent-pane.ts +378 -0
  99. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  100. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  101. package/src/ui/inline-panel/crew-editor.ts +192 -0
  102. package/src/ui/inline-panel/index.ts +290 -0
  103. package/src/ui/inline-panel/panel-rows.ts +37 -0
  104. package/src/ui/inline-panel/panel-selection.ts +157 -0
  105. package/src/ui/inline-panel/panel-store.ts +111 -0
  106. package/src/ui/inline-panel/view-session-store.ts +36 -0
  107. package/src/ui/pi-ui-compat.ts +9 -0
  108. package/src/ui/render-diff.ts +16 -8
  109. package/src/ui/run-dashboard.ts +87 -42
  110. package/src/ui/run-event-bus.ts +10 -1
  111. package/src/ui/run-snapshot-cache.ts +83 -35
  112. package/src/ui/transcript-cache.ts +101 -13
  113. package/src/ui/transcript-viewer.ts +92 -24
  114. package/src/ui/widget/index.ts +203 -25
  115. package/src/ui/widget/task-list.ts +198 -0
  116. package/src/ui/widget/widget-formatters.ts +240 -4
  117. package/src/ui/widget/widget-renderer.ts +234 -37
  118. package/src/ui/widget/widget-types.ts +11 -0
  119. package/src/utils/child-process-shield.ts +106 -0
  120. package/src/utils/redaction.ts +7 -0
  121. package/src/utils/safe-abort.ts +45 -0
  122. package/src/utils/visual.ts +43 -0
  123. package/src/workflows/discover-workflows.ts +1 -0
  124. package/src/workflows/workflow-config.ts +7 -0
  125. package/src/worktree/worktree-manager.ts +65 -4
  126. package/workflows/default.workflow.md +36 -26
  127. package/workflows/strict-fast-fix.workflow.md +26 -0
@@ -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);
@@ -4,19 +4,30 @@
4
4
  * Extracted from crew-widget.ts.
5
5
  */
6
6
 
7
+ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
7
8
  import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
8
9
  import { isPlanApprovalStatePending } from "../../runtime/plan-approval.ts";
9
10
  import { isFinishedRunStatus } from "../../runtime/process-status.ts";
11
+ import type { TeamRunManifest } from "../../state/types.ts";
10
12
  import { truncate } from "../../utils/visual.ts";
11
13
  import { Box, Text } from "../layout-primitives.ts";
12
14
  import { spinnerFrame } from "../spinner.ts";
13
15
  import { colorizeStatusGlyphs, iconForStatus } from "../status-colors.ts";
14
16
  import type { CrewTheme } from "../theme-adapter.ts";
15
- import { agentActivity, agentStats, notificationBadge } from "./widget-formatters.ts";
17
+ import {
18
+ agentActivity,
19
+ agentStats,
20
+ budgetedRow,
21
+ dockElapsed,
22
+ dockStatusIcon,
23
+ dockStatusLabel,
24
+ dockUsageText,
25
+ notificationBadge,
26
+ } from "./widget-formatters.ts";
16
27
  import { activeWidgetRuns, shortRunLabel } from "./widget-model.ts";
17
28
  import type { WidgetRun } from "./widget-types.ts";
18
29
 
19
- const MAX_AGENTS_DISPLAY = 3;
30
+ export const MAX_AGENTS_DISPLAY = 3;
20
31
  const FINISHED_LINGER_MAX_AGE = 1;
21
32
  /** Default terminal width when caller doesn't pass one explicitly. Keep <= 116
22
33
  * (the same default used elsewhere in pi-crew tool renderers) so we never paint
@@ -43,8 +54,192 @@ export function widgetHeader(runs: WidgetRun[], runningGlyph: string, maxLines =
43
54
  return `${runningGlyph} Crew agents${notificationBadge(notificationCount)} · ${parts.join(" · ")} · /team-dashboard`;
44
55
  }
45
56
 
57
+ // ── Agent ordering (shared with the inline panel) ──────────────────────
58
+
59
+ /**
60
+ * L-4: prioritize RUNNING > QUEUED > WAITING so the most relevant live workers
61
+ * are always shown first. Finished rows fill only the leftover budget and never
62
+ * steal a slot from an active agent.
63
+ */
64
+ const ACTIVE_PRIORITY: Record<string, number> = { running: 0, queued: 1, waiting: 2 };
65
+
66
+ function isActiveStatus(status: string): boolean {
67
+ return status === "running" || status === "queued" || status === "waiting";
68
+ }
69
+
70
+ /**
71
+ * The agent order the widget paints, split into its two sections.
72
+ *
73
+ * Exported because the inline panel navigates the same list: if the panel
74
+ * derived its own order, the cursor index would drift from the rendered rows.
75
+ * One function, one order.
76
+ */
77
+ export function orderWidgetAgents(entry: WidgetRun, now = Date.now()): { active: CrewAgentRecord[]; finished: CrewAgentRecord[] } {
78
+ const runDone = isFinishedRunStatus(entry.run.status);
79
+ const active = entry.agents.filter((agent) => isActiveStatus(agent.status));
80
+ const finished = entry.agents.filter((agent) => {
81
+ if (isActiveStatus(agent.status)) return false;
82
+ if (!agent.completedAt) return false;
83
+ // Mid-run, finished agents are the run's HISTORY: they must stay in
84
+ // the dock until the RUN itself is done, not age out after a minute
85
+ // while later phases are still working. The linger windows below only
86
+ // apply once the run reached a terminal status (and the run-level
87
+ // visibility grace then decides how much longer the dock shows at all).
88
+ if (!runDone) return true;
89
+ const maxAgeMs = (ERROR_STATUSES.has(agent.status) ? ERROR_LINGER_MAX_AGE : FINISHED_LINGER_MAX_AGE) * 60_000;
90
+ const age = now - new Date(agent.completedAt).getTime();
91
+ return Number.isFinite(age) && age < maxAgeMs;
92
+ });
93
+ return {
94
+ active: [...active].sort((a, b) => (ACTIVE_PRIORITY[a.status] ?? 9) - (ACTIVE_PRIORITY[b.status] ?? 9)),
95
+ finished,
96
+ };
97
+ }
98
+
46
99
  // ── Line builder ──────────────────────────────────────────────────────
47
100
 
101
+ /**
102
+ * Row layout for the per-agent lines.
103
+ *
104
+ * - `detailed` — the historical two-line tree (name row + `⊶ activity` row).
105
+ * - `compact` — one width-budgeted line per agent, so a wide terminal shows the
106
+ * full description instead of the same clip a narrow one gets.
107
+ */
108
+ export type WidgetRowStyle = "compact" | "detailed";
109
+
110
+ export interface WidgetRenderOptions {
111
+ rowStyle?: WidgetRowStyle;
112
+ /** Task id under the inline panel cursor, if any. */
113
+ selectedTaskId?: string;
114
+ /** Task id whose transcript pane is open, if any. */
115
+ viewedTaskId?: string;
116
+ /**
117
+ * True while the inline panel holds the cursor. Every agent is then listed
118
+ * (no MAX_AGENTS_DISPLAY cap) so keyboard navigation can reach all of them;
119
+ * the idle widget stays capped to keep the prompt area small.
120
+ */
121
+ focused?: boolean;
122
+ }
123
+
124
+ /** Short display form of a model id: `zai/glm-5.3` → `glm-5.3`. */
125
+ function shortModelLabel(agent: CrewAgentRecord, run: TeamRunManifest): string | undefined {
126
+ const model = agent.model ?? run.modelContext?.parentModel ?? run.modelContext?.override;
127
+ if (typeof model !== "string" || !model) return undefined;
128
+ return model.split("/").at(-1) ?? model;
129
+ }
130
+
131
+ /** One flat dock row (pi-subtask style) for an agent — active or finished. */
132
+ function compactAgentRow(
133
+ run: TeamRunManifest,
134
+ agent: CrewAgentRecord,
135
+ finished: boolean,
136
+ runs: readonly WidgetRun[],
137
+ options: WidgetRenderOptions,
138
+ width: number,
139
+ liveHandle: ReturnType<typeof listLiveAgents>[number] | undefined,
140
+ ): string {
141
+ const marker = options.selectedTaskId === agent.taskId ? "❯" : " ";
142
+ const dockGlyph = options.viewedTaskId === agent.taskId ? "⏺" : dockStatusIcon(agent.status);
143
+ const name = liveHandle?.agent ?? agent.agent;
144
+ const label = liveHandle?.description ?? agent.role ?? "";
145
+ // Task-first: the agent exists to run its task, so the row names the task
146
+ // right after the agent. With multiple runs, prefix each row with its run
147
+ // label so the flat dock still says which run an agent belongs to.
148
+ const runTag = runs.length > 1 ? `${shortRunLabel(run)} · ` : "";
149
+ const taskTag = agent.taskId ? ` · ${agent.taskId}` : "";
150
+ const roleTag = label && label !== agent.taskId && label !== name ? ` · ${label}` : "";
151
+ const nameText = runTag + name + taskTag + roleTag;
152
+ // pi-subtask activity: the worker's latest line while running, otherwise
153
+ // the status word.
154
+ const liveLine = liveHandle?.activity?.responseText
155
+ ?.split("\n")
156
+ .find((line) => line.trim())
157
+ ?.trim();
158
+ const activity =
159
+ !finished && liveHandle?.status === "running" && liveLine
160
+ ? liveLine.length > 60
161
+ ? `${liveLine.slice(0, 60)}…`
162
+ : liveLine
163
+ : finished
164
+ ? dockStatusLabel(agent.status)
165
+ : agentActivity(agent, liveHandle);
166
+ const usage = dockUsageText(agent, liveHandle, { viewed: options.viewedTaskId === agent.taskId });
167
+ const ageText = dockElapsed(agent.completedAt ?? agent.startedAt);
168
+ const model = shortModelLabel(agent, run);
169
+ // Stats tail: `· glm-5.3 · ↑1.2k ↓350 · 41s` — the model the worker is
170
+ // actually on first, then usage, then elapsed.
171
+ const suffix = `${model ? ` · ${model}` : ""}${usage ? ` · ${usage}` : ""}${ageText ? ` · ${ageText}` : ""}`;
172
+ return budgetedRow({ lead: `${marker} ${dockGlyph} `, name: nameText, activity, suffix }, width);
173
+ }
174
+
175
+ /**
176
+ * The compact dock: hint line, the `main` conversation row, then a
177
+ * MAX_AGENTS_DISPLAY-row SCROLL WINDOW over the flat agent list (exactly the
178
+ * order the inline panel navigates — `panelRowsFromRuns` parity). The window
179
+ * follows the panel selection bottom-pinned: moving the cursor past the
180
+ * window scrolls one row at a time and the ❯ marker is always painted; idle
181
+ * (no selection) shows the top of the list, which the shared ordering puts at
182
+ * the highest-priority live agents. Hidden rows surface as `… ↑N earlier` /
183
+ * `… +N more` indicators.
184
+ */
185
+ function compactDockLines(
186
+ runs: WidgetRun[],
187
+ options: WidgetRenderOptions,
188
+ width: number,
189
+ maxLines: number,
190
+ notificationCount: number,
191
+ runningGlyph: string,
192
+ ): string[] {
193
+ const now = Date.now();
194
+ const flat: Array<{
195
+ run: TeamRunManifest;
196
+ agent: CrewAgentRecord;
197
+ finished: boolean;
198
+ liveHandle: ReturnType<typeof listLiveAgents>[number] | undefined;
199
+ }> = [];
200
+ for (const entry of runs) {
201
+ const { active, finished } = orderWidgetAgents(entry, now);
202
+ const liveForRun = listLiveAgents().filter((a) => a.runId === entry.run.runId);
203
+ for (const agent of active) {
204
+ flat.push({ run: entry.run, agent, finished: false, liveHandle: liveForRun.find((h) => h.taskId === agent.taskId) });
205
+ }
206
+ for (const agent of finished) {
207
+ flat.push({ run: entry.run, agent, finished: true, liveHandle: liveForRun.find((h) => h.taskId === agent.taskId) });
208
+ }
209
+ }
210
+
211
+ // No agents at all: fall back to the legacy header so the space under the
212
+ // editor is never just blank.
213
+ if (flat.length === 0) return [widgetHeader(runs, runningGlyph, maxLines, notificationCount)];
214
+
215
+ const lines: string[] = [];
216
+ let hint: string;
217
+ if (options.viewedTaskId) {
218
+ const viewedName = flat.find((row) => row.agent.taskId === options.viewedTaskId)?.agent.agent ?? "agent";
219
+ hint = `viewing @${viewedName} — typing goes to the agent · ↓ switch · esc back`;
220
+ } else if (options.focused) {
221
+ hint = "enter to view · x to stop/cancel · esc back";
222
+ } else {
223
+ hint = `agents (${flat.length}) — ↓ to select`;
224
+ }
225
+ lines.push(truncate(hint, width));
226
+ // Filled ● = you're on the main conversation; hollow ◯ = an agent view is
227
+ // open (same convention as pi-subtask's main row).
228
+ const mainMarker = options.focused && !options.selectedTaskId ? "❯" : " ";
229
+ const mainIcon = options.viewedTaskId ? "◯" : "●";
230
+ lines.push(truncate(`${mainMarker} ${mainIcon} main`, width));
231
+
232
+ const selectedIndex = options.selectedTaskId ? flat.findIndex((row) => row.agent.taskId === options.selectedTaskId) : -1;
233
+ const windowStart = selectedIndex >= 0 ? Math.max(0, selectedIndex - MAX_AGENTS_DISPLAY + 1) : 0;
234
+ const windowEnd = Math.min(flat.length, windowStart + MAX_AGENTS_DISPLAY);
235
+ if (windowStart > 0) lines.push(truncate(` … ↑${windowStart} earlier (↑ to scroll)`, width));
236
+ for (const row of flat.slice(windowStart, windowEnd)) {
237
+ lines.push(compactAgentRow(row.run, row.agent, row.finished, runs, options, width, row.liveHandle));
238
+ }
239
+ if (windowEnd < flat.length) lines.push(truncate(` … +${flat.length - windowEnd} more (↓ to scroll)`, width));
240
+ return lines;
241
+ }
242
+
48
243
  export function buildWidgetLines(
49
244
  cwd: string,
50
245
  frame = 0,
@@ -52,7 +247,10 @@ export function buildWidgetLines(
52
247
  providedRuns?: WidgetRun[],
53
248
  notificationCount = 0,
54
249
  width = DEFAULT_WIDGET_WIDTH,
250
+ options: WidgetRenderOptions = {},
55
251
  ): string[] {
252
+ const rowStyle: WidgetRowStyle = options.rowStyle ?? "detailed";
253
+ const focused = options.focused === true;
56
254
  // Match the legacy `buildCrewWidgetLines` API: when no runs are supplied,
57
255
  // auto-fetch via activeWidgetRuns(cwd). Otherwise widgets calling with
58
256
  // only `(cwd, frame)` would render an empty line set (regression vs. the
@@ -61,18 +259,20 @@ export function buildWidgetLines(
61
259
  if (!runs.length) return [];
62
260
 
63
261
  const runningGlyph = spinnerFrame("widget-header");
262
+
263
+ // Compact = pi-subtask's dock: NO "Crew agents" header, NO tree — hint
264
+ // line, `main` row, then a 3-row scroll window over the flat agent list.
265
+ if (rowStyle === "compact") {
266
+ const lines = compactDockLines(runs, options, width, maxLines, notificationCount, runningGlyph);
267
+ return focused ? lines : lines.slice(0, maxLines);
268
+ }
269
+
64
270
  const lines: string[] = [widgetHeader(runs, runningGlyph, maxLines, notificationCount)];
65
271
 
66
- for (const { run, agents, snapshot } of runs) {
67
- const activeAgents = agents.filter((a) => a.status === "running" || a.status === "queued" || a.status === "waiting");
272
+ for (const entry of runs) {
273
+ const { run, agents } = entry;
68
274
  const now = Date.now();
69
- const finishedAgents = agents.filter((item) => {
70
- if (item.status === "running" || item.status === "queued" || item.status === "waiting") return false;
71
- if (!item.completedAt) return false;
72
- const maxAgeMs = (ERROR_STATUSES.has(item.status) ? ERROR_LINGER_MAX_AGE : FINISHED_LINGER_MAX_AGE) * 60_000;
73
- const age = now - new Date(item.completedAt).getTime();
74
- return Number.isFinite(age) && age < maxAgeMs;
75
- });
275
+ const { active: activeAgents, finished: finishedAgents } = orderWidgetAgents(entry, now);
76
276
  const completed = agents.filter((a) => a.status === "completed").length;
77
277
  // WP-3 (H4): while a run is parked awaiting plan approval, the spinner
78
278
  // glyph is replaced by a `⚠ plan:<last-8 runId>` badge. Plain-unicode ⚠
@@ -90,11 +290,6 @@ export function buildWidgetLines(
90
290
  // activity line) and is GUARANTEED stable across ticks:
91
291
  // - agents count — from `agents` array, always populated, never empty.
92
292
  // - run elapsed — from `run.createdAt`, always set on manifest.
93
- // Both come from sources with no race window — `agents` is read from
94
- // snapshot.agents OR agentsFor(run) (both always return same length
95
- // for a healthy run), and `run.createdAt` is immutable. The format
96
- // shape `"X/Y agents · Ns"` is therefore truly invariant: same number
97
- // of `·`-separated fields, same field meanings, every render tick.
98
293
  //
99
294
  // Bug 022 (timer-fix + label): for TERMINAL runs (failed/cancelled/
100
295
  // completed) the elapsed counter previously kept ticking up forever
@@ -112,56 +307,58 @@ export function buildWidgetLines(
112
307
 
113
308
  const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
114
309
 
115
- // L-4: prioritize RUNNING > QUEUED > WAITING within the visible window so the
116
- // most relevant live workers are always shown. Finished rows fill only the
117
- // leftover budget and never steal slots from running agents.
118
- const ACTIVE_PRIORITY: Record<string, number> = {
119
- running: 0,
120
- queued: 1,
121
- waiting: 2,
122
- };
123
- const prioritizedActive = [...activeAgents].sort((a, b) => (ACTIVE_PRIORITY[a.status] ?? 9) - (ACTIVE_PRIORITY[b.status] ?? 9));
310
+ // Focused: list every agent so the keyboard cursor can reach it. Idle: keep
311
+ // the historical cap so the prompt area stays small.
312
+ const activeCap = focused ? activeAgents.length : MAX_AGENTS_DISPLAY;
124
313
  // Finished rows only appear in slots not used by active agents (max 2). When
125
314
  // there are >= MAX_AGENTS_DISPLAY live workers, finished rows are suppressed
126
315
  // entirely so they cannot push a live agent's activity line off-screen.
127
- const finishedSlots = Math.max(0, Math.min(2, MAX_AGENTS_DISPLAY - activeAgents.length));
316
+ const finishedSlots = focused ? finishedAgents.length : Math.max(0, Math.min(2, MAX_AGENTS_DISPLAY - activeAgents.length));
317
+
318
+ /** Cursor marker column, mirroring the inline panel's selection. */
319
+ const markerFor = (taskId: string): string => (options.selectedTaskId === taskId ? "❯" : " ");
128
320
 
129
- const visibleAgents = prioritizedActive.slice(0, MAX_AGENTS_DISPLAY);
321
+ const visibleAgents = activeAgents.slice(0, activeCap);
130
322
  for (const [index, agent] of visibleAgents.entries()) {
131
- const last = index === visibleAgents.length - 1 && activeAgents.length <= MAX_AGENTS_DISPLAY && finishedSlots === 0;
323
+ const last = index === visibleAgents.length - 1 && activeAgents.length <= activeCap && finishedSlots === 0;
132
324
  const branch = last ? "└─" : "├─";
133
- const agentGlyph = iconForStatus(agent.status, { runningGlyph });
134
325
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
326
+ const legacyGlyph = options.viewedTaskId === agent.taskId ? "◉" : iconForStatus(agent.status, { runningGlyph });
135
327
  const stats = agentStats(agent, liveHandle);
136
328
  const name = liveHandle?.agent ?? agent.agent;
329
+ const activity = agentActivity(agent, liveHandle);
137
330
  const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
138
- const _activeMain = truncate(`│ ${branch} ${agentGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`, width);
331
+ const _activeMain = truncate(`│ ${branch} ${legacyGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`, width);
139
332
  lines.push(_activeMain);
140
- const _activity = truncate(`│ ⊶ ${agentActivity(agent, liveHandle)}${stats ? ` · ${stats}` : ""}`, width);
333
+ const _activity = truncate(`│ ⊶ ${activity}${stats ? ` · ${stats}` : ""}`, width);
141
334
  lines.push(_activity);
142
335
  }
143
336
 
144
- if (activeAgents.length > MAX_AGENTS_DISPLAY) {
145
- lines.push(truncate(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`, width));
337
+ if (activeAgents.length > activeCap) {
338
+ lines.push(truncate(`│ └─ … +${activeAgents.length - activeCap} more agents`, width));
146
339
  }
147
340
 
148
341
  for (const [index, agent] of finishedAgents.slice(0, finishedSlots).entries()) {
149
342
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
150
343
  const name = liveHandle?.agent ?? agent.agent;
151
- const icon =
344
+ const legacyIcon =
152
345
  agent.status === "completed" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "needs_attention" ? "⚠" : "▪";
153
346
  const stats = agentStats(agent, liveHandle);
154
347
  const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
155
348
  const isLastFinished = index === Math.min(finishedAgents.length, finishedSlots) - 1;
156
349
  const branch = isLastFinished ? "└─" : "├─";
157
- const _finished = truncate(`│ ${branch} ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
350
+ const _finished = truncate(`│ ${branch} ${legacyIcon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
158
351
  lines.push(_finished);
159
352
  }
160
353
 
161
- if (lines.length >= maxLines) break;
354
+ // Focused (inline panel cursor active): the keyboard can reach every
355
+ // agent, so the renderer must list them ALL — clipping here would put
356
+ // the cursor marker (❯) on rows that are never painted. The idle
357
+ // widget keeps its historical cap to hold the prompt area small.
358
+ if (lines.length >= maxLines && !focused) break;
162
359
  }
163
360
 
164
- return lines.slice(0, maxLines);
361
+ return focused ? lines : lines.slice(0, maxLines);
165
362
  }
166
363
 
167
364
  // ── Colorization ──────────────────────────────────────────────────────