@tt-a1i/openpi 0.3.1 → 0.4.0

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 (60) hide show
  1. package/README.md +87 -24
  2. package/SETUP.md +3 -3
  3. package/extensions/ask-user/index.ts +30 -14
  4. package/extensions/background-terminals/src/prompt.ts +1 -1
  5. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  6. package/extensions/capabilities/index.ts +30 -42
  7. package/extensions/capabilities/src/ui.ts +93 -0
  8. package/extensions/file-mutation-display/index.ts +34 -76
  9. package/extensions/file-mutation-display/render.ts +387 -88
  10. package/extensions/file-search/index.ts +8 -7
  11. package/extensions/file-search/src/binaries.ts +18 -18
  12. package/extensions/git-info/src/changed-files-view.ts +47 -14
  13. package/extensions/git-read/index.ts +330 -0
  14. package/extensions/git-read/src/args.ts +171 -0
  15. package/extensions/git-read/src/process.ts +81 -0
  16. package/extensions/git-read/src/prompt.ts +56 -0
  17. package/extensions/sessions/index.ts +70 -55
  18. package/extensions/setup/index.ts +6 -6
  19. package/extensions/shared/activity-status.ts +6 -5
  20. package/extensions/shared/below-editor-navigation.ts +26 -0
  21. package/extensions/shared/capability-intent.ts +53 -0
  22. package/extensions/shared/child-session.ts +7 -1
  23. package/extensions/shared/result-budget.ts +134 -0
  24. package/extensions/shared/screen-chrome.ts +133 -0
  25. package/extensions/shared/setup-config.ts +24 -5
  26. package/extensions/shared/spinner.ts +28 -0
  27. package/extensions/shared/text-projection.ts +56 -0
  28. package/extensions/shared/tool-surface.ts +13 -6
  29. package/extensions/subagents/index.ts +204 -140
  30. package/extensions/subagents/navigation.ts +52 -23
  31. package/extensions/subagents/src/agent-types.ts +37 -15
  32. package/extensions/subagents/src/backends/stub.ts +7 -0
  33. package/extensions/subagents/src/id-sequence.ts +84 -0
  34. package/extensions/subagents/src/manager.ts +620 -537
  35. package/extensions/subagents/src/prompt.ts +153 -38
  36. package/extensions/subagents/src/result-artifact.ts +142 -0
  37. package/extensions/subagents/src/runtime.ts +8 -5
  38. package/extensions/subagents/src/ui/takeover.ts +84 -109
  39. package/extensions/subagents/src/ui/transcript.ts +76 -42
  40. package/extensions/subagents/src/ui/wait-result.ts +1 -1
  41. package/extensions/tasks/ui.ts +79 -62
  42. package/extensions/ui-customization/footer.ts +7 -4
  43. package/extensions/user-input-fold/index.ts +185 -0
  44. package/extensions/workflows/artifacts.ts +35 -0
  45. package/extensions/workflows/controller.ts +14 -2
  46. package/extensions/workflows/coordinator.ts +64 -0
  47. package/extensions/workflows/dashboard.ts +353 -173
  48. package/extensions/workflows/handoff.ts +62 -20
  49. package/extensions/workflows/index.ts +647 -387
  50. package/extensions/workflows/model.ts +57 -15
  51. package/extensions/workflows/navigation.ts +33 -14
  52. package/extensions/workflows/prompt.ts +104 -8
  53. package/extensions/workflows/replay-safety.ts +16 -6
  54. package/extensions/workflows/result-delivery.ts +189 -0
  55. package/extensions/workflows/sandbox-child.cjs +11 -0
  56. package/package.json +1 -1
  57. package/skills/subagents/SKILL.md +2 -2
  58. package/skills/workflows/REFERENCE.md +7 -4
  59. package/skills/workflows/SKILL.md +53 -10
  60. package/extensions/subagents/src/format.ts +0 -48
@@ -5,8 +5,8 @@
5
5
  * name 5/5 agents · 31m18s · done
6
6
  * description
7
7
  * ╭ Phases ────────────╮ ╭ Gather · 3 agents ──────────────────────────────╮
8
- * │ ❯ Gather 3/3 │ │ CodeRabbit feedback gpt-5 · 7%/372k 5m37s│
9
- * │ Verify 1/1 │ │ Other bot feedback gpt-5 · 9%/372k 4m43s│
8
+ * │ ❯ Gather 3/3 │ │ CodeRabbit feedback gpt-5 · 7%/372k 5m37s│
9
+ * │ Verify 1/1 │ │ Other bot feedback gpt-5 · 9%/372k 4m43s│
10
10
  * ╰────────────────────╯ ╰─────────────────────────────────────────────────╯
11
11
  * up/down select · right enter · left back · s save report
12
12
  */
@@ -14,51 +14,59 @@
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import {
17
- getAgentDir,
18
17
  type ExtensionContext,
18
+ getAgentDir,
19
19
  type KeybindingsManager,
20
20
  } from "@earendil-works/pi-coding-agent";
21
21
  import {
22
22
  Key,
23
23
  matchesKey,
24
+ type TUI,
24
25
  truncateToWidth,
25
- visibleWidth,
26
26
  wrapTextWithAnsi,
27
- type TUI,
28
27
  } from "@earendil-works/pi-tui";
