@tt-a1i/openpi 0.3.0 → 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 (61) 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 +35 -44
  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 +216 -170
  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/result-delivery.ts +50 -5
  38. package/extensions/subagents/src/runtime.ts +8 -5
  39. package/extensions/subagents/src/ui/takeover.ts +84 -109
  40. package/extensions/subagents/src/ui/transcript.ts +76 -42
  41. package/extensions/subagents/src/ui/wait-result.ts +1 -1
  42. package/extensions/tasks/ui.ts +79 -62
  43. package/extensions/ui-customization/footer.ts +7 -4
  44. package/extensions/user-input-fold/index.ts +185 -0
  45. package/extensions/workflows/artifacts.ts +35 -0
  46. package/extensions/workflows/controller.ts +14 -2
  47. package/extensions/workflows/coordinator.ts +64 -0
  48. package/extensions/workflows/dashboard.ts +353 -173
  49. package/extensions/workflows/handoff.ts +62 -20
  50. package/extensions/workflows/index.ts +647 -387
  51. package/extensions/workflows/model.ts +57 -15
  52. package/extensions/workflows/navigation.ts +33 -14
  53. package/extensions/workflows/prompt.ts +104 -8
  54. package/extensions/workflows/replay-safety.ts +16 -6
  55. package/extensions/workflows/result-delivery.ts +189 -0
  56. package/extensions/workflows/sandbox-child.cjs +11 -0
  57. package/package.json +1 -1
  58. package/skills/subagents/SKILL.md +2 -2
  59. package/skills/workflows/REFERENCE.md +7 -4
  60. package/skills/workflows/SKILL.md +53 -10
  61. package/extensions/subagents/src/format.ts +0 -48
@@ -17,9 +17,9 @@
17
17
  * `agent()` always resolves to `{ ok, output, structured?, error? }` — it
18
18
  * never throws into the script. Scripts branch on `ok` explicitly.
19
19
  *
20
- * Runs are blocking by default (live progress in the tool block). Pass
21
- * `background: true` to return immediately and get a follow-up message when
22
- * the run finishes. Run artifacts (script, args, statuses, result) are saved
20
+ * Interactive runs detach by default and deliver a completion turn later.
21
+ * Pass `wait: true` when the current tool call must return the final result.
22
+ * Run artifacts (script, args, statuses, result) are saved
23
23
  * under `~/.pi/agent/workflows/<runId>/` for inspection; result and bounded
24
24
  * transcripts use separate artifacts.
25
25
  *
@@ -33,26 +33,41 @@ import { randomBytes } from "node:crypto";
33
33
  import * as fs from "node:fs";
34
34
  import * as path from "node:path";
35
35
  import {
36
+ type ExtensionAPI,
37
+ type ExtensionContext,
36
38
  getAgentDir,
37
39
  getMarkdownTheme,
38
40
  keyHint,
39
- type ExtensionAPI,
40
41
  type SessionManager,
41
- type ExtensionContext,
42
42
  } from "@earendil-works/pi-coding-agent";
43
- import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
44
- import { Type, type Static } from "typebox";
43
+ import {
44
+ Container,
45
+ Markdown,
46
+ Spacer,
47
+ Text,
48
+ truncateToWidth,
49
+ } from "@earendil-works/pi-tui";
50
+ import { type Static, Type } from "typebox";
45
51
  import { formatActivityStatus } from "../shared/activity-status.ts";
46
52
  import { waitBounded } from "../shared/child-session.ts";
53
+ import { contextPercent } from "../shared/context-utilization.ts";
47
54
  import {
48
55
  registerEditorLayer,
49
56
  removeEditorLayer,
50
57
  } from "../shared/editor-layers.ts";
58
+ import { fitNavigationSides } from "../shared/below-editor-navigation.ts";
51
59
  import { loadSetupConfig } from "../shared/setup-config.ts";
60
+ import { SPINNER_INTERVAL_MS } from "../shared/spinner.ts";
52
61
  import {
53
62
  OPENPI_TOOL_SURFACE,
54
63
  patchOwnedTools,
55
64
  } from "../shared/tool-surface.ts";
65
+ import {
66
+ createWorktree,
67
+ reclaimWorktree,
68
+ type Worktree,
69
+ type WorktreeCleanup,
70
+ } from "../shared/worktree.ts";
56
71
  import {
57
72
  loadAgentTypes,
58
73
  resolveAgentModel,
@@ -60,16 +75,30 @@ import {
60
75
  selectSubagentModel,
61
76
  } from "../subagents/src/agent-types.ts";
62
77
  import {
63
- createWorktree,
64
- reclaimWorktree,
65
- type Worktree,
66
- type WorktreeCleanup,
67
- } from "../shared/worktree.ts";
78
+ acceptanceInstruction,
79
+ acceptanceSchema,
80
+ applyAcceptance,
81
+ evaluateAcceptance,
82
+ parseAcceptanceContract,
83
+ } from "./acceptance.ts";
68
84
  import {
69
85
  createWorkflowPersistence,
70
86
  loadJournal,
87
+ persistWorkflowAgentResult,
71
88
  persistWorkflowJson,
72
89
  } from "./artifacts.ts";
90
+ import { RunController } from "./controller.ts";
91
+ import {
92
+ resolveWorkflowLaunchPolicy,
93
+ waitForWorkflowCompletion,
94
+ } from "./coordinator.ts";
95
+ import {
96
+ listPersistedRunIds,
97
+ readPersistedWorkflowDetails,
98
+ recoverStaleWorkflowDetails,
99
+ sessionWorkflowRunIds,
100
+ showWorkflowDashboard,
101
+ } from "./dashboard.ts";
73
102
  import { createWorkflowHandoffRegistry } from "./handoff.ts";
