@tt-a1i/openpi 0.3.1 → 0.5.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 (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -38,6 +38,148 @@ export interface WorkflowJournal {
38
38
  readonly entries: readonly JournalEntry[];
39
39
  }
40
40
 
41
+ interface EncodedJournalEntry {
42
+ readonly entry: JournalEntry;
43
+ /** The entry formatted at the indentation used inside the journal array. */
44
+ readonly json: string;
45
+ readonly bytes: number;
46
+ }
47
+
48
+ const JOURNAL_PREFIX = `{
49
+ "version": ${JOURNAL_VERSION},
50
+ "entries": [
51
+ `;
52
+ const JOURNAL_SUFFIX = "\n ]\n}";
53
+ const JOURNAL_SEPARATOR = ",\n";
54
+ const JOURNAL_EMPTY = JSON.stringify(
55
+ { version: JOURNAL_VERSION, entries: [] },
56
+ null,
57
+ 2,
58
+ );
59
+ const JOURNAL_PREFIX_BYTES = Buffer.byteLength(JOURNAL_PREFIX, "utf8");
60
+ const JOURNAL_SUFFIX_BYTES = Buffer.byteLength(JOURNAL_SUFFIX, "utf8");
61
+ const JOURNAL_SEPARATOR_BYTES = Buffer.byteLength(JOURNAL_SEPARATOR, "utf8");
62
+ const JOURNAL_EMPTY_BYTES = Buffer.byteLength(JOURNAL_EMPTY, "utf8");
63
+
64
+ function encodeJournalEntry(entry: JournalEntry): EncodedJournalEntry {
65
+ // JSON.stringify on an array serializes an undefined element as null. The
66
+ // public type only permits objects, but retaining that fallback keeps this
67
+ // helper's behavior total if a malformed caller reaches it at runtime.
68
+ const json = JSON.stringify(entry, null, 2) ?? "null";
69
+ const indented = ` ${json.replaceAll("\n", "\n ")}`;
70
+ return {
71
+ entry,
72
+ json: indented,
73
+ bytes: Buffer.byteLength(indented, "utf8"),
74
+ };
75
+ }
76
+
77
+ function journalBytes(entryCount: number, entryBytes: number) {
78
+ if (entryCount === 0) return JOURNAL_EMPTY_BYTES;
79
+ return (
80
+ JOURNAL_PREFIX_BYTES +
81
+ entryBytes +
82
+ JOURNAL_SUFFIX_BYTES +
83
+ (entryCount - 1) * JOURNAL_SEPARATOR_BYTES
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Incrementally bounded journal storage for a running Workflow.
89
+ *
90
+ * Each entry is encoded once when appended. The active queue uses a head
91
+ * cursor, so dropping old entries only subtracts their already-known byte
92
+ * contribution. `toJson()` assembles the complete canonical artifact without
93
+ * re-stringifying each entry or repeatedly measuring the whole suffix.
94
+ */
95
+ export interface WorkflowJournalAccumulator {
96
+ readonly bytes: number;
97
+ readonly dropped: number;
98
+ readonly length: number;
99
+ append(entry: JournalEntry): void;
100
+ toJournal(): WorkflowJournal;
101
+ toJson(): string;
102
+ }
103
+
104
+ export function createJournalAccumulator(
105
+ maxBytes = JOURNAL_MAX_BYTES,
106
+ ): WorkflowJournalAccumulator {
107
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < JOURNAL_EMPTY_BYTES) {
108
+ throw new RangeError(
109
+ `Journal byte budget must be at least ${JOURNAL_EMPTY_BYTES} bytes`,
110
+ );
111
+ }
112
+
113
+ let queue: Array<EncodedJournalEntry | undefined> = [];
114
+ let head = 0;
115
+ let entryBytes = 0;
116
+ let dropped = 0;
117
+
118
+ const activeLength = () => queue.length - head;
119
+ const activeBytes = () => journalBytes(activeLength(), entryBytes);
120
+
121
+ const compact = () => {
122
+ // Release the encoded payload immediately when it is evicted. Compact the
123
+ // sparse prefix only occasionally so eviction remains amortized O(1).
124
+ if (head === queue.length) {
125
+ queue = [];
126
+ head = 0;
127
+ return;
128
+ }
129
+ if (head >= 64 && head * 2 >= queue.length) {
130
+ queue = queue.slice(head);
131
+ head = 0;
132
+ }
133
+ };
134
+
135
+ return {
136
+ get bytes() {
137
+ return activeBytes();
138
+ },
139
+ get dropped() {
140
+ return dropped;
141
+ },
142
+ get length() {
143
+ return activeLength();
144
+ },
145
+ append(entry) {
146
+ const encoded = encodeJournalEntry(entry);
147
+ queue.push(encoded);
148
+ entryBytes += encoded.bytes;
149
+
150
+ while (activeLength() > 0 && activeBytes() > maxBytes) {
151
+ const oldest = queue[head];
152
+ if (!oldest) break;
153
+ queue[head] = undefined;
154
+ head++;
155
+ entryBytes -= oldest.bytes;
156
+ dropped++;
157
+ }
158
+ compact();
159
+ },
160
+ toJournal() {
161
+ const entries: JournalEntry[] = [];
162
+ for (let index = head; index < queue.length; index++) {
163
+ const encoded = queue[index];
164
+ if (encoded) entries.push(encoded.entry);
165
+ }
166
+ return {
167
+ version: JOURNAL_VERSION,
168
+ entries,
169
+ };
170
+ },
171
+ toJson() {
172
+ if (activeLength() === 0) return JOURNAL_EMPTY;
173
+ const entries: string[] = [];
174
+ for (let index = head; index < queue.length; index++) {
175
+ const encoded = queue[index];
176
+ if (encoded) entries.push(encoded.json);
177
+ }
178
+ return JOURNAL_PREFIX + entries.join(JOURNAL_SEPARATOR) + JOURNAL_SUFFIX;
179
+ },
180
+ };
181
+ }
182
+
41
183
  /**
42
184
  * Only semantic options change what an agent produces, so only these are
43
185
  * hashed. Callers pass their whole options object; `label` and `phase` are
@@ -146,19 +288,12 @@ export type ReplayCache = ReturnType<typeof createReplayCache>;
146
288
  * journal that serializes to a useless preview string.
147
289
  */
148
290
  export function boundedJournal(entries: readonly JournalEntry[]) {
149
- const size = (kept: readonly JournalEntry[]) =>
150
- Buffer.byteLength(
151
- JSON.stringify({ version: JOURNAL_VERSION, entries: kept }, null, 2),
152
- "utf8",
153
- );
154
-
155
- let kept = [...entries];
156
- let dropped = 0;
157
- while (kept.length > 0 && size(kept) > JOURNAL_MAX_BYTES) {
158
- kept = kept.slice(1);
159
- dropped++;
160
- }
161
- return { journal: { version: JOURNAL_VERSION, entries: kept }, dropped };
291
+ const accumulator = createJournalAccumulator();
292
+ for (const entry of entries) accumulator.append(entry);
293
+ return {
294
+ journal: accumulator.toJournal(),
295
+ dropped: accumulator.dropped,
296
+ };
162
297
  }
163
298
 
164
299
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -5,10 +5,11 @@
5
5
 
6
6
  import * as os from "node:os";
7
7
  import {
8
- truncateHead,
9
8
  type ExtensionContext,
9
+ truncateHead,
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import { formatContextUtilization } from "../shared/context-utilization.ts";
12
+ import { spinnerFrame } from "../shared/spinner.ts";
12
13
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
13
14
  import type { WorktreeCleanup } from "../shared/worktree.ts";
14
15
  import type { AcceptanceLedger } from "./acceptance.ts";
@@ -47,8 +48,31 @@ export function emptyUsage(): AgentUsage {
47
48
  };
48
49
  }
49
50
 
50
- export type AgentState = "running" | "done" | "error";
51
- export type WorkflowStatus = "running" | "completed" | "failed" | "aborted";
51
+ export type AgentState = "running" | "done" | "error" | "uncertain";
52
+ export type WorkflowStatus =
53
+ | "running"
54
+ | "completed"
55
+ | "failed"
56
+ | "aborted"
57
+ | "uncertain";
58
+
59
+ export type WorkflowDeliveryState =
60
+ | "none"
61
+ | "held-for-inline"
62
+ | "pending"
63
+ | "delivered"
64
+ | "consumed-inline";
65
+
66
+ /** Durable completion-delivery plane, independent from execution status. */
67
+ export interface WorkflowDelivery {
68
+ /** Stable per-run idempotency identity, never a transport-batch id. */
69
+ id: string;
70
+ state: WorkflowDeliveryState;
71
+ attempts: number;
72
+ updatedAt: number;
73
+ deliveredAt?: number;
74
+ lastError?: string;
75
+ }
52
76
 
53
77
  export type TranscriptRole =
54
78
  | "user"
@@ -84,6 +108,8 @@ export interface AgentRecord {
84
108
  inputCallIds?: string[];
85
109
  /** Opaque same-run reference returned to the workflow script. */
86
110
  resultRef?: string;
111
+ /** Run-directory-relative authoritative result captured before projection. */
112
+ resultArtifact?: string;
87
113
  label: string;
88
114
  phase?: string;
89
115
  state: AgentState;
@@ -115,6 +141,23 @@ export interface AgentRecord {
115
141
  export interface WorkflowLogEntry {
116
142
  at: number;
117
143
  text: string;
144
+ /** Runtime-authored evidence, distinct from free-form script narration. */
145
+ kind?: "pipeline-drop";
146
+ }
147
+
148
+ /** Metadata attached only to the bounded in-memory settled-run projection. */
149
+ export interface WorkflowMemoryProjection {
150
+ readonly kind: "settled";
151
+ readonly maxBytes: number;
152
+ readonly bytes: number;
153
+ readonly truncated: boolean;
154
+ readonly omitted: {
155
+ readonly agents: number;
156
+ readonly logs: number;
157
+ readonly transcriptEntries: number;
158
+ readonly result: boolean;
159
+ readonly graph: boolean;
160
+ };
118
161
  }
119
162
 
120
163
  export interface WorkflowDetails {
@@ -124,6 +167,8 @@ export interface WorkflowDetails {
124
167
  name?: string;
125
168
  description?: string;
126
169
  background: boolean;
170
+ /** Whether this run's terminal result is pending, delivered, or inline. */
171
+ delivery?: WorkflowDelivery;
127
172
  status: WorkflowStatus;
128
173
  startedAt: number;
129
174
  finishedAt?: number;
@@ -144,6 +189,54 @@ export interface WorkflowDetails {
144
189
  /** Read-only lineage projection; never execution or admission authority. */
145
190
  graph?: WorkflowGraphProjection;
146
191
  error?: string;
192
+ /** Present only on the session-memory projection, never canonical history. */
193
+ memoryProjection?: WorkflowMemoryProjection;
194
+ }
195
+
196
+ /** Bounded tool-result projection; authoritative details remain in run artifacts. */
197
+ export function compactWorkflowToolDetails(
198
+ details: WorkflowDetails,
199
+ ): WorkflowDetails {
200
+ return {
201
+ ...details,
202
+ ...(details.result !== undefined
203
+ ? {
204
+ result: JSON.parse(
205
+ safeStringify(details.result, { maxBytes: 64 * 1024 }),
206
+ ),
207
+ }
208
+ : {}),
209
+ agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Bound only the current-session terminal projection. Persisted workflow
215
+ * records and side artifacts remain the canonical history.
216
+ */
217
+ export const MAX_SETTLED_RUNS = 32;
218
+
219
+ /** Evict the oldest terminal projections without touching active-run state. */
220
+ export function evictOldestSettledRuns(
221
+ settledRuns: Map<string, WorkflowDetails>,
222
+ maxRuns = MAX_SETTLED_RUNS,
223
+ ): string[] {
224
+ const limit = Number.isFinite(maxRuns)
225
+ ? Math.max(0, Math.floor(maxRuns))
226
+ : MAX_SETTLED_RUNS;
227
+ const evicted: string[] = [];
228
+ while (settledRuns.size > limit) {
229
+ let oldest: { runId: string; at: number } | undefined;
230
+ for (const [runId, details] of settledRuns) {
231
+ if (details.status === "running") continue;
232
+ const at = details.finishedAt ?? details.startedAt;
233
+ if (!oldest || at < oldest.at) oldest = { runId, at };
234
+ }
235
+ if (!oldest) break;
236
+ settledRuns.delete(oldest.runId);
237
+ evicted.push(oldest.runId);
238
+ }
239
+ return evicted;
147
240
  }
148
241
 
149
242
  export function workflowGraphRecords(
@@ -278,11 +371,12 @@ export function appendLog(
278
371
  details: WorkflowDetails,
279
372
  text: string,
280
373
  at: number,
374
+ kind?: WorkflowLogEntry["kind"],
281
375
  ): void {
282
376
  const clean = sanitizeLine(text, MAX_LOG_TEXT);
283
377
  if (!clean) return;
284
378
  const logs = (details.logs ??= []);
285
- logs.push({ at, text: clean });
379
+ logs.push({ at, text: clean, ...(kind ? { kind } : {}) });
286
380
  const excess = logs.length - MAX_LOG_ENTRIES;
287
381
  if (excess > 0) {
288
382
  logs.splice(0, excess);
@@ -334,19 +428,31 @@ export function createUsageReader(agents: readonly AgentRecord[]) {
334
428
  };
335
429
  }
336
430
 
337
- /** Colored square state indicator (no emojis/glyphs). */
338
- export const SQUARE = "■";
339
-
340
- export function stateSquare(state: AgentState, theme: Theme): string {
341
- if (state === "done") return theme.fg("success", SQUARE);
342
- if (state === "error") return theme.fg("error", SQUARE);
343
- return theme.fg("warning", SQUARE);
431
+ /**
432
+ * One status indicator per state, shared by the transcript card, the
433
+ * dashboard, and the strips. Running spins on the package-wide cadence so
434
+ * every live view animates in step.
435
+ */
436
+ export function stateGlyph(
437
+ state: AgentState,
438
+ theme: Theme,
439
+ now: number,
440
+ ): string {
441
+ if (state === "done") return theme.fg("success", "✓");
442
+ if (state === "error") return theme.fg("error", "✗");
443
+ if (state === "uncertain") return theme.fg("warning", "?");
444
+ return theme.fg("warning", spinnerFrame(now));
344
445
  }
345
446
 
346
- export function statusSquare(status: WorkflowStatus, theme: Theme): string {
347
- if (status === "completed") return theme.fg("success", SQUARE);
348
- if (status === "running") return theme.fg("warning", SQUARE);
349
- return theme.fg("error", SQUARE);
447
+ export function statusGlyph(
448
+ status: WorkflowStatus,
449
+ theme: Theme,
450
+ now: number,
451
+ ): string {
452
+ if (status === "completed") return theme.fg("success", "✓");
453
+ if (status === "running") return theme.fg("warning", spinnerFrame(now));
454
+ if (status === "uncertain") return theme.fg("warning", "?");
455
+ return theme.fg("error", "✗");
350
456
  }
351
457
 
352
458
  export function statusWord(status: WorkflowStatus): string {
@@ -357,7 +463,7 @@ export function statusColor(
357
463
  status: WorkflowStatus,
358
464
  ): "success" | "warning" | "error" {
359
465
  if (status === "completed") return "success";
360
- if (status === "running") return "warning";
466
+ if (status === "running" || status === "uncertain") return "warning";
361
467
  return "error";
362
468
  }
363
469
 
@@ -392,10 +498,14 @@ export function agentContext(agent: AgentRecord): string {
392
498
  });
393
499
  }
394
500
 
395
- export function formatElapsed(startedAt: number, finishedAt?: number): string {
501
+ export function formatElapsed(
502
+ startedAt: number,
503
+ finishedAt?: number,
504
+ now = Date.now(),
505
+ ): string {
396
506
  const totalSeconds = Math.max(
397
507
  0,
398
- Math.round(((finishedAt ?? Date.now()) - startedAt) / 1000),
508
+ Math.round(((finishedAt ?? now) - startedAt) / 1000),
399
509
  );
400
510
  const minutes = Math.floor(totalSeconds / 60);
401
511
  const seconds = totalSeconds % 60;
@@ -420,13 +530,15 @@ export function aggregateUsage(agents: AgentRecord[]): AgentUsage {
420
530
  export function countStates(details: WorkflowDetails) {
421
531
  let done = 0;
422
532
  let failed = 0;
533
+ let uncertain = 0;
423
534
  let running = 0;
424
535
  for (const agent of details.agents) {
425
536
  if (agent.state === "done") done++;
426
537
  else if (agent.state === "error") failed++;
538
+ else if (agent.state === "uncertain") uncertain++;
427
539
  else running++;
428
540
  }
429
- return { done, failed, running };
541
+ return { done, failed, uncertain, running };
430
542
  }
431
543
 
432
544
  export interface PhaseGroup {
@@ -4,7 +4,9 @@ import {
4
4
  BelowEditorStripState,
5
5
  belowEditorStripInput,
6
6
  fitNavigationSides,
7
+ renderNavigationMetrics,
7
8
  } from "../shared/below-editor-navigation.ts";
9
+ import { SPINNER_INTERVAL_MS, spinnerFrame } from "../shared/spinner.ts";
8
10
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
9
11
  import {
10
12
  aggregateUsage,
@@ -12,9 +14,9 @@ import {
12
14
  formatElapsed,
13
15
  formatTokens,
14
16
  statusColor,
15
- statusSquare,
16
17
  type Theme,
17
18
  type WorkflowDetails,
19
+ type WorkflowStatus,
18
20
  } from "./model.ts";
19
21
 
20
22
  /** Workflow-named aliases preserve the public seam while sharing interaction. */
@@ -29,13 +31,29 @@ export interface WorkflowStripEntry {
29
31
  details: WorkflowDetails;
30
32
  }
31
33
 
34
+ /** Changes only when the strip needs an immediate lifecycle repaint. */
35
+ export function workflowStripEntryKey(entry: WorkflowStripEntry | undefined) {
36
+ return entry ? `${entry.runId}:${entry.details.status}` : undefined;
37
+ }
38
+
32
39
  function cleanLine(value: string) {
33
40
  return sanitizeTerminalText(value).replace(/\s+/g, " ").trim();
34
41
  }
35
42
 
43
+ /**
44
+ * One status indicator per run state; doubles as the focus marker when
45
+ * selected. Running spins, in step with the dashboard and takeover headers.
46
+ */
47
+ function statusGlyph(status: WorkflowStatus, theme: Theme, now: number) {
48
+ if (status === "completed") return theme.fg("success", "✓");
49
+ if (status === "running") return theme.fg("warning", spinnerFrame(now));
50
+ if (status === "uncertain") return theme.fg("warning", "?");
51
+ return theme.fg("error", "✗");
52
+ }
53
+
36
54
  /** Live, one-line Claude-style workflow entry rendered below the editor. */
37
55
  export class WorkflowStripWidget {
38
- private readonly timer: ReturnType<typeof setInterval>;
56
+ private timer: ReturnType<typeof setInterval> | undefined;
39
57
  private readonly tui: TUI;
40
58
  private readonly theme: Theme;
41
59
  private readonly strip: BelowEditorStripState;
@@ -51,43 +69,68 @@ export class WorkflowStripWidget {
51
69
  this.theme = theme;
52
70
  this.strip = strip;
53
71
  this.getEntry = getEntry;
54
- this.timer = setInterval(() => this.tui.requestRender(), 500);
55
- this.timer.unref?.();
72
+ this.syncSpinner();
56
73
  }
57
74
 
58
75
  dispose() {
59
- clearInterval(this.timer);
76
+ if (this.timer) clearInterval(this.timer);
77
+ this.timer = undefined;
60
78
  }
61
79
 
62
- invalidate() {}
80
+ invalidate() {
81
+ this.syncSpinner();
82
+ }
83
+
84
+ private syncSpinner() {
85
+ if (this.getEntry()?.details.status === "running") {
86
+ if (this.timer) return;
87
+ const timer = setInterval(() => {
88
+ if (this.timer !== timer) return;
89
+ if (this.getEntry()?.details.status !== "running") {
90
+ clearInterval(timer);
91
+ this.timer = undefined;
92
+ }
93
+ this.tui.requestRender();
94
+ }, SPINNER_INTERVAL_MS);
95
+ this.timer = timer;
96
+ timer.unref?.();
97
+ return;
98
+ }
99
+ if (this.timer) clearInterval(this.timer);
100
+ this.timer = undefined;
101
+ }
63
102
 
64
103
  render(width: number) {
104
+ this.syncSpinner();
65
105
  const entry = this.getEntry();
66
106
  if (!entry || width <= 0) return [];
67
107
  const details = entry.details;
68
- const { done, failed } = countStates(details);
108
+ const { done, failed, uncertain } = countStates(details);
69
109
  const settled = done + failed;
70
110
  const usage = aggregateUsage(details.agents);
71
111
  const tokenCount = usage.input + usage.output;
72
- const marker = this.strip.focused
112
+ const glyph = this.strip.focused
73
113
  ? this.theme.fg("accent", "❯")
74
- : this.theme.fg("dim", "○");
114
+ : statusGlyph(details.status, this.theme, Date.now());
75
115
  const displayName = cleanLine(details.name ?? entry.runId) || entry.runId;
76
116
  const name = this.strip.focused
77
117
  ? this.theme.bold(this.theme.fg("accent", displayName))
78
118
  : this.theme.fg("text", displayName);
79
119
  const rawContext = details.currentPhase ?? details.description;
80
120
  const context = rawContext ? cleanLine(rawContext) : undefined;
81
- const left = ` ${marker} ${statusSquare(details.status, this.theme)} ${name}${context ? this.theme.fg("dim", ` · ${context}`) : ""}`;
82
- const metrics = [
83
- `${settled}/${details.agents.length} agents`,
84
- formatElapsed(details.startedAt, details.finishedAt),
85
- tokenCount > 0 ? `${formatTokens(tokenCount)} tokens` : undefined,
121
+ const left = ` ${glyph} ${name}${context ? this.theme.fg("dim", ` · ${context}`) : ""}`;
122
+ const right = renderNavigationMetrics(
123
+ this.theme,
124
+ [
125
+ details.agents.length > 0
126
+ ? `${settled}/${details.agents.length} agents${uncertain ? ` · ${uncertain} uncertain` : ""}`
127
+ : undefined,
128
+ formatElapsed(details.startedAt, details.finishedAt),
129
+ tokenCount > 0 ? `${formatTokens(tokenCount)} tokens` : undefined,
130
+ ],
86
131
  this.strip.focused ? "enter open · ↑ back" : "↓ to manage",
87
- ]
88
- .filter((part): part is string => Boolean(part))
89
- .join(" · ");
90
- const right = this.theme.fg(statusColor(details.status), metrics);
132
+ details.status === "running" ? undefined : statusColor(details.status),
133
+ );
91
134
  return [fitNavigationSides(left, right, width)];
92
135
  }
93
136
  }