28
+ import { contextPercent } from "../shared/context-utilization.ts";
29
+ import { fitNavigationSides } from "../shared/below-editor-navigation.ts";
30
+ import {
31
+ panelFrame,
32
+ type ScreenHint,
33
+ screenTitleLine,
34
+ hintLine as sharedHintLine,
35
+ } from "../shared/screen-chrome.ts";
36
+ import { SPINNER_INTERVAL_MS, spinnerFrame } from "../shared/spinner.ts";
37
+ import { sanitizeTerminalText } from "../shared/terminal-text.ts";
29
38
  import { isAcceptanceLedger } from "./acceptance.ts";
39
+ import { projectWorkflowGraph } from "./graph-projection.ts";
40
+ import {
41
+ classifyInterruptedInvocation,
42
+ decodeInvocationRecord,
43
+ } from "./invocation-ledger.ts";
30
44
  import {
45
+ type AgentRecord,
46
+ type AgentUsage,
31
47
  agentContext,
48
+ aggregateUsage,
32
49
  countStates,
33
50
  formatElapsed,
34
51
  formatUsage,
35
- aggregateUsage,
36
52
  isWorkflowRunId,
37
53
  MAX_LOG_TEXT,
54
+ type PhaseGroup,
38
55
  phaseGroups,
39
- resultJson,
40
56
  resolveWorkflowRunTarget,
57
+ resultJson,
41
58
  sanitizeLine,
42
59
  shortenHome,
43
- stateSquare,
60
+ stateGlyph,
44
61
  statusColor,
62
+ statusGlyph,
45
63
  statusWord,
46
- SQUARE,
47
64
  type Theme,
48
- type AgentRecord,
49
- type AgentUsage,
50
- type PhaseGroup,
51
65
  type TranscriptEntry,
52
66
  type WorkflowDetails,
53
67
  type WorkflowLogEntry,
54
68
  workflowGraphRecords,
55
69
  } from "./model.ts";
56
- import { sanitizeTerminalText } from "../shared/terminal-text.ts";
57
- import { projectWorkflowGraph } from "./graph-projection.ts";
58
- import {
59
- classifyInterruptedInvocation,
60
- decodeInvocationRecord,
61
- } from "./invocation-ledger.ts";
62
70
  import { writeFileAtomic } from "./serialization.ts";
63
71
 
64
72
  const NOTICE_TTL_MS = 4000;
@@ -80,6 +88,80 @@ function runsDir(): string {
80
88
  return path.join(getAgentDir(), "workflows");
81
89
  }
82
90
 