74
103
  import {
75
104
  classifyInterruptedInvocation,
@@ -77,57 +106,59 @@ import {
77
106
  requestInvocation,
78
107
  transitionInvocation,
79
108
  } from "./invocation-ledger.ts";
80
- import {
81
- normalizeWorkflowOperatorKey,
82
- WorkflowOperatorRegistry,
83
- } from "./operator.ts";
84
109
  import {
85
110
  agentCallKey,
86
111
  createReplayCache,
87
112
  type JournalEntry,
88
113
  type ReplayCache,
89
114
  } from "./journal.ts";
90
- import { RunController } from "./controller.ts";
91
- import {
92
- normalizePersistedWorkflowDetails,
93
- recoverStaleWorkflowDetails,
94
- sessionWorkflowRunIds,
95
- showWorkflowDashboard,
96
- } from "./dashboard.ts";
97
115
  import {
98
116
  extractMeta,
99
117
  prepareWorkflowScript,
100
118
  type WorkflowMeta,
101
119
  } from "./meta.ts";
102
120
  import {
121
+ type AgentRecord,
103
122
  agentContext,
104
123
  aggregateUsage,
105
124
  appendLog,
106
125
  countStates,
126
+ createUsageReader,
107
127
  emptyUsage,
108
128
  formatElapsed,
109
129
  formatUsage,
110
130
  isWorkflowRunId,
111
131
  phaseGroups,
132
+ refreshWorkflowGraph,
112
133
  resolveWorkflowRunTarget,
113
134
  resultJson,
114
135
  sanitizeLine,
115
136
  sanitizeWorkflowDisplayLine,
116
137
  sanitizeWorkflowDisplayText,
117
- stateSquare,
138
+ stateGlyph,
118
139
  statusColor,
140
+ statusGlyph,
119
141
  statusWord,
120
- createUsageReader,
121
- refreshWorkflowGraph,
122
- SQUARE,
123
- type AgentRecord,
124
142
  type WorkflowDetails,
125
143
  } from "./model.ts";
144
+ import {
145
+ WorkflowNavigationEditor,
146
+ type WorkflowStripEntry,
147
+ WorkflowStripState,
148
+ WorkflowStripWidget,
149
+ } from "./navigation.ts";
150
+ import {
151
+ normalizeWorkflowOperatorKey,
152
+ WorkflowOperatorRegistry,
153
+ } from "./operator.ts";
126
154
  import {
127
155
  buildBackgroundWorkflowFollowUp,
128
156
  buildBackgroundWorkflowLaunchResult,
157
+ buildProjectedWorkflowCompletionBatch,
158
+ buildProjectedWorkflowResultMessage,
129
159
  buildWorkflowAgentPrompt,
130
160
  buildWorkflowResultMessage,
161
+ buildWorkflowStatusSummary,
131
162
  WORKFLOW_LIFECYCLE_PROMPT_SNIPPET,
132
163
  WORKFLOW_PARAMETER_DESCRIPTIONS,
133
164
  WORKFLOW_PROMPT_GUIDELINES,
@@ -139,39 +170,331 @@ import {
139
170
  WORKFLOW_TOOL_DESCRIPTION,
140
171
  } from "./prompt.ts";
141
172
  import {
142
- WorkflowNavigationEditor,
143
- WorkflowStripState,
144
- WorkflowStripWidget,
145
- type WorkflowStripEntry,
146
- } from "./navigation.ts";
173
+ createWorkflowResultDelivery,
174
+ type WorkflowCompletionEnvelope,
175
+ } from "./result-delivery.ts";
176
+ import {
177
+ beginProcessReplayWorkspaceLease,
178
+ createReplayIdentity,
179
+ isReplaySafeAgentCall,
180
+ } from "./replay-safety.ts";
147
181
  import {
148
182
  createWorkflowResources,
149
183
  runAgent,
150
184
  type ThinkingLevel,
185
+ type WorkflowAgentSessionFactory,
151
186
  type WorkflowModel,
152
187
  } from "./runner.ts";
153
- import {
154
- beginProcessReplayWorkspaceLease,
155
- createReplayIdentity,
156
- isReplaySafeAgentCall,
157
- } from "./replay-safety.ts";
158
188
  import { runWorkflowSandbox } from "./sandbox.ts";
159
- import {
160
- acceptanceInstruction,
161
- acceptanceSchema,
162
- applyAcceptance,
163
- evaluateAcceptance,
164
- parseAcceptanceContract,
165
- } from "./acceptance.ts";
189
+ import { safeStringify, writeFileAtomic } from "./serialization.ts";
166
190
  import {
167
191
  finalizeWorktreeHandoff,
168
192
  prepareWorktreeHandoff,
169
193
  } from "./worktree-handoff.ts";
170
- import { safeStringify, writeFileAtomic } from "./serialization.ts";
171
194
 
172
195
  const PREVIEW_LENGTH = 200;
173
196
  const EMIT_INTERVAL_MS = 120;
174
197
 
198
+ /** Header of a workflow card: identity and phase on the left, metrics right. */
199
+ function runHeader(
200
+ details: WorkflowDetails,
201
+ theme: Parameters<typeof statusGlyph>[1],
202
+ now: number,
203
+ ) {
204
+ const { done, failed, uncertain } = countStates(details);
205
+ const settled = done + failed;
206
+ const elapsed = formatElapsed(details.startedAt, details.finishedAt);
207
+ // A just-launched run has no agents and a 0s clock; the metrics join in
208
+ // once there is something real to report.
209
+ const counts =
210
+ details.agents.length > 0
211
+ ? `${settled}/${details.agents.length} agents${uncertain ? ` · ${uncertain} uncertain` : ""}`
212
+ : undefined;
213
+ const metrics = [counts, counts || elapsed !== "0s" ? elapsed : undefined]
214
+ .filter(Boolean)
215
+ .join(" · ");
216
+ let left =
217
+ `${statusGlyph(details.status, theme, now)} ${theme.fg("toolTitle", theme.bold("workflow "))}` +
218
+ theme.fg(
219
+ "accent",
220
+ sanitizeWorkflowDisplayLine(details.name ?? details.runId),
221
+ );
222
+ if (details.status === "running" && details.currentPhase) {
223
+ left += theme.fg(
224
+ "muted",
225
+ ` · ${sanitizeWorkflowDisplayLine(details.currentPhase)}`,
226
+ );
227
+ }
228
+ // The glyph already carries the run state, so the status word only stays
229
+ // for terminal states.
230
+ let right = theme.fg("dim", metrics);
231
+ if (details.status !== "running") {
232
+ right +=
233
+ theme.fg("dim", `${metrics ? " · " : ""}`) +
234
+ theme.fg(statusColor(details.status), statusWord(details.status));
235
+ }
236
+ if (failed) right += theme.fg("error", ` · ${failed} failed`);
237
+ return { left, right };
238
+ }
239
+
240
+ /**
241
+ * Collapsed workflow card, rebuilt per repaint. Metrics right-align like the
242
+ * below-editor strip, and each agent row carries one number only: context
243
+ * occupancy says a running agent is alive, elapsed says what a settled one
244
+ * cost. A swarm shows the first few agents and summarizes the rest instead
245
+ * of flooding the chat.
246
+ */
247
+ function buildCollapsedRows(
248
+ details: WorkflowDetails,
249
+ theme: Parameters<typeof statusGlyph>[1],
250
+ width: number,
251
+ now: number,
252
+ totals: string,
253
+ ) {
254
+ const header = runHeader(details, theme, now);
255
+ const rows = [fitNavigationSides(header.left, header.right, width)];
256
+ const collapsedAgents = details.agents.slice(0, 8);
257
+ for (const agent of collapsedAgents) {
258
+ const percent = contextPercent({
259
+ tokens: agent.usage.contextTokens,
260
+ contextWindow: agent.contextWindow,
261
+ });
262
+ const stat =
263
+ agent.state === "running"
264
+ ? percent === undefined
265
+ ? undefined
266
+ : `${percent}%`
267
+ : formatElapsed(agent.startedAt, agent.finishedAt);
268
+ const left = ` ${stateGlyph(agent.state, theme, now)} ${theme.fg(
269
+ "accent",
270
+ sanitizeWorkflowDisplayLine(agent.label),
271
+ )}`;
272
+ rows.push(
273
+ stat
274
+ ? fitNavigationSides(left, theme.fg("dim", stat), width)
275
+ : truncateToWidth(left, width, "…"),
276
+ );
277
+ }
278
+ const hiddenAgents = details.agents.length - collapsedAgents.length;
279
+ if (hiddenAgents > 0) {
280
+ rows.push(` ${theme.fg("dim", `… ${hiddenAgents} more`)}`);
281
+ }
282
+ // Only the tail collapsed: the newest lines are the ones that say where
283
+ // the run is now.
284
+ for (const entry of (details.logs ?? []).slice(-3)) {
285
+ rows.push(
286
+ truncateToWidth(
287
+ ` ${theme.fg("muted", "›")} ${theme.fg(
288
+ "dim",
289
+ sanitizeWorkflowDisplayLine(entry.text),
290
+ )}`,
291
+ width,
292
+ "…",
293
+ ),
294
+ );
295
+ }
296
+ if (totals) rows.push(` ${theme.fg("dim", `Total: ${totals}`)}`);
297
+ if (details.error) {
298
+ rows.push(
299
+ truncateToWidth(
300
+ ` ${theme.fg(
301
+ "error",
302
+ `Error: ${sanitizeWorkflowDisplayLine(details.error)}`,
303
+ )}`,
304
+ width,
305
+ "…",
306
+ ),
307
+ );
308
+ }
309
+ // Expanding only earns its hint when there is more to see.
310
+ if (
311
+ details.agents.length > 0 ||
312
+ (details.logs ?? []).length > 0 ||
313
+ details.description ||
314
+ details.result !== undefined ||
315
+ details.error
316
+ ) {
317
+ rows.push(
318
+ theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`),
319
+ );
320
+ }
321
+ return rows;
322
+ }
323
+
324
+ function buildExpandedWorkflow(
325
+ details: WorkflowDetails,
326
+ theme: Parameters<typeof statusGlyph>[1],
327
+ now: number,
328
+ totals: string,
329
+ ) {
330
+ const header = runHeader(details, theme, now);
331
+ const container = new Container();
332
+ container.addChild(
333
+ new Text(
334
+ header.right ? `${header.left} ${header.right}` : header.left,
335
+ 0,
336
+ 0,
337
+ ),
338
+ );
339
+ if (details.description) {
340
+ container.addChild(
341
+ new Text(
342
+ theme.fg("dim", sanitizeWorkflowDisplayLine(details.description)),
343
+ 0,
344
+ 0,
345
+ ),
346
+ );
347
+ }
348
+
349
+ for (const group of phaseGroups(details)) {
350
+ container.addChild(new Spacer(1));
351
+ container.addChild(
352
+ new Text(
353
+ theme.fg(
354
+ "muted",
355
+ `─── ${sanitizeWorkflowDisplayLine(group.title)} ───`,
356
+ ),
357
+ 0,
358
+ 0,
359
+ ),
360
+ );
361
+ for (const agent of group.agents) {
362
+ const usage = formatUsage(agent.usage, agent.model);
363
+ const context = agentContext(agent);
364
+ let line = `${stateGlyph(agent.state, theme, now)} ${theme.fg(
365
+ "accent",
366
+ sanitizeWorkflowDisplayLine(agent.label),
367
+ )} ${theme.fg(
368
+ "dim",
369
+ [context, formatElapsed(agent.startedAt, agent.finishedAt)]
370
+ .filter(Boolean)
371
+ .join(" · "),
372
+ )}`;
373
+ if (usage)
374
+ line += ` ${theme.fg("dim", sanitizeWorkflowDisplayLine(usage))}`;
375
+ container.addChild(new Text(line, 0, 0));
376
+ if (agent.error) {
377
+ container.addChild(
378
+ new Text(
379
+ ` ${theme.fg("error", sanitizeWorkflowDisplayLine(agent.error))}`,
380
+ 0,
381
+ 0,
382
+ ),
383
+ );
384
+ } else if (agent.preview) {
385
+ const preview = sanitizeWorkflowDisplayText(
386
+ agent.preview,
387
+ PREVIEW_LENGTH,
388
+ )
389
+ .split("\n")
390
+ .slice(0, 2)
391
+ .join(" ");
392
+ container.addChild(new Text(` ${theme.fg("dim", preview)}`, 0, 0));
393
+ }
394
+ }
395
+ }
396
+
397
+ if (details.logs && details.logs.length > 0) {
398
+ container.addChild(new Spacer(1));
399
+ container.addChild(new Text(theme.fg("muted", "─── log ───"), 0, 0));
400
+ if (details.logsDropped) {
401
+ container.addChild(
402
+ new Text(
403
+ theme.fg("dim", `(${details.logsDropped} earlier line(s) dropped)`),
404
+ 0,
405
+ 0,
406
+ ),
407
+ );
408
+ }
409
+ for (const entry of details.logs) {
410
+ container.addChild(
411
+ new Text(
412
+ `${theme.fg("muted", "›")} ${theme.fg(
413
+ "dim",
414
+ sanitizeWorkflowDisplayLine(entry.text),
415
+ )}`,
416
+ 0,
417
+ 0,
418
+ ),
419
+ );
420
+ }
421
+ }
422
+
423
+ if (details.error) {
424
+ container.addChild(new Spacer(1));
425
+ container.addChild(
426
+ new Text(
427
+ theme.fg(
428
+ "error",
429
+ `Error: ${sanitizeWorkflowDisplayLine(details.error)}`,
430
+ ),
431
+ 0,
432
+ 0,
433
+ ),
434
+ );
435
+ }
436
+
437
+ if (details.result !== undefined) {
438
+ container.addChild(new Spacer(1));
439
+ container.addChild(new Text(theme.fg("muted", "─── result ───"), 0, 0));
440
+ container.addChild(
441
+ new Markdown(
442
+ `\`\`\`json\n${resultJson(details.result)}\n\`\`\``,
443
+ 0,
444
+ 0,
445
+ getMarkdownTheme(),
446
+ ),
447
+ );
448
+ }
449
+
450
+ if (totals) {
451
+ container.addChild(new Spacer(1));
452
+ container.addChild(new Text(theme.fg("dim", `Total: ${totals}`), 0, 0));
453
+ }
454
+ return container;
455
+ }
456
+
457
+ interface WorkflowRenderState {
458
+ spinnerTimer?: ReturnType<typeof setInterval>;
459
+ }
460
+
461
+ function syncWorkflowSpinner(
462
+ state: WorkflowRenderState,
463
+ isRunning: () => boolean,
464
+ invalidate: () => void,
465
+ ) {
466
+ if (!isRunning()) {
467
+ if (state.spinnerTimer) clearInterval(state.spinnerTimer);
468
+ state.spinnerTimer = undefined;
469
+ return;
470
+ }
471
+ if (state.spinnerTimer) return;
472
+ const spinnerTimer = setInterval(() => {
473
+ if (state.spinnerTimer !== spinnerTimer) return;
474
+ if (!isRunning()) {
475
+ clearInterval(spinnerTimer);
476
+ state.spinnerTimer = undefined;
477
+ }
478
+ invalidate();
479
+ }, SPINNER_INTERVAL_MS);
480
+ state.spinnerTimer = spinnerTimer;
481
+ spinnerTimer.unref?.();
482
+ }
483
+
484
+ /**
485
+ * Test-only injection seam for execute-level tests: production never sets it.
486
+ * The underscore-prefixed setter name makes any accidental production use
487
+ * self-evidently wrong.
488
+ */
489
+ let testAgentSessionFactory: WorkflowAgentSessionFactory | undefined;
490
+
491
+ /** Test-only: override how workflow children create their agent sessions. */
492
+ export function __setWorkflowTestAgentSessionFactory(
493
+ factory: WorkflowAgentSessionFactory | undefined,
494
+ ) {
495
+ testAgentSessionFactory = factory;
496
+ }
497
+
175
498
  const THINKING_LEVELS = [
176
499
  "off",
177
500
  "minimal",
@@ -223,6 +546,11 @@ const WorkflowParams = Type.Object({
223
546
  description: WORKFLOW_PARAMETER_DESCRIPTIONS.background,
224
547
  }),
225
548
  ),