91
+ /** Every persisted run id on disk; empty when no runs directory exists. */
92
+ export function listPersistedRunIds(): string[] {
93
+ try {
94
+ return fs.readdirSync(runsDir()).filter(isWorkflowRunId);
95
+ } catch {
96
+ // No runs yet.
97
+ }
98
+ return [];
99
+ }
100
+
101
+ /** Hydrate the result/transcript side artifacts referenced by workflow.json. */
102
+ function hydrateRunArtifacts(runId: string, details: WorkflowDetails) {
103
+ const runDir = path.join(runsDir(), runId);
104
+ if (details.resultArtifact) {
105
+ try {
106
+ details.result = JSON.parse(
107
+ fs.readFileSync(
108
+ path.join(runDir, path.basename(details.resultArtifact)),
109
+ "utf8",
110
+ ),
111
+ );
112
+ } catch {
113
+ // Keep the compact compatibility marker from workflow.json.
114
+ }
115
+ }
116
+ if (details.transcriptArtifact) {
117
+ try {
118
+ const transcripts = JSON.parse(
119
+ fs.readFileSync(
120
+ path.join(runDir, path.basename(details.transcriptArtifact)),
121
+ "utf8",
122
+ ),
123
+ ) as Record<string, unknown>;
124
+ for (const agent of details.agents) {
125
+ agent.transcript = normalizeTranscript(
126
+ transcripts[String(agent.index)],
127
+ );
128
+ }
129
+ } catch {
130
+ // Older or partially written artifacts simply lack transcripts.
131
+ }
132
+ }
133
+ }
134
+
135
+ export interface ReadPersistedRunOptions {
136
+ /** Hydrate result and transcript side artifacts referenced by workflow.json. */
137
+ hydrateArtifacts?: boolean;
138
+ }
139
+
140
+ /**
141
+ * The single read entry for a persisted workflow.json: parse, normalize
142
+ * (including runs written by older tooling), and optionally hydrate the side
143
+ * artifacts. Unreadable or invalid runs read as undefined. Stale "running"
144
+ * reconciliation stays with callers so selection filters can run on the
145
+ * recorded timestamps first (`recoverStaleWorkflowDetails`).
146
+ */
147
+ export function readPersistedWorkflowDetails(
148
+ runId: string,
149
+ options: ReadPersistedRunOptions = {},
150
+ ): WorkflowDetails | undefined {
151
+ let details: WorkflowDetails | undefined;
152
+ try {
153
+ const raw: unknown = JSON.parse(
154
+ fs.readFileSync(path.join(runsDir(), runId, "workflow.json"), "utf8"),
155
+ );
156
+ details = normalizePersistedWorkflowDetails(runId, raw);
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ if (!details) return undefined;
161
+ if (options.hydrateArtifacts) hydrateRunArtifacts(runId, details);
162
+ return details;
163
+ }
164
+
83
165
  function isWorktreeCleanup(
84
166
  value: unknown,
85
167
  ): value is NonNullable<AgentRecord["worktreeCleanup"]> {
@@ -117,6 +199,45 @@ function normalizeUsage(value: unknown): AgentUsage {
117
199
  };
118
200
  }
119
201
 
202
+ function normalizeDelivery(value: unknown): WorkflowDetails["delivery"] {
203
+ if (!value || typeof value !== "object") return undefined;
204
+ const record = value as Record<string, unknown>;
205
+ const state = record.state;
206
+ if (
207
+ state !== "none" &&
208
+ state !== "held-for-inline" &&
209
+ state !== "pending" &&
210
+ state !== "delivered" &&
211
+ state !== "consumed-inline"
212
+ ) {
213
+ return undefined;
214
+ }
215
+ if (typeof record.id !== "string" || record.id.length === 0) return undefined;
216
+ const attempts =
217
+ typeof record.attempts === "number" &&
218
+ Number.isSafeInteger(record.attempts) &&
219
+ record.attempts >= 0
220
+ ? record.attempts
221
+ : 0;
222
+ const updatedAt =
223
+ typeof record.updatedAt === "number" && Number.isFinite(record.updatedAt)
224
+ ? record.updatedAt
225
+ : 0;
226
+ return {
227
+ id: sanitizeLine(record.id, 256),
228
+ state,
229
+ attempts,
230
+ updatedAt,
231
+ ...(typeof record.deliveredAt === "number" &&
232
+ Number.isFinite(record.deliveredAt)
233
+ ? { deliveredAt: record.deliveredAt }
234
+ : {}),
235
+ ...(typeof record.lastError === "string"
236
+ ? { lastError: sanitizeLine(record.lastError, 2_000) }
237
+ : {}),
238
+ };
239
+ }
240
+
120
241
  function normalizeTranscript(value: unknown): TranscriptEntry[] {
121
242
  if (!Array.isArray(value)) return [];
122
243
  const transcript: TranscriptEntry[] = [];
@@ -166,9 +287,11 @@ export function normalizePersistedWorkflowDetails(
166
287
  const state =
167
288
  a.state === "error" || a.state === "failed"
168
289
  ? "error"
169
- : a.state === "running"
170
- ? "running"
171
- : "done";
290
+ : a.state === "uncertain"
291
+ ? "uncertain"
292
+ : a.state === "running"
293
+ ? "running"
294
+ : "done";
172
295
  const index = typeof a.index === "number" ? a.index : agents.length + 1;
173
296
  const decodedInvocation = decodeInvocationRecord(a.invocation);
174
297
  const invocation =
@@ -205,6 +328,11 @@ export function normalizePersistedWorkflowDetails(
205
328
  ...(typeof a.resultRef === "string" && a.resultRef
206
329
  ? { resultRef: sanitizeLine(a.resultRef, 256) }
207
330
  : {}),
331
+ ...(typeof a.resultArtifact === "string" &&
332
+ a.resultArtifact.startsWith("agent-results/") &&
333
+ !a.resultArtifact.includes("..")
334
+ ? { resultArtifact: sanitizeLine(a.resultArtifact, 256) }
335
+ : {}),
208
336
  label:
209
337
  typeof a.label === "string"
210
338
  ? sanitizeLine(a.label, 160) || `agent-${index}`
@@ -285,7 +413,8 @@ export function normalizePersistedWorkflowDetails(
285
413
  const status =
286
414
  record.status === "running" ||
287
415
  record.status === "failed" ||
288
- record.status === "aborted"
416
+ record.status === "aborted" ||
417
+ record.status === "uncertain"
289
418
  ? record.status
290
419
  : "completed";
291
420
 
@@ -306,6 +435,7 @@ export function normalizePersistedWorkflowDetails(
306
435
  ? sanitizeLine(meta.description, 2_000) || undefined
307
436
  : undefined,
308
437
  background: record.background === true,
438
+ delivery: normalizeDelivery(record.delivery),
309
439
  status,
310
440
  startedAt,
311
441
  finishedAt:
@@ -351,13 +481,36 @@ export function recoverStaleWorkflowDetails(
351
481
  recoveredAt = Date.now(),
352
482
  ): WorkflowDetails {
353
483
  if (details.status !== "running") return details;
354
- details.status = "aborted";
484
+ details.status = "uncertain";
355
485
  details.finishedAt = details.finishedAt ?? recoveredAt;
356
- details.error = details.error ?? "Recovered stale run that was not active";
486
+ details.error =
487
+ details.error ??
488
+ "Workflow owner was lost; completion and external side effects are uncertain";
489
+ if (!details.delivery) {
490
+ details.delivery = {
491
+ id: `workflow:${details.runId}`,
492
+ state: "pending",
493
+ attempts: 0,
494
+ updatedAt: recoveredAt,
495
+ lastError: "Migrated a pre-delivery run after its owner disappeared",
496
+ };
497
+ } else if (
498
+ details.delivery.state !== "delivered" &&
499
+ details.delivery.state !== "consumed-inline"
500
+ ) {
501
+ details.delivery = {
502
+ ...details.delivery,
503
+ state: "pending",
504
+ updatedAt: recoveredAt,
505
+ lastError: "Recovered after the workflow owner process ended",
506
+ };
507
+ }
357
508
  for (const agent of details.agents) {
358
509
  if (agent.state !== "running") continue;
359
- agent.state = "error";
360
- agent.error = agent.error ?? "Run ended before this agent settled";
510
+ agent.state = "uncertain";
511
+ agent.error =
512
+ agent.error ??
513
+ "Workflow owner was lost before this agent produced terminal evidence";
361
514
  agent.finishedAt = details.finishedAt;
362
515
  }
363
516
  details.graph = projectWorkflowGraph(workflowGraphRecords(details.agents));
@@ -389,69 +542,26 @@ export function loadRunEntries(
389
542
  /** Hide runs untouched by the current request; live runs always show. */
390
543
  startedSince = 0,
391
544
  ): RunEntry[] {
392
- let names: string[] = [];
393
- try {
394
- names = fs.readdirSync(runsDir()).filter(isWorkflowRunId);
395
- } catch {
396
- // No runs yet.
397
- }
398
545
  const entries: RunEntry[] = [];
399
- for (const runId of names) {
546
+ for (const runId of listPersistedRunIds()) {
400
547
  const live = active.get(runId);
401
548
  if (live) {
402
549
  entries.push({ runId, details: live, live: true });
403
550
  continue;
404
551
  }
405
- try {
406
- const raw = JSON.parse(
407
- fs.readFileSync(path.join(runsDir(), runId, "workflow.json"), "utf8"),
408
- );
409
- const details = normalizePersistedWorkflowDetails(runId, raw);
410
- const touchedAt = Math.max(
411
- details?.startedAt ?? 0,
412
- details?.finishedAt ?? 0,
413
- );
414
- if (
415
- details &&
416
- touchedAt >= startedSince &&
417
- (details.sessionId === sessionId || referencedRunIds.has(runId))
418
- ) {
419
- const runDir = path.join(runsDir(), runId);
420
- if (details.resultArtifact) {
421
- try {
422
- details.result = JSON.parse(
423
- fs.readFileSync(
424
- path.join(runDir, path.basename(details.resultArtifact)),
425
- "utf8",
426
- ),
427
- );
428
- } catch {
429
- // Keep the compact compatibility marker from workflow.json.
430
- }
431
- }
432
- if (details.transcriptArtifact) {
433
- try {
434
- const transcripts = JSON.parse(
435
- fs.readFileSync(
436
- path.join(runDir, path.basename(details.transcriptArtifact)),
437
- "utf8",
438
- ),
439
- ) as Record<string, unknown>;
440
- for (const agent of details.agents) {
441
- agent.transcript = normalizeTranscript(
442
- transcripts[String(agent.index)],
443
- );
444
- }
445
- } catch {
446
- // Older or partially written artifacts simply lack transcripts.
447
- }
448
- }
449
- recoverStaleWorkflowDetails(details);
450
- entries.push({ runId, details, live: false });
451
- }
452
- } catch {
453
- // Skip unreadable runs.
552
+ const details = readPersistedWorkflowDetails(runId, {
553
+ hydrateArtifacts: true,
554
+ });
555
+ if (!details) continue;
556
+ const touchedAt = Math.max(details.startedAt, details.finishedAt ?? 0);
557
+ if (
558
+ touchedAt < startedSince ||
559
+ (details.sessionId !== sessionId && !referencedRunIds.has(runId))
560
+ ) {
561
+ continue;
454
562
  }
563
+ recoverStaleWorkflowDetails(details);
564
+ entries.push({ runId, details, live: false });
455
565
  }
456
566
  return entries.sort((a, b) => b.details.startedAt - a.details.startedAt);
457
567
  }
@@ -472,13 +582,13 @@ export function workflowGraphSummary(
472
582
  }
473
583
 
474
584
  export function buildWorkflowReport(details: WorkflowDetails): string {
475
- const { done, failed } = countStates(details);
585
+ const { done, failed, uncertain } = countStates(details);
476
586
  const lines: string[] = [
477
587
  `# Workflow ${details.name ?? details.runId}`,
478
588
  "",
479
589
  `- Run: ${details.runId}`,
480
590
  `- Status: ${statusWord(details.status)}`,
481
- `- Agents: ${done}/${details.agents.length} ok${failed ? `, ${failed} failed` : ""}`,
591
+ `- Agents: ${done}/${details.agents.length} ok${failed ? `, ${failed} failed` : ""}${uncertain ? `, ${uncertain} uncertain` : ""}`,
482
592
  `- Elapsed: ${formatElapsed(details.startedAt, details.finishedAt)}`,
483
593
  ];
484
594
  const totals = formatUsage(aggregateUsage(details.agents));
@@ -498,7 +608,9 @@ export function buildWorkflowReport(details: WorkflowDetails): string {
498
608
  ? "ok"
499
609
  : agent.state === "error"
500
610
  ? "FAILED"
501
- : "running";
611
+ : agent.state === "uncertain"
612
+ ? "UNCERTAIN"
613
+ : "running";
502
614
  const stats = [
503
615
  agent.model,
504
616
  agentContext(agent),
@@ -633,7 +745,7 @@ export class WorkflowDashboard {
633
745
  this.refresh();
634
746
  this.tui.requestRender();
635
747
  }
636
- }, 500);
748
+ }, SPINNER_INTERVAL_MS);
637
749
  }
638
750
 
639
751
  dispose() {
@@ -869,17 +981,6 @@ export class WorkflowDashboard {
869
981
  return lines.map((line) => truncateToWidth(line, width, ""));
870
982
  }
871
983
 
872
- /** Compose `left ... right` within `width`, truncating left when needed. */
873
- private split(left: string, right: string, width: number): string {
874
- const rightWidth = visibleWidth(right);
875
- let text = left;
876
- if (visibleWidth(text) + rightWidth + 1 > width) {
877
- text = truncateToWidth(text, Math.max(0, width - rightWidth - 2), "…");
878
- }
879
- const pad = Math.max(1, width - visibleWidth(text) - rightWidth);
880
- return text + " ".repeat(pad) + right;
881
- }
882
-
883
984
  /** Bordered panel with a title in the top border, padded to exact height. */
884
985
  private panel(
885
986
  title: string,
@@ -887,23 +988,7 @@ export class WorkflowDashboard {
887
988
  width: number,
888
989
  height: number,
889
990
  ): string[] {
890
- const theme = this.theme;
891
- const inner = Math.max(0, width - 2);
892
- const border = (s: string) => theme.fg("borderMuted", s);
893
- const titleText = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
894
- const dashes = Math.max(0, inner - visibleWidth(titleText) - 1);
895
- const lines: string[] = [
896
- border("╭─") + titleText + border("─".repeat(dashes) + "╮"),
897
- ];
898
- const bodyHeight = Math.max(0, height - 2);
899
- for (let i = 0; i < bodyHeight; i++) {
900
- const row = rows[i] ?? "";
901
- const clipped = truncateToWidth(row, inner, "…");
902
- const pad = Math.max(0, inner - visibleWidth(clipped));
903
- lines.push(border("│") + clipped + " ".repeat(pad) + border("│"));
904
- }
905
- lines.push(border("╰" + "─".repeat(inner) + "╯"));
906
- return lines;
991
+ return panelFrame(this.theme, { label: title, rows, width, height });
907
992
  }
908
993
 
909
994
  /** Scroll window keeping `selected` visible. */
@@ -924,25 +1009,21 @@ export class WorkflowDashboard {
924
1009
  return this.keybindings.getKeys(binding).join("/") || "unbound";
925
1010
  }
926
1011
 
927
- private hintLine(hint: string, width: number): string {
928
- const theme = this.theme;
929
- if (this.notice)
930
- return truncateToWidth(theme.fg("accent", ` ${this.notice}`), width);
931
- return truncateToWidth(theme.fg("dim", ` ${hint}`), width);
1012
+ private hintLine(hints: readonly ScreenHint[], width: number): string {
1013
+ return sharedHintLine(this.theme, hints, width, this.notice);
932
1014
  }
933
1015
 
934
1016
  private renderList(width: number, height: number): string[] {
935
1017
  const theme = this.theme;
936
1018
  const lines: string[] = [];
937
- const header = this.split(
938
- " " + theme.bold(theme.fg("accent", "Workflows")),
939
- theme.fg(
940
- "dim",
941
- `${this.entries.length} run${this.entries.length === 1 ? "" : "s"} `,
1019
+ lines.push(
1020
+ screenTitleLine(
1021
+ theme,
1022
+ "Workflows",
1023
+ `${this.entries.length} run${this.entries.length === 1 ? "" : "s"}`,
1024
+ width,
942
1025
  ),
943
- width,
944
1026
  );
945
- lines.push(header);
946
1027
 
947
1028
  const panelHeight = height - 2;
948
1029
  const bodyHeight = Math.max(0, panelHeight - 2);
@@ -957,7 +1038,7 @@ export class WorkflowDashboard {
957
1038
  ),
958
1039
  );
959
1040
  lines.push(
960
- this.hintLine(`${this.keys("tui.select.cancel")} close`, width),
1041
+ this.hintLine([[this.keys("tui.select.cancel"), "close"]], width),
961
1042
  );
962
1043
  return lines;
963
1044
  }
@@ -976,22 +1057,30 @@ export class WorkflowDashboard {
976
1057
  const label = selected
977
1058
  ? theme.fg("accent", name)
978
1059
  : theme.fg("text", name);
979
- const { done, failed } = countStates(d);
1060
+ const { done, failed, uncertain } = countStates(d);
980
1061
  const settled = done + failed;
981
1062
  const right =
982
1063
  theme.fg(
983
1064
  "dim",
984
- `${settled}/${d.agents.length} agents · ${formatElapsed(d.startedAt, d.finishedAt)} · `,
1065
+ `${settled}/${d.agents.length} agents${uncertain ? ` · ${uncertain} uncertain` : ""} · ${formatElapsed(d.startedAt, d.finishedAt)} · `,
985
1066
  ) +
986
1067
  theme.fg(statusColor(d.status), statusWord(d.status)) +
987
1068
  " ";
988
- const left = ` ${marker} ${statusSquareFor(d, theme)} ${label} ${theme.fg("dim", d.runId)}`;
989
- return this.split(left, right, width - 2);
1069
+ const left = ` ${marker} ${statusGlyph(d.status, theme, Date.now())} ${label} ${theme.fg("dim", d.runId)}`;
1070
+ return fitNavigationSides(left, right, width - 2);
990
1071
  });
991
1072
  lines.push(...this.panel("Runs", rows, width, panelHeight));
992
1073
  lines.push(
993
1074
  this.hintLine(
994
- `${this.keys("tui.select.up")}/${this.keys("tui.select.down")} select · ${this.keys("tui.select.confirm")} open · x stop · ${this.keys("tui.select.cancel")} close`,
1075
+ [
1076
+ [
1077
+ `${this.keys("tui.select.up")}/${this.keys("tui.select.down")}`,
1078
+ "select",
1079
+ ],
1080
+ [this.keys("tui.select.confirm"), "open"],
1081
+ ["x", "stop"],
1082
+ [this.keys("tui.select.cancel"), "close"],
1083
+ ],
995
1084
  width,
996
1085
  ),
997
1086
  );
@@ -1006,28 +1095,37 @@ export class WorkflowDashboard {
1006
1095
  const theme = this.theme;
1007
1096
  const lines: string[] = [];
1008
1097
 
1009
- const { done, failed } = countStates(d);
1098
+ const { done, failed, uncertain } = countStates(d);
1010
1099
  const settled = done + failed;
1100
+ // Same language as the transcript card: the glyph carries the state, the
1101
+ // status word only shows for terminal states.
1011
1102
  const right =
1012
1103
  theme.fg(
1013
1104
  "dim",
1014
- `${settled}/${d.agents.length} agents · ${formatElapsed(d.startedAt, d.finishedAt)} · `,
1105
+ `${settled}/${d.agents.length} agents${uncertain ? ` · ${uncertain} uncertain` : ""} · ${formatElapsed(d.startedAt, d.finishedAt)}`,
1015
1106
  ) +
1016
- theme.fg(statusColor(d.status), statusWord(d.status)) +
1017
- " ";
1107
+ (d.status === "running"
1108
+ ? " "
1109
+ : theme.fg("dim", " · ") +
1110
+ theme.fg(statusColor(d.status), statusWord(d.status)) +
1111
+ " ");
1018
1112
  lines.push(
1019
- this.split(
1020
- " " + theme.bold(theme.fg("accent", d.name ?? d.runId)),
1113
+ fitNavigationSides(
1114
+ ` ${statusGlyph(d.status, theme, Date.now())} ${theme.bold(theme.fg("accent", d.name ?? d.runId))}`,
1021
1115
  right,
1022
1116
  width,
1023
1117
  ),
1024
1118
  );
1025
1119
  const totals = formatUsage(aggregateUsage(d.agents));
1026
- const graphSummary = d.graph ? workflowGraphSummary(d.graph) : undefined;
1120
+ // A graph with no edges is a flat swarm — "N nodes · 0 edges" is noise.
1121
+ const graphSummary =
1122
+ d.graph && d.graph.edges.length > 0
1123
+ ? workflowGraphSummary(d.graph)
1124
+ : undefined;
1027
1125
  const subRight = [graphSummary, totals].filter(Boolean).join(" · ");
1028
1126
  const subLeft = " " + theme.fg("muted", d.description ?? d.runId);
1029
1127
  lines.push(
1030
- this.split(
1128
+ fitNavigationSides(
1031
1129
  subLeft,
1032
1130
  subRight ? theme.fg("dim", `${subRight} `) : " ",
1033
1131
  width,
@@ -1064,7 +1162,7 @@ export class WorkflowDashboard {
1064
1162
  const groupDone = group.agents.filter(
1065
1163
  (a) => a.state !== "running",
1066
1164
  ).length;
1067
- const square = groupSquare(group, theme);
1165
+ const square = groupGlyph(group, theme);
1068
1166
  const title =
1069
1167
  selected && this.detailFocus === "phases"
1070
1168
  ? theme.fg("accent", group.title)
@@ -1073,7 +1171,11 @@ export class WorkflowDashboard {
1073
1171
  group.agents.length > 0
1074
1172
  ? theme.fg("dim", `${groupDone}/${group.agents.length} `)
1075
1173
  : theme.fg("dim", "- ");
1076
- return this.split(` ${marker} ${square} ${title}`, counts, sidebarInner);
1174
+ return fitNavigationSides(
1175
+ ` ${marker} ${square} ${title}`,
1176
+ counts,
1177
+ sidebarInner,
1178
+ );
1077
1179
  });
1078
1180
 
1079
1181
  // Right: agents in the selected phase.
@@ -1085,6 +1187,10 @@ export class WorkflowDashboard {
1085
1187
  0,
1086
1188
  ...selectedGroup.agents.map((a) => a.label.length),
1087
1189
  );
1190
+ const models = new Set(
1191
+ selectedGroup.agents.map((a) => a.model).filter(Boolean),
1192
+ );
1193
+ const mixedModels = models.size > 1;
1088
1194
  const agentWindow = this.windowed(
1089
1195
  selectedGroup.agents,
1090
1196
  this.agentIndex,
@@ -1097,9 +1203,18 @@ export class WorkflowDashboard {
1097
1203
  selected && this.detailFocus === "agents"
1098
1204
  ? theme.fg("accent", "❯")
1099
1205
  : " ";
1206
+ // The model repeats on every row when the run is homogeneous; only
1207
+ // mixed fleets earn a per-row model. Context occupancy matters while
1208
+ // an agent runs; once settled, its cost is the elapsed on the right.
1209
+ const percent = contextPercent({
1210
+ tokens: agent.usage.contextTokens,
1211
+ contextWindow: agent.contextWindow,
1212
+ });
1100
1213
  const stats = [
1101
- agent.model,
1102
- agentContext(agent),
1214
+ mixedModels ? agent.model : undefined,
1215
+ agent.state === "running" && percent !== undefined
1216
+ ? `${percent}%`
1217
+ : undefined,
1103
1218
  agent.acceptance
1104
1219
  ? `acceptance:${agent.acceptance.status}`
1105
1220
  : undefined,
@@ -1110,16 +1225,16 @@ export class WorkflowDashboard {
1110
1225
  selected && this.detailFocus === "agents"
1111
1226
  ? theme.fg("accent", agent.label.padEnd(Math.min(maxLabel, 40)))
1112
1227
  : theme.fg("text", agent.label.padEnd(Math.min(maxLabel, 40)));
1113
- const left = ` ${marker} ${stateSquare(agent.state, theme)} ${label} ${theme.fg("dim", stats)}`;
1228
+ const left = ` ${marker} ${stateGlyph(agent.state, theme, Date.now())} ${label}${stats ? ` ${theme.fg("dim", stats)}` : ""}`;
1114
1229
  const right = theme.fg(
1115
1230
  "dim",
1116
1231
  `${formatElapsed(agent.startedAt, agent.finishedAt)} `,
1117
1232
  );
1118
- agentRows.push(this.split(left, right, agentsInner));
1233
+ agentRows.push(fitNavigationSides(left, right, agentsInner));
1119
1234
  if (agent.error) {
1120
1235
  agentRows.push(
1121
1236
  truncateToWidth(
1122
- ` ${theme.fg("error", sanitizeLine(agent.error, 2_000))}`,
1237
+ ` ${theme.fg("error", displayError(agent.error))}`,
1123
1238
  agentsInner,
1124
1239
  "…",
1125
1240
  ),
@@ -1170,11 +1285,32 @@ export class WorkflowDashboard {
1170
1285
  );
1171
1286
  }
1172
1287
 
1173
- const hint =
1288
+ const hints: ScreenHint[] =
1174
1289
  this.detailFocus === "phases"
1175
- ? `j/k select phase · l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")} agents · ${this.keys("tui.select.cancel")} back · x stop · s save report`
1176
- : `j/k select agent · h/${this.keys("tui.editor.cursorLeft")}/${this.keys("tui.select.cancel")} phases · l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")} details · x stop · s save report`;
1177
- lines.push(this.hintLine(hint, width));
1290
+ ? [
1291
+ ["j/k", "select phase"],
1292
+ [
1293
+ `l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")}`,
1294
+ "agents",
1295
+ ],
1296
+ [this.keys("tui.select.cancel"), "back"],
1297
+ ["x", "stop"],
1298
+ ["s", "save report"],
1299
+ ]
1300
+ : [
1301
+ ["j/k", "select agent"],
1302
+ [
1303
+ `h/${this.keys("tui.editor.cursorLeft")}/${this.keys("tui.select.cancel")}`,
1304
+ "phases",
1305
+ ],
1306
+ [
1307
+ `l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")}`,
1308
+ "details",
1309
+ ],
1310
+ ["x", "stop"],
1311
+ ["s", "save report"],
1312
+ ];
1313
+ lines.push(this.hintLine(hints, width));
1178
1314
  return lines;
1179
1315
  }
1180
1316
 
@@ -1191,14 +1327,30 @@ export class WorkflowDashboard {
1191
1327
  }
1192
1328
 
1193
1329
  for (const entry of agent.transcript) {
1330
+ // Tool calls are one-liners: the arrow shows direction, the accent name
1331
+ // says what ran, and the arguments collapse to a single compact line
1332
+ // instead of a vertical JSON block.
1333
+ if (entry.role === "tool") {
1334
+ const name = entry.name ? sanitizeLine(entry.name, 160) : "unknown";
1335
+ const args = compactInlineJson(entry.text);
1336
+ rows.push(
1337
+ ` ${theme.fg("muted", "→")} ${theme.fg("accent", name)}${args ? theme.fg("dim", ` ${args}`) : ""}`,
1338
+ );
1339
+ continue;
1340
+ }
1194
1341
  const label = transcriptLabel(entry);
1195
1342
  const color = transcriptColor(entry);
1343
+ const marker = entry.role === "toolResult" ? "←" : "●";
1196
1344
  rows.push(
1197
- ` ${theme.fg(color, SQUARE)} ${theme.bold(theme.fg(color, label))}`,
1345
+ ` ${theme.fg(color, marker)} ${theme.bold(theme.fg(color, label))}`,
1198
1346
  );
1199
1347
  const contentWidth = Math.max(8, width - 4);
1200
1348
  const styled = theme.fg(
1201
- entry.role === "thinking" ? "dim" : entry.isError ? "error" : "text",
1349
+ entry.role === "thinking" || entry.role === "toolResult"
1350
+ ? "dim"
1351
+ : entry.isError
1352
+ ? "error"
1353
+ : "text",
1202
1354
  sanitizeTerminalText(entry.text),
1203
1355
  );
1204
1356
  for (const line of wrapTextWithAnsi(styled, contentWidth)) {
@@ -1229,14 +1381,14 @@ export class WorkflowDashboard {
1229
1381
  .join(" · ") + " ",
1230
1382
  );
1231
1383
  lines.push(
1232
- this.split(
1233
- ` ${stateSquare(agent.state, theme)} ${theme.bold(theme.fg("accent", agent.label))}`,
1384
+ fitNavigationSides(
1385
+ ` ${stateGlyph(agent.state, theme, Date.now())} ${theme.bold(theme.fg("accent", agent.label))}`,
1234
1386
  right,
1235
1387
  width,
1236
1388
  ),
1237
1389
  );
1238
1390
  lines.push(
1239
- this.split(
1391
+ fitNavigationSides(
1240
1392
  ` ${theme.fg("muted", `${details.name ?? details.runId} · ${agent.phase ?? "unphased"}`)}`,
1241
1393
  theme.fg("dim", `${agent.transcript.length} entries `),
1242
1394
  width,
@@ -1261,7 +1413,12 @@ export class WorkflowDashboard {
1261
1413
  lines.push(...this.panel(position, visible, width, panelHeight));
1262
1414
  lines.push(
1263
1415
  this.hintLine(
1264
- "j/k scroll · ctrl-u/d page · g/G top/bottom · h/left/esc back",
1416
+ [
1417
+ ["j/k", "scroll"],
1418
+ ["ctrl-u/d", "page"],
1419
+ ["g/G", "top/bottom"],
1420
+ ["h/left/esc", "back"],
1421
+ ],
1265
1422
  width,
1266
1423
  ),
1267
1424
  );
@@ -1269,13 +1426,38 @@ export class WorkflowDashboard {
1269
1426
  }
1270
1427
  }
1271
1428
 
1429
+ /**
1430
+ * Agent errors often arrive as an HTTP status plus a JSON body
1431
+ * (`429: {"message":"user rate limit exceeded …"}`); the panel row keeps the
1432
+ * status code and the message, dropping the braces and quotes.
1433
+ */
1434
+ function displayError(error: string) {
1435
+ const clean = sanitizeLine(error, 2_000);
1436
+ const match = clean.match(/^(\d{3})[:\s]*\{\s*"message"\s*:\s*"([^"]+)"/);
1437
+ if (match) return `${match[1]}: ${match[2]}`;
1438
+ return clean;
1439
+ }
1440
+
1272
1441
  function transcriptLabel(entry: TranscriptEntry): string {
1273
- if (entry.role === "user") return "USER";
1274
- if (entry.role === "assistant") return "ASSISTANT";
1275
- if (entry.role === "thinking") return "THINKING";
1442
+ if (entry.role === "user") return "user";
1443
+ if (entry.role === "assistant") return "assistant";
1444
+ if (entry.role === "thinking") return "thinking";
1276
1445
  const name = entry.name ? sanitizeLine(entry.name, 160) : "unknown";
1277
- if (entry.role === "tool") return `TOOL ${name}`;
1278
- return `RESULT ${name}`;
1446
+ return name;
1447
+ }
1448
+
1449
+ /**
1450
+ * Tool arguments arrive pretty-printed over many lines; the transcript shows
1451
+ * them inline. Non-JSON text passes through flattened.
1452
+ */
1453
+ function compactInlineJson(text: string) {
1454
+ const flat = sanitizeTerminalText(text).trim();
1455
+ if (!flat) return "";
1456
+ try {
1457
+ return JSON.stringify(JSON.parse(flat));
1458
+ } catch {
1459
+ return flat.replace(/\s+/g, " ");
1460
+ }
1279
1461
  }
1280
1462
 
1281
1463
  function transcriptColor(
@@ -1289,17 +1471,15 @@ function transcriptColor(
1289
1471
  return "muted";
1290
1472
  }
1291
1473
 
1292
- function statusSquareFor(details: WorkflowDetails, theme: Theme): string {
1293
- return theme.fg(statusColor(details.status), SQUARE);
1294
- }
1295
-
1296
- function groupSquare(group: PhaseGroup, theme: Theme): string {
1297
- if (group.agents.length === 0) return theme.fg("dim", SQUARE);
1474
+ function groupGlyph(group: PhaseGroup, theme: Theme) {
1475
+ if (group.agents.length === 0) return theme.fg("dim", "○");
1298
1476
  if (group.agents.some((a) => a.state === "running"))
1299
- return theme.fg("warning", SQUARE);
1477
+ return theme.fg("warning", spinnerFrame(Date.now()));
1478
+ if (group.agents.some((a) => a.state === "uncertain"))
1479
+ return theme.fg("warning", "?");
1300
1480
  if (group.agents.some((a) => a.state === "error"))
1301
- return theme.fg("error", SQUARE);
1302
- return theme.fg("success", SQUARE);
1481
+ return theme.fg("error", "✗");
1482
+ return theme.fg("success", "✓");
1303
1483
  }
1304
1484
 
1305
1485
  /** Open the dashboard as a full-screen overlay. */
@@ -1309,10 +1489,10 @@ export async function showWorkflowDashboard(
1309
1489
  initialRunId?: string,
1310
1490
  startedSince = 0,
1311
1491
  onAbort?: (runId: string) => boolean,
1312
- ): Promise<void> {
1492
+ ) {
1313
1493
  await ctx.ui.custom<void>(
1314
1494
  (tui, theme, keybindings, done) => {
1315
- const dashboard: WorkflowDashboard = new WorkflowDashboard(
1495
+ const dashboard = new WorkflowDashboard(
1316
1496
  tui,
1317
1497
  theme,
1318
1498
  keybindings,