549
+ wait: Type.Optional(
550
+ Type.Boolean({
551
+ description: WORKFLOW_PARAMETER_DESCRIPTIONS.wait,
552
+ }),
553
+ ),
226
554
  resume_from_run_id: Type.Optional(
227
555
  Type.String({
228
556
  description: WORKFLOW_PARAMETER_DESCRIPTIONS.resumeFromRunId,
@@ -267,12 +595,12 @@ function errorText(error: unknown): string {
267
595
  }
268
596
 
269
597
  function summaryLine(details: WorkflowDetails): string {
270
- const { done, failed } = countStates(details);
598
+ const { done, failed, uncertain } = countStates(details);
271
599
  const settled = done + failed;
272
600
  // The newest narrator line beats the phase title when there is one: the
273
601
  // script wrote it precisely because it says more than the phase does.
274
602
  const latest = details.logs?.[details.logs.length - 1]?.text;
275
- return `workflow ${details.name ?? details.runId}: ${settled}/${details.agents.length} agents${
603
+ return `workflow ${details.name ?? details.runId}: ${settled}/${details.agents.length} agents${uncertain ? ` · ${uncertain} uncertain` : ""}${
276
604
  latest
277
605
  ? ` · ${latest}`
278
606
  : details.currentPhase
@@ -348,15 +676,8 @@ function listRuns(
348
676
  referencedRunIds: ReadonlySet<string>,
349
677
  startedSince = 0,
350
678
  ): RunSummary[] {
351
- const base = path.join(getAgentDir(), "workflows");
352
- let names: string[] = [];
353
- try {
354
- names = fs.readdirSync(base).filter(isWorkflowRunId);
355
- } catch {
356
- // No runs yet.
357
- }
358
679
  const summaries: RunSummary[] = [];
359
- for (const runId of names) {
680
+ for (const runId of listPersistedRunIds()) {
360
681
  const live = activeRuns.get(runId);
361
682
  if (live) {
362
683
  const { done, failed } = countStates(live);
@@ -371,34 +692,30 @@ function listRuns(
371
692
  });
372
693
  continue;
373
694
  }
374
- try {
375
- const parsed = JSON.parse(
376
- fs.readFileSync(path.join(base, runId, "workflow.json"), "utf8"),
377
- ) as Partial<WorkflowDetails>;
378
- const startedAt = parsed.startedAt ?? 0;
379
- const touchedAt = Math.max(startedAt, parsed.finishedAt ?? 0);
380
- if (
381
- touchedAt < startedSince ||
382
- (parsed.sessionId !== sessionId && !referencedRunIds.has(runId))
383
- ) {
384
- continue;
385
- }
386
- const agents = parsed.agents ?? [];
387
- summaries.push({
388
- runId,
389
- name: parsed.name,
390
- status:
391
- parsed.status === "running"
392
- ? "aborted"
393
- : (parsed.status ?? "unknown"),
394
- done: agents.filter((agent) => agent.state !== "running").length,
395
- total: agents.length,
396
- startedAt: parsed.startedAt ?? 0,
397
- active: false,
398
- });
399
- } catch {
400
- // Ignore unreadable artifacts because their session cannot be verified.
695
+ // Same reader as the dashboard and workflow_status: old-format runs are
696
+ // normalized here too, so every surface reports one status per run.
697
+ const details = readPersistedWorkflowDetails(runId, {
698
+ hydrateArtifacts: false,
699
+ });
700
+ if (!details) continue;
701
+ const touchedAt = Math.max(details.startedAt, details.finishedAt ?? 0);
702
+ if (
703
+ touchedAt < startedSince ||
704
+ (details.sessionId !== sessionId && !referencedRunIds.has(runId))
705
+ ) {
706
+ continue;
401
707
  }
708
+ recoverStaleWorkflowDetails(details);
709
+ const { done, failed } = countStates(details);
710
+ summaries.push({
711
+ runId,
712
+ name: details.name,
713
+ status: details.status,
714
+ done: done + failed,
715
+ total: details.agents.length,
716
+ startedAt: details.startedAt,
717
+ active: false,
718
+ });
402
719
  }
403
720
  return summaries.sort((a, b) => b.startedAt - a.startedAt);
404
721
  }
@@ -410,14 +727,15 @@ function runDetailText(
410
727
  const runDir = path.join(getAgentDir(), "workflows", run.runId);
411
728
  const live = activeRuns.get(run.runId);
412
729
  if (live) return buildWorkflowResultMessage(live, runDir);
413
- try {
414
- const parsed = JSON.parse(
415
- fs.readFileSync(path.join(runDir, "workflow.json"), "utf8"),
416
- ) as WorkflowDetails;
417
- return buildWorkflowResultMessage(parsed, runDir);
418
- } catch {
419
- return `Run ${run.runId} — ${run.status}`;
420
- }
730
+ const details = readPersistedWorkflowDetails(run.runId, {
731
+ hydrateArtifacts: true,
732
+ });
733
+ if (details)
734
+ return buildWorkflowResultMessage(
735
+ recoverStaleWorkflowDetails(details),
736
+ runDir,
737
+ );
738
+ return `Run ${run.runId} — ${run.status}`;
421
739
  }
422
740
 
423
741
  export default function workflows(pi: ExtensionAPI) {
@@ -428,13 +746,9 @@ export default function workflows(pi: ExtensionAPI) {
428
746
  [...activeRuns].map(([runId, run]) => [runId, run.details] as const),
429
747
  );
430
748
  const settledRuns = new Map<string, WorkflowDetails>();
431
- const hideLifecycleTools = () =>
432
- patchOwnedTools(pi, "workflows", {
433
- disable: OPENPI_TOOL_SURFACE.workflows.deferred,
434
- });
435
- const showLifecycleTools = () =>
749
+ const registerStableToolFamily = () =>
436
750
  patchOwnedTools(pi, "workflows", {
437
- enable: OPENPI_TOOL_SURFACE.workflows.deferred,
751
+ enable: OPENPI_TOOL_SURFACE.workflows.entry,
438
752
  });
439
753
  const stripState = new WorkflowStripState();
440
754
  const widgetKey = "workflow-navigation";
@@ -444,6 +758,52 @@ export default function workflows(pi: ExtensionAPI) {
444
758
  * next explicit request acknowledges them.
445
759
  */
446
760
  let lastContext: ExtensionContext | undefined;
761
+ const completionEnvelope = (
762
+ details: WorkflowDetails,
763
+ ): WorkflowCompletionEnvelope => {
764
+ const deliveryId = details.delivery?.id;
765
+ if (!deliveryId) throw new Error("Workflow delivery identity is missing");
766
+ return {
767
+ deliveryId,
768
+ runId: details.runId,
769
+ details,
770
+ };
771
+ };
772
+ const resultDelivery = createWorkflowResultDelivery({
773
+ isIdle: () => lastContext?.isIdle() ?? false,
774
+ persist: (details) =>
775
+ persistWorkflowJson(
776
+ path.join(getAgentDir(), "workflows", details.runId),
777
+ details,
778
+ ),
779
+ deliver: async (envelopes, wake) => {
780
+ const content = buildProjectedWorkflowCompletionBatch(
781
+ envelopes.map((envelope) => ({
782
+ deliveryId: envelope.deliveryId,
783
+ details: envelope.details,
784
+ runDir: path.join(getAgentDir(), "workflows", envelope.runId),
785
+ })),
786
+ lastContext?.getContextUsage?.(),
787
+ );
788
+ pi.sendMessage(
789
+ {
790
+ customType: "workflow-result",
791
+ content,
792
+ display: true,
793
+ ...(envelopes.length === 1
794
+ ? { details: compactToolDetails(envelopes[0]!.details) }
795
+ : {}),
796
+ },
797
+ wake
798
+ ? { deliverAs: "followUp", triggerTurn: true }
799
+ : { deliverAs: "nextTurn" },
800
+ );
801
+ return envelopes.map((envelope) => ({
802
+ deliveryId: envelope.deliveryId,
803
+ delivered: true,
804
+ }));
805
+ },
806
+ });
447
807
  let completedRuns = 0;
448
808
  let failedRuns = 0;
449
809
  let widgetVisible = false;
@@ -593,7 +953,7 @@ export default function workflows(pi: ExtensionAPI) {
593
953
  };
594
954
 
595
955
  pi.on("session_start", (_event, ctx) => {
596
- hideLifecycleTools();
956
+ registerStableToolFamily();
597
957
  if (ctx.hasUI) lastContext = ctx;
598
958
  agentTypes = loadAgentTypes({
599
959
  agentDir: getAgentDir(),
@@ -606,6 +966,35 @@ export default function workflows(pi: ExtensionAPI) {
606
966
  settledRuns.clear();
607
967
  installWorkflowNavigation(ctx);
608
968
  updateIndicator();
969
+
970
+ const sessionId = ctx.sessionManager.getSessionId();
971
+ for (const runId of listPersistedRunIds()) {
972
+ const details = readPersistedWorkflowDetails(runId, {
973
+ hydrateArtifacts: true,
974
+ });
975
+ if (!details || details.sessionId !== sessionId) {
976
+ continue;
977
+ }
978
+ const wasRunning = details.status === "running";
979
+ if (wasRunning) {
980
+ recoverStaleWorkflowDetails(details);
981
+ persistWorkflowJson(
982
+ path.join(getAgentDir(), "workflows", details.runId),
983
+ details,
984
+ );
985
+ }
986
+ // Pre-V2 terminal runs have no delivery receipt. They may already have
987
+ // been shown, so replaying them on upgrade would be a surprising
988
+ // duplicate. Only migrate owner-lost runs, whose uncertainty matters.
989
+ if (details.delivery) {
990
+ resultDelivery.restore(completionEnvelope(details));
991
+ }
992
+ }
993
+ void resultDelivery.flushIfIdle();
994
+ });
995
+
996
+ pi.on("agent_settled", () => {
997
+ void resultDelivery.parentSettled();
609
998
  });
610
999
 
611
1000
  pi.on("input", (event) => {
@@ -631,6 +1020,7 @@ export default function workflows(pi: ExtensionAPI) {
631
1020
  widgetVisible = false;
632
1021
  requestWidgetRender = undefined;
633
1022
  stripState.focused = false;
1023
+ resultDelivery.clear();
634
1024
  });
635
1025
 
636
1026
  pi.registerCommand("workflows", {
@@ -736,7 +1126,13 @@ export default function workflows(pi: ExtensionAPI) {
736
1126
  const meta = prepared.meta;
737
1127
  const runId = `wf_${randomBytes(6).toString("hex")}`;
738
1128
  const runDir = path.join(getAgentDir(), "workflows", runId);
739
- const background = (params.background ?? false) && ctx.hasUI;
1129
+ const canDeliverLater = ctx.hasUI && ctx.mode === "tui";
1130
+ const launchPolicy = resolveWorkflowLaunchPolicy(
1131
+ { wait: params.wait, background: params.background },
1132
+ canDeliverLater,
1133
+ );
1134
+ const background = launchPolicy.detached;
1135
+ const now = Date.now();
740
1136
 
741
1137
  const details: WorkflowDetails = {
742
1138
  runId,
@@ -745,9 +1141,15 @@ export default function workflows(pi: ExtensionAPI) {
745
1141
  description: meta.description,
746
1142
  background,
747
1143
  status: "running",
748
- startedAt: Date.now(),
1144
+ startedAt: now,
749
1145
  phases: [...meta.phases],
750
1146
  agents: [],
1147
+ delivery: {
1148
+ id: `workflow:${runId}:terminal`,
1149
+ state: launchPolicy.wait ? "held-for-inline" : "none",
1150
+ attempts: 0,
1151
+ updatedAt: now,
1152
+ },
751
1153
  };
752
1154
 
753
1155
  // Resume: replay cached results for calls whose content is unchanged.
@@ -776,13 +1178,13 @@ export default function workflows(pi: ExtensionAPI) {
776
1178
  journal: () => journalEntries,
777
1179
  });
778
1180
 
779
- // Background runs survive Esc on the parent turn, but all runs are
780
- // aborted and settled during session shutdown.
1181
+ // A caller wait never owns the run. All runs survive an interrupted
1182
+ // launch turn and are aborted only by workflow_stop/session shutdown.
781
1183
  const workflowConfig = loadSetupConfig().workflows;
782
1184
  const projectTrusted = ctx.isProjectTrusted();
783
1185
  const runAgentTypes = agentTypes;
784
1186
  const controller = new RunController(
785
- background ? undefined : signal,
1187
+ undefined,
786
1188
  workflowConfig.concurrency,
787
1189
  workflowConfig.maxAgentCalls,
788
1190
  );
@@ -870,6 +1272,13 @@ export default function workflows(pi: ExtensionAPI) {
870
1272
  }
871
1273
  details.status = status;
872
1274
  details.finishedAt = Date.now();
1275
+ if (details.delivery && background) {
1276
+ details.delivery = {
1277
+ ...details.delivery,
1278
+ state: "pending",
1279
+ updatedAt: details.finishedAt,
1280
+ };
1281
+ }
873
1282
  refreshWorkflowGraph(details);
874
1283
  if (error) details.error = sanitizeWorkflowDisplayLine(error);
875
1284
  return true;
@@ -905,7 +1314,11 @@ export default function workflows(pi: ExtensionAPI) {
905
1314
 
906
1315
  // One reader per run: it carries a high-water mark, because per-agent
907
1316
  // usage is recomputed from a message list that compaction shrinks.
908
- const readUsage = createUsageReader(details.agents);
1317
+ const readBaseUsage = createUsageReader(details.agents);
1318
+ const readUsage = () => ({
1319
+ ...readBaseUsage(),
1320
+ limits: controller.capacity(),
1321
+ });
909
1322
 
910
1323
  let agentCounter = 0;
911
1324
  const agentFn = async (
@@ -1239,11 +1652,18 @@ export default function workflows(pi: ExtensionAPI) {
1239
1652
  cached.structured,
1240
1653
  );
1241
1654
  }
1655
+ record.resultArtifact = persistWorkflowAgentResult(runDir, index, {
1656
+ output: cached.output,
1657
+ ...(cached.structured !== undefined
1658
+ ? { structured: cached.structured }
1659
+ : {}),
1660
+ });
1242
1661
  const ref = handoffs.register({
1243
1662
  callId,
1244
1663
  settled: true,
1245
1664
  ok: true,
1246
1665
  output: cached.output,
1666
+ resultArtifact: record.resultArtifact,
1247
1667
  ...(cached.structured !== undefined
1248
1668
  ? { structured: cached.structured }
1249
1669
  : {}),
@@ -1365,6 +1785,9 @@ export default function workflows(pi: ExtensionAPI) {
1365
1785
  ...(sessionManager ? { sessionManager } : {}),
1366
1786
  modelRegistry: ctx.modelRegistry,
1367
1787
  ...(agentType?.tools ? { tools: agentType.tools } : {}),
1788
+ ...(testAgentSessionFactory
1789
+ ? { sessionFactory: testAgentSessionFactory }
1790
+ : {}),
1368
1791
  ...(replayIdentity
1369
1792
  ? {
1370
1793
  replayFilesystemBoundary: {
@@ -1441,11 +1864,26 @@ export default function workflows(pi: ExtensionAPI) {
1441
1864
  record.error = judged.error
1442
1865
  ? sanitizeWorkflowDisplayLine(judged.error)
1443
1866
  : undefined;
1867
+ if (outcomeOk) {
1868
+ record.resultArtifact = persistWorkflowAgentResult(
1869
+ runDir,
1870
+ index,
1871
+ {
1872
+ output: outcome.output,
1873
+ ...(outcome.structured !== undefined
1874
+ ? { structured: outcome.structured }
1875
+ : {}),
1876
+ },
1877
+ );
1878
+ }
1444
1879
  const ref = handoffs.register({
1445
1880
  callId,
1446
1881
  settled: true,
1447
1882
  ok: outcomeOk,
1448
1883
  output: outcome.output,
1884
+ ...(record.resultArtifact
1885
+ ? { resultArtifact: record.resultArtifact }
1886
+ : {}),
1449
1887
  ...(outcome.structured !== undefined
1450
1888
  ? { structured: outcome.structured }
1451
1889
  : {}),
@@ -1624,48 +2062,29 @@ export default function workflows(pi: ExtensionAPI) {
1624
2062
  if (ctx.hasUI) lastContext = ctx;
1625
2063
  updateIndicator();
1626
2064
 
2065
+ const recordTerminalRun = () => {
2066
+ activeRuns.delete(runId);
2067
+ recordSettledRun(details);
2068
+ updateIndicator();
2069
+ };
2070
+
2071
+ const settleForLaterDelivery = async (inlineReleased: boolean) => {
2072
+ try {
2073
+ await completion;
2074
+ } catch (error) {
2075
+ details.status = "failed";
2076
+ details.finishedAt = Date.now();
2077
+ details.error = details.error ?? errorText(error);
2078
+ } finally {
2079
+ recordTerminalRun();
2080
+ const envelope = completionEnvelope(details);
2081
+ if (inlineReleased) resultDelivery.releaseInline(envelope);
2082
+ else resultDelivery.defer(envelope);
2083
+ }
2084
+ };
2085
+
1627
2086
  if (background) {
1628
- void completion
1629
- .catch((error) => {
1630
- details.status = "failed";
1631
- details.finishedAt = Date.now();
1632
- details.error = details.error ?? errorText(error);
1633
- })
1634
- .finally(() => {
1635
- activeRuns.delete(runId);
1636
- recordSettledRun(details);
1637
- updateIndicator();
1638
- try {
1639
- // Deliver like the subagent/terminal families: a custom-typed
1640
- // session message with a dedicated renderer, not a plain
1641
- // user-provenance turn.
1642
- //
1643
- // Wake the model only if it is idle and therefore plausibly
1644
- // waiting on this run. If it is busy with something else, the
1645
- // result still enters context with the user's next message
1646
- // (nextTurn) instead of forcing a turn it can only acknowledge.
1647
- const wake = ctx.isIdle();
1648
- pi.sendMessage(
1649
- {
1650
- customType: "workflow-result",
1651
- content: buildBackgroundWorkflowFollowUp({
1652
- runId,
1653
- name: details.name,
1654
- status: details.status,
1655
- result: buildWorkflowResultMessage(details, runDir),
1656
- }),
1657
- display: true,
1658
- details: compactToolDetails(details),
1659
- },
1660
- wake
1661
- ? { deliverAs: "followUp", triggerTurn: true }
1662
- : { deliverAs: "nextTurn" },
1663
- );
1664
- } catch {
1665
- // Session may be shutting down.
1666
- }
1667
- });
1668
- showLifecycleTools();
2087
+ void settleForLaterDelivery(false);
1669
2088
  return {
1670
2089
  content: [
1671
2090
  {
@@ -1681,13 +2100,15 @@ export default function workflows(pi: ExtensionAPI) {
1681
2100
  };
1682
2101
  }
1683
2102
 
1684
- try {
1685
- await completion;
1686
- } finally {
1687
- activeRuns.delete(runId);
1688
- recordSettledRun(details);
1689
- updateIndicator();
2103
+ const waitOutcome = await waitForWorkflowCompletion(completion, signal);
2104
+ if (waitOutcome === "aborted") {
2105
+ void settleForLaterDelivery(true);
2106
+ throw new Error(
2107
+ `Workflow wait interrupted; run ${runId} continues in the background.`,
2108
+ );
1690
2109
  }
2110
+ recordTerminalRun();
2111
+ resultDelivery.consumeInline(details);
1691
2112
  if (details.status !== "completed") {
1692
2113
  // Pi marks tool failures only when execute throws; returning isError is
1693
2114
  // ignored by the extension API.
@@ -1697,7 +2118,11 @@ export default function workflows(pi: ExtensionAPI) {
1697
2118
  content: [
1698
2119
  {
1699
2120
  type: "text",
1700
- text: buildWorkflowResultMessage(details, runDir),
2121
+ text: buildProjectedWorkflowResultMessage(
2122
+ details,
2123
+ runDir,
2124
+ ctx.getContextUsage?.(),
2125
+ ),
1701
2126
  },
1702
2127
  ],
1703
2128
  details: compactToolDetails(details),
@@ -1716,14 +2141,14 @@ export default function workflows(pi: ExtensionAPI) {
1716
2141
  const description = (meta as WorkflowMeta).description;
1717
2142
  if (description) text += `\n ${theme.fg("dim", description)}`;
1718
2143
  for (const phase of meta.phases.slice(0, 8)) {
1719
- text += `\n ${theme.fg("dim", SQUARE)} ${theme.fg("accent", phase.title)}${
2144
+ text += `\n ${theme.fg("dim", "○")} ${theme.fg("accent", phase.title)}${
1720
2145
  phase.detail ? theme.fg("dim", ` — ${phase.detail}`) : ""
1721
2146
  }`;
1722
2147
  }
1723
2148
  return new Text(text, 0, 0);
1724
2149
  },
1725
2150
 
1726
- renderResult(result, { expanded }, theme) {
2151
+ renderResult(result, { expanded, isPartial }, theme, context) {
1727
2152
  const details = result.details as WorkflowDetails | undefined;
1728
2153
  if (!details) {
1729
2154
  const first = result.content[0];
@@ -1733,205 +2158,49 @@ export default function workflows(pi: ExtensionAPI) {
1733
2158
  0,
1734
2159
  );
1735
2160
  }
2161
+ const currentDetails = () =>
2162
+ activeRuns.get(details.runId)?.details ??
2163
+ settledRuns.get(details.runId) ??
2164
+ details;
2165
+ syncWorkflowSpinner(
2166
+ context.state as WorkflowRenderState,
2167
+ () =>
2168
+ currentDetails().status === "running" &&
2169
+ (isPartial || activeRuns.has(details.runId)),
2170
+ context.invalidate,
2171
+ );
1736
2172
 
1737
- const { done, failed } = countStates(details);
1738
- const settled = done + failed;
1739
- const elapsed = formatElapsed(details.startedAt, details.finishedAt);
1740
- let header =
1741
- `${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` +
1742
- `${theme.fg(
1743
- "accent",
1744
- sanitizeWorkflowDisplayLine(details.name ?? details.runId),
1745
- )} ` +
1746
- theme.fg(
1747
- "dim",
1748
- `${settled}/${details.agents.length} agents · ${elapsed} · `,
1749
- ) +
1750
- theme.fg(statusColor(details.status), statusWord(details.status));
1751
- if (failed) header += theme.fg("error", ` · ${failed} failed`);
1752
- if (details.background) header += theme.fg("dim", " (background)");
1753
- if (details.status === "running" && details.currentPhase) {
1754
- header += theme.fg(
1755
- "muted",
1756
- ` · ${sanitizeWorkflowDisplayLine(details.currentPhase)}`,
1757
- );
1758
- }
1759
- const totals = formatUsage(aggregateUsage(details.agents));
1760
-
1761
- if (!expanded) {
1762
- let text = header;
1763
- for (const agent of details.agents) {
1764
- const context = agentContext(agent);
1765
- text += `\n ${stateSquare(agent.state, theme)} ${theme.fg(
1766
- "accent",
1767
- sanitizeWorkflowDisplayLine(agent.label),
1768
- )}${
1769
- agent.phase
1770
- ? theme.fg(
1771
- "dim",
1772
- ` (${sanitizeWorkflowDisplayLine(agent.phase)})`,
1773
- )
1774
- : ""
1775
- }${theme.fg(
1776
- "dim",
1777
- `${context ? ` · ${context}` : ""} · ${formatElapsed(agent.startedAt, agent.finishedAt)}`,
1778
- )}`;
1779
- }
1780
- // Only the tail collapsed: the newest lines are the ones that say
1781
- // where the run is now.
1782
- for (const entry of (details.logs ?? []).slice(-3)) {
1783
- text += `\n ${theme.fg("muted", "›")} ${theme.fg(
1784
- "dim",
1785
- sanitizeWorkflowDisplayLine(entry.text),
1786
- )}`;
1787
- }
1788
- if (totals) text += `\n ${theme.fg("dim", `Total: ${totals}`)}`;
1789
- if (details.error)
1790
- text += `\n ${theme.fg(
1791
- "error",
1792
- `Error: ${sanitizeWorkflowDisplayLine(details.error)}`,
1793
- )}`;
1794
- text += `\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`;
1795
- return new Text(text, 0, 0);
1796
- }
1797
-
1798
- const container = new Container();
1799
- container.addChild(new Text(header, 0, 0));
1800
- if (details.description) {
1801
- container.addChild(
1802
- new Text(
1803
- theme.fg("dim", sanitizeWorkflowDisplayLine(details.description)),
1804
- 0,
1805
- 0,
1806
- ),
1807
- );
1808
- }
1809
-
1810
- for (const group of phaseGroups(details)) {
1811
- container.addChild(new Spacer(1));
1812
- container.addChild(
1813
- new Text(
1814
- theme.fg(
1815
- "muted",
1816
- `─── ${sanitizeWorkflowDisplayLine(group.title)} ───`,
1817
- ),
1818
- 0,
1819
- 0,
1820
- ),
1821
- );
1822
- for (const agent of group.agents) {
1823
- const usage = formatUsage(agent.usage, agent.model);
1824
- const context = agentContext(agent);
1825
- let line = `${stateSquare(agent.state, theme)} ${theme.fg(
1826
- "accent",
1827
- sanitizeWorkflowDisplayLine(agent.label),
1828
- )} ${theme.fg(
1829
- "dim",
1830
- [context, formatElapsed(agent.startedAt, agent.finishedAt)]
1831
- .filter(Boolean)
1832
- .join(" · "),
1833
- )}`;
1834
- if (usage)
1835
- line += ` ${theme.fg("dim", sanitizeWorkflowDisplayLine(usage))}`;
1836
- container.addChild(new Text(line, 0, 0));
1837
- if (agent.error) {
1838
- container.addChild(
1839
- new Text(
1840
- ` ${theme.fg("error", sanitizeWorkflowDisplayLine(agent.error))}`,
1841
- 0,
1842
- 0,
1843
- ),
2173
+ return {
2174
+ render(width: number) {
2175
+ const current = currentDetails();
2176
+ const totals = formatUsage(aggregateUsage(current.agents));
2177
+ if (!expanded) {
2178
+ return buildCollapsedRows(
2179
+ current,
2180
+ theme,
2181
+ width,
2182
+ Date.now(),
2183
+ totals,
1844
2184
  );
1845
- } else if (agent.preview) {
1846
- const preview = sanitizeWorkflowDisplayText(
1847
- agent.preview,
1848
- PREVIEW_LENGTH,
1849
- )
1850
- .split("\n")
1851
- .slice(0, 2)
1852
- .join(" ");
1853
- container.addChild(new Text(` ${theme.fg("dim", preview)}`, 0, 0));
1854
2185
  }
1855
- }
1856
- }
1857
-
1858
- if (details.logs && details.logs.length > 0) {
1859
- container.addChild(new Spacer(1));
1860
- container.addChild(new Text(theme.fg("muted", "─── log ───"), 0, 0));
1861
- if (details.logsDropped) {
1862
- container.addChild(
1863
- new Text(
1864
- theme.fg(
1865
- "dim",
1866
- `(${details.logsDropped} earlier line(s) dropped)`,
1867
- ),
1868
- 0,
1869
- 0,
1870
- ),
1871
- );
1872
- }
1873
- for (const entry of details.logs) {
1874
- container.addChild(
1875
- new Text(
1876
- `${theme.fg("muted", "›")} ${theme.fg(
1877
- "dim",
1878
- sanitizeWorkflowDisplayLine(entry.text),
1879
- )}`,
1880
- 0,
1881
- 0,
1882
- ),
1883
- );
1884
- }
1885
- }
1886
-
1887
- if (details.error) {
1888
- container.addChild(new Spacer(1));
1889
- container.addChild(
1890
- new Text(
1891
- theme.fg(
1892
- "error",
1893
- `Error: ${sanitizeWorkflowDisplayLine(details.error)}`,
1894
- ),
1895
- 0,
1896
- 0,
1897
- ),
1898
- );
1899
- }
1900
-
1901
- if (details.result !== undefined) {
1902
- container.addChild(new Spacer(1));
1903
- container.addChild(new Text(theme.fg("muted", "─── result ───"), 0, 0));
1904
- container.addChild(
1905
- new Markdown(
1906
- `\`\`\`json\n${resultJson(details.result)}\n\`\`\``,
1907
- 0,
1908
- 0,
1909
- getMarkdownTheme(),
1910
- ),
1911
- );
1912
- }
1913
-
1914
- if (totals) {
1915
- container.addChild(new Spacer(1));
1916
- container.addChild(new Text(theme.fg("dim", `Total: ${totals}`), 0, 0));
1917
- }
1918
- return container;
2186
+ return buildExpandedWorkflow(
2187
+ current,
2188
+ theme,
2189
+ Date.now(),
2190
+ totals,
2191
+ ).render(width);
2192
+ },
2193
+ invalidate() {},
2194
+ };
1919
2195
  },
1920
2196
  });
1921
2197
 
1922
2198
  /** Resolve one run from live, settled, or persisted state. */
1923
2199
  const resolveRunDetails = (target: string) => {
1924
- const base = path.join(getAgentDir(), "workflows");
1925
- let persistedIds: string[] = [];
1926
- try {
1927
- persistedIds = fs.readdirSync(base).filter(isWorkflowRunId);
1928
- } catch {
1929
- // In-memory runs remain inspectable without the artifact directory.
1930
- }
1931
2200
  const resolution = resolveWorkflowRunTarget(target, [
1932
2201
  ...activeRuns.keys(),
1933
2202
  ...settledRuns.keys(),
1934
- ...persistedIds,
2203
+ ...listPersistedRunIds(),
1935
2204
  ]);
1936
2205
  if (!resolution.ok) return resolution;
1937
2206
 
@@ -1940,31 +2209,22 @@ export default function workflows(pi: ExtensionAPI) {
1940
2209
  const settled = settledRuns.get(resolution.runId);
1941
2210
  if (settled) return { ok: true, details: settled } as const;
1942
2211
 
1943
- try {
1944
- const parsed: unknown = JSON.parse(
1945
- fs.readFileSync(
1946
- path.join(base, resolution.runId, "workflow.json"),
1947
- "utf8",
1948
- ),
1949
- );
1950
- const details = normalizePersistedWorkflowDetails(
1951
- resolution.runId,
1952
- parsed,
1953
- );
1954
- if (!details) throw new Error("invalid workflow details");
1955
- // A run absent from activeRuns cannot still be running this session; a
1956
- // persisted "running" is a run that was hard-killed or missed the
1957
- // shutdown settle deadline.
1958
- return {
1959
- ok: true,
1960
- details: recoverStaleWorkflowDetails(details),
1961
- } as const;
1962
- } catch {
2212
+ const details = readPersistedWorkflowDetails(resolution.runId, {
2213
+ hydrateArtifacts: true,
2214
+ });
2215
+ if (!details) {
1963
2216
  return {
1964
2217
  ok: false,
1965
2218
  error: `Workflow run ${resolution.runId} could not be read.`,
1966
2219
  } as const;
1967
2220
  }
2221
+ // A run absent from activeRuns cannot still be running this session; a
2222
+ // persisted "running" is a run that was hard-killed or missed the
2223
+ // shutdown settle deadline.
2224
+ return {
2225
+ ok: true,
2226
+ details: recoverStaleWorkflowDetails(details),
2227
+ } as const;
1968
2228
  };
1969
2229
 
1970
2230
  pi.registerTool({
@@ -1978,23 +2238,29 @@ export default function workflows(pi: ExtensionAPI) {
1978
2238
  }),
1979
2239
  }),
1980
2240
  execute(_toolCallId, params) {
1981
- const running = [...activeRuns].filter(
1982
- ([, run]) => run.details.status === "running",
1983
- );
1984
- const resolution = resolveWorkflowRunTarget(
1985
- params.runId,
1986
- running.map(([runId]) => runId),
1987
- );
2241
+ const resolution = resolveRunDetails(params.runId);
1988
2242
  if (!resolution.ok) throw new Error(resolution.error);
1989
- stopRun(resolution.runId);
2243
+ const details = resolution.details;
2244
+ if (details.status !== "running") {
2245
+ return Promise.resolve({
2246
+ content: [
2247
+ {
2248
+ type: "text",
2249
+ text: `Workflow ${details.runId} is already ${details.status}.`,
2250
+ },
2251
+ ],
2252
+ details: { runId: details.runId, status: details.status },
2253
+ });
2254
+ }
2255
+ stopRun(details.runId);
1990
2256
  return Promise.resolve({
1991
2257
  content: [
1992
2258
  {
1993
2259
  type: "text",
1994
- text: `Stopping workflow ${resolution.runId}.`,
2260
+ text: `Stopping workflow ${details.runId}.`,
1995
2261
  },
1996
2262
  ],
1997
- details: { runId: resolution.runId, status: "aborting" },
2263
+ details: { runId: details.runId, status: "aborting" },
1998
2264
  });
1999
2265
  },
2000
2266
  });
@@ -2014,13 +2280,14 @@ export default function workflows(pi: ExtensionAPI) {
2014
2280
  // Details are a uniform run-summary array (one entry for a single-id peek)
2015
2281
  // so the tool has a single result shape; the text carries the detail.
2016
2282
  const summarize = (d: WorkflowDetails) => {
2017
- const { done, failed } = countStates(d);
2283
+ const { done, failed, uncertain } = countStates(d);
2018
2284
  return {
2019
2285
  runId: d.runId,
2020
2286
  name: d.name,
2021
2287
  status: d.status,
2022
2288
  done,
2023
2289
  failed,
2290
+ uncertain,
2024
2291
  total: d.agents.length,
2025
2292
  };
2026
2293
  };
@@ -2031,7 +2298,7 @@ export default function workflows(pi: ExtensionAPI) {
2031
2298
  const runDir = path.join(getAgentDir(), "workflows", details.runId);
2032
2299
  return Promise.resolve({
2033
2300
  content: [
2034
- { type: "text", text: buildWorkflowResultMessage(details, runDir) },
2301
+ { type: "text", text: buildWorkflowStatusSummary(details, runDir) },
2035
2302
  ],
2036
2303
  details: { runs: [summarize(details)] },
2037
2304
  });
@@ -2049,8 +2316,8 @@ export default function workflows(pi: ExtensionAPI) {
2049
2316
  });
2050
2317
  }
2051
2318
  const lines = runs.map((d) => {
2052
- const { done, failed } = countStates(d);
2053
- return `${d.runId}${d.name ? ` "${d.name}"` : ""} — ${statusWord(d.status)} · ${done + failed}/${d.agents.length} agents${failed ? `, ${failed} failed` : ""}`;
2319
+ const { done, failed, uncertain } = countStates(d);
2320
+ return `${d.runId}${d.name ? ` "${d.name}"` : ""} — ${statusWord(d.status)} · ${done + failed}/${d.agents.length} agents${failed ? `, ${failed} failed` : ""}${uncertain ? `, ${uncertain} uncertain` : ""}`;
2054
2321
  });
2055
2322
  return Promise.resolve({
2056
2323
  content: [{ type: "text", text: lines.join("\n") }],
@@ -2071,17 +2338,10 @@ export default function workflows(pi: ExtensionAPI) {
2071
2338
  .join("") ?? "");
2072
2339
  const safeBody = sanitizeWorkflowDisplayText(body);
2073
2340
  if (!details) return new Text(safeBody, 0, 0);
2074
- const { done, failed } = countStates(details);
2075
- const settled = done + failed;
2076
- let header =
2077
- `${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` +
2078
- `${theme.fg(
2079
- "accent",
2080
- sanitizeWorkflowDisplayLine(details.name ?? details.runId),
2081
- )} ` +
2082
- theme.fg("dim", `${settled}/${details.agents.length} agents · `) +
2083
- theme.fg(statusColor(details.status), statusWord(details.status));
2084
- if (failed) header += theme.fg("error", ` · ${failed} failed`);
2341
+ const headerParts = runHeader(details, theme, Date.now());
2342
+ const header = headerParts.right
2343
+ ? `${headerParts.left} ${headerParts.right}`
2344
+ : headerParts.left;
2085
2345
  if (expanded) return new Text(`${header}\n\n${safeBody}`, 0, 0);
2086
2346
  const preview = safeBody.split("\n").slice(0, 8).join("\n");
2087
2347
  return new Text(