@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
@@ -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,43 +33,90 @@ 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";
45
- import { formatActivityStatus } from "../shared/activity-status.ts";
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";
51
+ import {
52
+ createStatusWriter,
53
+ formatActivityStatus,
54
+ } from "../shared/activity-status.ts";
55
+ import { fitNavigationSides } from "../shared/below-editor-navigation.ts";
46
56
  import { waitBounded } from "../shared/child-session.ts";
57
+ import { contextPercent } from "../shared/context-utilization.ts";
47
58
  import {
48
59
  registerEditorLayer,
49
60
  removeEditorLayer,
50
61
  } from "../shared/editor-layers.ts";
51
62
  import { loadSetupConfig } from "../shared/setup-config.ts";
63
+ import { SPINNER_INTERVAL_MS } from "../shared/spinner.ts";
52
64
  import {
53
65
  OPENPI_TOOL_SURFACE,
54
66
  patchOwnedTools,
55
67
  } from "../shared/tool-surface.ts";
56
68
  import {
57
- loadAgentTypes,
58
- resolveAgentModel,
59
- roleModelForAgentType,
60
- selectSubagentModel,
61
- } from "../subagents/src/agent-types.ts";
69
+ notifyWebCapabilities,
70
+ projectWorkflowCapability,
71
+ registerWebCapability,
72
+ type WebCapabilityScope,
73
+ } from "../shared/web-observer-registry.ts";
62
74
  import {
63
75
  createWorktree,
64
76
  reclaimWorktree,
65
77
  type Worktree,
66
78
  type WorktreeCleanup,
67
79
  } from "../shared/worktree.ts";
80
+ import {
81
+ loadAgentTypes,
82
+ resolveAgentModel,
83
+ roleModelForAgentType,
84
+ selectSubagentModel,
85
+ } from "../subagents/src/agent-types.ts";
86
+ import {
87
+ acceptanceInstruction,
88
+ acceptanceSchema,
89
+ applyAcceptance,
90
+ parseAcceptanceContract,
91
+ } from "./acceptance.ts";
68
92
  import {
69
93
  createWorkflowPersistence,
70
94
  loadJournal,
95
+ persistWorkflowAgentResult,
96
+ persistWorkflowDeliveryState,
71
97
  persistWorkflowJson,
98
+ persistWorkflowTerminalState,
72
99
  } from "./artifacts.ts";
100
+ import {
101
+ buildExpandedWorkflowCompletion,
102
+ buildWorkflowCompletionDisplay,
103
+ isWorkflowCompletionDisplay,
104
+ workflowCompletionAlerts,
105
+ workflowCompletionResultPreview,
106
+ workflowCompletionSummary,
107
+ } from "./completion-projection.ts";
108
+ import { RunController } from "./controller.ts";
109
+ import {
110
+ resolveWorkflowLaunchMode,
111
+ waitForWorkflowCompletion,
112
+ } from "./coordinator.ts";
113
+ import {
114
+ listPersistedRunIds,
115
+ readPersistedWorkflowDetails,
116
+ recoverStaleWorkflowDetails,
117
+ sessionWorkflowRunIds,
118
+ showWorkflowDashboard,
119
+ } from "./dashboard.ts";
73
120
  import { createWorkflowHandoffRegistry } from "./handoff.ts";
74
121
  import {
75
122
  classifyInterruptedInvocation,
@@ -77,57 +124,60 @@ import {
77
124
  requestInvocation,
78
125
  transitionInvocation,
79
126
  } from "./invocation-ledger.ts";
80
- import {
81
- normalizeWorkflowOperatorKey,
82
- WorkflowOperatorRegistry,
83
- } from "./operator.ts";
84
127
  import {
85
128
  agentCallKey,
129
+ createJournalAccumulator,
86
130
  createReplayCache,
87
- type JournalEntry,
88
131
  type ReplayCache,
89
132
  } from "./journal.ts";
90
- import { RunController } from "./controller.ts";
91
- import {
92
- normalizePersistedWorkflowDetails,
93
- recoverStaleWorkflowDetails,
94
- sessionWorkflowRunIds,
95
- showWorkflowDashboard,
96
- } from "./dashboard.ts";
97
133
  import {
98
134
  extractMeta,
99
135
  prepareWorkflowScript,
100
136
  type WorkflowMeta,
101
137
  } from "./meta.ts";
102
138
  import {
139
+ type AgentRecord,
103
140
  agentContext,
104
141
  aggregateUsage,
105
142
  appendLog,
143
+ compactWorkflowToolDetails,
106
144
  countStates,
145
+ createUsageReader,
107
146
  emptyUsage,
108
147
  formatElapsed,
109
148
  formatUsage,
110
149
  isWorkflowRunId,
111
150
  phaseGroups,
151
+ refreshWorkflowGraph,
112
152
  resolveWorkflowRunTarget,
113
153
  resultJson,
114
154
  sanitizeLine,
115
155
  sanitizeWorkflowDisplayLine,
116
156
  sanitizeWorkflowDisplayText,
117
- stateSquare,
157
+ stateGlyph,
118
158
  statusColor,
159
+ statusGlyph,
119
160
  statusWord,
120
- createUsageReader,
121
- refreshWorkflowGraph,
122
- SQUARE,
123
- type AgentRecord,
124
161
  type WorkflowDetails,
125
162
  } from "./model.ts";
126
163
  import {
127
- buildBackgroundWorkflowFollowUp,
164
+ WorkflowNavigationEditor,
165
+ type WorkflowStripEntry,
166
+ WorkflowStripState,
167
+ WorkflowStripWidget,
168
+ workflowStripEntryKey,
169
+ } from "./navigation.ts";
170
+ import {
171
+ normalizeWorkflowOperatorKey,
172
+ WorkflowOperatorRegistry,
173
+ } from "./operator.ts";
174
+ import {
128
175
  buildBackgroundWorkflowLaunchResult,
176
+ buildProjectedWorkflowCompletionBatches,
177
+ buildProjectedWorkflowResultMessage,
129
178
  buildWorkflowAgentPrompt,
130
179
  buildWorkflowResultMessage,
180
+ buildWorkflowStatusSummary,
131
181
  WORKFLOW_LIFECYCLE_PROMPT_SNIPPET,
132
182
  WORKFLOW_PARAMETER_DESCRIPTIONS,
133
183
  WORKFLOW_PROMPT_GUIDELINES,
@@ -139,39 +189,336 @@ import {
139
189
  WORKFLOW_TOOL_DESCRIPTION,
140
190
  } from "./prompt.ts";
141
191
  import {
142
- WorkflowNavigationEditor,
143
- WorkflowStripState,
144
- WorkflowStripWidget,
145
- type WorkflowStripEntry,
146
- } from "./navigation.ts";
192
+ beginProcessReplayWorkspaceLease,
193
+ createReplayIdentity,
194
+ isReplaySafeAgentCall,
195
+ } from "./replay-safety.ts";
196
+ import {
197
+ createWorkflowResultDelivery,
198
+ type WorkflowCompletionEnvelope,
199
+ } from "./result-delivery.ts";
200
+ import {
201
+ createWorkflowSettledRunRetention,
202
+ projectWorkflowDetails,
203
+ type WorkflowSettledRunRetentionOptions,
204
+ } from "./retention.ts";
147
205
  import {
148
206
  createWorkflowResources,
149
207
  runAgent,
150
208
  type ThinkingLevel,
209
+ type WorkflowAgentSessionFactory,
151
210
  type WorkflowModel,
152
211
  } from "./runner.ts";
153
- import {
154
- beginProcessReplayWorkspaceLease,
155
- createReplayIdentity,
156
- isReplaySafeAgentCall,
157
- } from "./replay-safety.ts";
158
212
  import { runWorkflowSandbox } from "./sandbox.ts";
159
- import {
160
- acceptanceInstruction,
161
- acceptanceSchema,
162
- applyAcceptance,
163
- evaluateAcceptance,
164
- parseAcceptanceContract,
165
- } from "./acceptance.ts";
213
+ import { writeFileAtomic } from "./serialization.ts";
166
214
  import {
167
215
  finalizeWorktreeHandoff,
168
216
  prepareWorktreeHandoff,
169
217
  } from "./worktree-handoff.ts";
170
- import { safeStringify, writeFileAtomic } from "./serialization.ts";
171
218
 
172
219
  const PREVIEW_LENGTH = 200;
173
220
  const EMIT_INTERVAL_MS = 120;
174
221
 
222
+ /** Header of a workflow card: identity and phase on the left, metrics right. */
223
+ function runHeader(
224
+ details: WorkflowDetails,
225
+ theme: Parameters<typeof statusGlyph>[1],
226
+ now: number,
227
+ ) {
228
+ const { done, failed, uncertain } = countStates(details);
229
+ const settled = done + failed;
230
+ const elapsed = formatElapsed(details.startedAt, details.finishedAt, now);
231
+ // A just-launched run has no agents and a 0s clock; the metrics join in
232
+ // once there is something real to report.
233
+ const counts =
234
+ details.agents.length > 0
235
+ ? `${settled}/${details.agents.length} agents${uncertain ? ` · ${uncertain} uncertain` : ""}`
236
+ : undefined;
237
+ const metrics = [counts, counts || elapsed !== "0s" ? elapsed : undefined]
238
+ .filter(Boolean)
239
+ .join(" · ");
240
+ let left =
241
+ `${statusGlyph(details.status, theme, now)} ${theme.fg("toolTitle", theme.bold("workflow "))}` +
242
+ theme.fg(
243
+ "accent",
244
+ sanitizeWorkflowDisplayLine(details.name ?? details.runId),
245
+ );
246
+ if (details.status === "running" && details.currentPhase) {
247
+ left += theme.fg(
248
+ "muted",
249
+ ` · ${sanitizeWorkflowDisplayLine(details.currentPhase)}`,
250
+ );
251
+ }
252
+ // The glyph already carries the run state, so the status word only stays
253
+ // for terminal states.
254
+ let right = theme.fg("dim", metrics);
255
+ if (details.status !== "running") {
256
+ right +=
257
+ theme.fg("dim", `${metrics ? " · " : ""}`) +
258
+ theme.fg(statusColor(details.status), statusWord(details.status));
259
+ }
260
+ if (failed) right += theme.fg("error", ` · ${failed} failed`);
261
+ return { left, right };
262
+ }
263
+
264
+ /**
265
+ * Collapsed workflow card, rebuilt per repaint. Metrics right-align like the
266
+ * below-editor strip, and each agent row carries one number only: context
267
+ * occupancy says a running agent is alive, elapsed says what a settled one
268
+ * cost. A swarm shows the first few agents and summarizes the rest instead
269
+ * of flooding the chat.
270
+ */
271
+ function buildCollapsedRows(
272
+ details: WorkflowDetails,
273
+ theme: Parameters<typeof statusGlyph>[1],
274
+ width: number,
275
+ now: number,
276
+ totals: string,
277
+ ) {
278
+ const header = runHeader(details, theme, now);
279
+ const rows = [fitNavigationSides(header.left, header.right, width)];
280
+ const collapsedAgents = details.agents.slice(0, 8);
281
+ for (const agent of collapsedAgents) {
282
+ const percent = contextPercent({
283
+ tokens: agent.usage.contextTokens,
284
+ contextWindow: agent.contextWindow,
285
+ });
286
+ const stat =
287
+ agent.state === "running"
288
+ ? percent === undefined
289
+ ? undefined
290
+ : `${percent}%`
291
+ : formatElapsed(agent.startedAt, agent.finishedAt, now);
292
+ const left = ` ${stateGlyph(agent.state, theme, now)} ${theme.fg(
293
+ "accent",
294
+ sanitizeWorkflowDisplayLine(agent.label),
295
+ )}`;
296
+ rows.push(
297
+ stat
298
+ ? fitNavigationSides(left, theme.fg("dim", stat), width)
299
+ : truncateToWidth(left, width, "…"),
300
+ );
301
+ }
302
+ const hiddenAgents = details.agents.length - collapsedAgents.length;
303
+ if (hiddenAgents > 0) {
304
+ rows.push(` ${theme.fg("dim", `… ${hiddenAgents} more`)}`);
305
+ }
306
+ // Only the tail collapsed: the newest lines are the ones that say where
307
+ // the run is now.
308
+ for (const entry of (details.logs ?? []).slice(-3)) {
309
+ rows.push(
310
+ truncateToWidth(
311
+ ` ${theme.fg("muted", "›")} ${theme.fg(
312
+ "dim",
313
+ sanitizeWorkflowDisplayLine(entry.text),
314
+ )}`,
315
+ width,
316
+ "…",
317
+ ),
318
+ );
319
+ }
320
+ if (totals) rows.push(` ${theme.fg("dim", `Total: ${totals}`)}`);
321
+ if (details.error) {
322
+ rows.push(
323
+ truncateToWidth(
324
+ ` ${theme.fg(
325
+ "error",
326
+ `Error: ${sanitizeWorkflowDisplayLine(details.error)}`,
327
+ )}`,
328
+ width,
329
+ "…",
330
+ ),
331
+ );
332
+ }
333
+ // Expanding only earns its hint when there is more to see.
334
+ if (
335
+ details.agents.length > 0 ||
336
+ (details.logs ?? []).length > 0 ||
337
+ details.description ||
338
+ details.result !== undefined ||
339
+ details.error
340
+ ) {
341
+ rows.push(
342
+ theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`),
343
+ );
344
+ }
345
+ return rows;
346
+ }
347
+
348
+ function buildExpandedWorkflow(
349
+ details: WorkflowDetails,
350
+ theme: Parameters<typeof statusGlyph>[1],
351
+ now: number,
352
+ totals: string,
353
+ ) {
354
+ const header = runHeader(details, theme, now);
355
+ const container = new Container();
356
+ container.addChild(
357
+ new Text(
358
+ header.right ? `${header.left} ${header.right}` : header.left,
359
+ 0,
360
+ 0,
361
+ ),
362
+ );
363
+ if (details.description) {
364
+ container.addChild(
365
+ new Text(
366
+ theme.fg("dim", sanitizeWorkflowDisplayLine(details.description)),
367
+ 0,
368
+ 0,
369
+ ),
370
+ );
371
+ }
372
+
373
+ for (const group of phaseGroups(details)) {
374
+ container.addChild(new Spacer(1));
375
+ container.addChild(
376
+ new Text(
377
+ theme.fg(
378
+ "muted",
379
+ `─── ${sanitizeWorkflowDisplayLine(group.title)} ───`,
380
+ ),
381
+ 0,
382
+ 0,
383
+ ),
384
+ );
385
+ for (const agent of group.agents) {
386
+ const usage = formatUsage(agent.usage, agent.model);
387
+ const context = agentContext(agent);
388
+ let line = `${stateGlyph(agent.state, theme, now)} ${theme.fg(
389
+ "accent",
390
+ sanitizeWorkflowDisplayLine(agent.label),
391
+ )} ${theme.fg(
392
+ "dim",
393
+ [context, formatElapsed(agent.startedAt, agent.finishedAt, now)]
394
+ .filter(Boolean)
395
+ .join(" · "),
396
+ )}`;
397
+ if (usage)
398
+ line += ` ${theme.fg("dim", sanitizeWorkflowDisplayLine(usage))}`;
399
+ container.addChild(new Text(line, 0, 0));
400
+ if (agent.error) {
401
+ container.addChild(
402
+ new Text(
403
+ ` ${theme.fg("error", sanitizeWorkflowDisplayLine(agent.error))}`,
404
+ 0,
405
+ 0,
406
+ ),
407
+ );
408
+ } else if (agent.preview) {
409
+ const preview = sanitizeWorkflowDisplayText(
410
+ agent.preview,
411
+ PREVIEW_LENGTH,
412
+ )
413
+ .split("\n")
414
+ .slice(0, 2)
415
+ .join(" ");
416
+ container.addChild(new Text(` ${theme.fg("dim", preview)}`, 0, 0));
417
+ }
418
+ }
419
+ }
420
+
421
+ if (details.logs && details.logs.length > 0) {
422
+ container.addChild(new Spacer(1));
423
+ container.addChild(new Text(theme.fg("muted", "─── log ───"), 0, 0));
424
+ if (details.logsDropped) {
425
+ container.addChild(
426
+ new Text(
427
+ theme.fg("dim", `(${details.logsDropped} earlier line(s) dropped)`),
428
+ 0,
429
+ 0,
430
+ ),
431
+ );
432
+ }
433
+ for (const entry of details.logs) {
434
+ container.addChild(
435
+ new Text(
436
+ `${theme.fg("muted", "›")} ${theme.fg(
437
+ "dim",
438
+ sanitizeWorkflowDisplayLine(entry.text),
439
+ )}`,
440
+ 0,
441
+ 0,
442
+ ),
443
+ );
444
+ }
445
+ }
446
+
447
+ if (details.error) {
448
+ container.addChild(new Spacer(1));
449
+ container.addChild(
450
+ new Text(
451
+ theme.fg(
452
+ "error",
453
+ `Error: ${sanitizeWorkflowDisplayLine(details.error)}`,
454
+ ),
455
+ 0,
456
+ 0,
457
+ ),
458
+ );
459
+ }
460
+
461
+ if (details.result !== undefined) {
462
+ container.addChild(new Spacer(1));
463
+ container.addChild(new Text(theme.fg("muted", "─── result ───"), 0, 0));
464
+ container.addChild(
465
+ new Markdown(
466
+ `\`\`\`json\n${resultJson(details.result)}\n\`\`\``,
467
+ 0,
468
+ 0,
469
+ getMarkdownTheme(),
470
+ ),
471
+ );
472
+ }
473
+
474
+ if (totals) {
475
+ container.addChild(new Spacer(1));
476
+ container.addChild(new Text(theme.fg("dim", `Total: ${totals}`), 0, 0));
477
+ }
478
+ return container;
479
+ }
480
+
481
+ interface WorkflowRenderState {
482
+ spinnerTimer?: ReturnType<typeof setInterval>;
483
+ }
484
+
485
+ function syncWorkflowSpinner(
486
+ state: WorkflowRenderState,
487
+ isRunning: () => boolean,
488
+ invalidate: () => void,
489
+ ) {
490
+ if (!isRunning()) {
491
+ if (state.spinnerTimer) clearInterval(state.spinnerTimer);
492
+ state.spinnerTimer = undefined;
493
+ return;
494
+ }
495
+ if (state.spinnerTimer) return;
496
+ const spinnerTimer = setInterval(() => {
497
+ if (state.spinnerTimer !== spinnerTimer) return;
498
+ if (!isRunning()) {
499
+ clearInterval(spinnerTimer);
500
+ state.spinnerTimer = undefined;
501
+ }
502
+ invalidate();
503
+ }, SPINNER_INTERVAL_MS);
504
+ state.spinnerTimer = spinnerTimer;
505
+ spinnerTimer.unref?.();
506
+ }
507
+
508
+ /**
509
+ * Test-only injection seam for execute-level tests: production never sets it.
510
+ * The underscore-prefixed setter name makes any accidental production use
511
+ * self-evidently wrong.
512
+ */
513
+ let testAgentSessionFactory: WorkflowAgentSessionFactory | undefined;
514
+
515
+ /** Test-only: override how workflow children create their agent sessions. */
516
+ export function __setWorkflowTestAgentSessionFactory(
517
+ factory: WorkflowAgentSessionFactory | undefined,
518
+ ) {
519
+ testAgentSessionFactory = factory;
520
+ }
521
+
175
522
  const THINKING_LEVELS = [
176
523
  "off",
177
524
  "minimal",
@@ -209,26 +556,35 @@ interface AgentCallOptions {
209
556
  inputs?: unknown;
210
557
  }
211
558
 
212
- const WorkflowParams = Type.Object({
213
- script: Type.String({
214
- description: WORKFLOW_PARAMETER_DESCRIPTIONS.script,
215
- }),
216
- args: Type.Optional(
217
- Type.String({
218
- description: WORKFLOW_PARAMETER_DESCRIPTIONS.args,
219
- }),
220
- ),
221
- background: Type.Optional(
222
- Type.Boolean({
223
- description: WORKFLOW_PARAMETER_DESCRIPTIONS.background,
559
+ const WorkflowParams = Type.Object(
560
+ {
561
+ script: Type.String({
562
+ description: WORKFLOW_PARAMETER_DESCRIPTIONS.script,
224
563
  }),
225
- ),
226
- resume_from_run_id: Type.Optional(
227
- Type.String({
228
- description: WORKFLOW_PARAMETER_DESCRIPTIONS.resumeFromRunId,
229
- }),
230
- ),
231
- });
564
+ args: Type.Optional(
565
+ Type.String({
566
+ description: WORKFLOW_PARAMETER_DESCRIPTIONS.args,
567
+ }),
568
+ ),
569
+ background: Type.Optional(
570
+ Type.Boolean({
571
+ deprecated: true,
572
+ description: WORKFLOW_PARAMETER_DESCRIPTIONS.background,
573
+ }),
574
+ ),
575
+ wait: Type.Optional(
576
+ Type.Boolean({
577
+ description: WORKFLOW_PARAMETER_DESCRIPTIONS.wait,
578
+ }),
579
+ ),
580
+ resume_from_run_id: Type.Optional(
581
+ Type.String({
582
+ description: WORKFLOW_PARAMETER_DESCRIPTIONS.resumeFromRunId,
583
+ }),
584
+ ),
585
+ },
586
+ { additionalProperties: false },
587
+ );
232
588
 
233
589
  type WorkflowInput = Static<typeof WorkflowParams>;
234
590
 
@@ -266,13 +622,32 @@ function errorText(error: unknown): string {
266
622
  );
267
623
  }
268
624
 
625
+ function isWorkflowRenderDetails(value: unknown): value is WorkflowDetails {
626
+ if (!value || typeof value !== "object") return false;
627
+ const details = value as Partial<WorkflowDetails>;
628
+ return (
629
+ typeof details.runId === "string" &&
630
+ details.runId.length > 0 &&
631
+ typeof details.background === "boolean" &&
632
+ (details.status === "running" ||
633
+ details.status === "completed" ||
634
+ details.status === "failed" ||
635
+ details.status === "aborted" ||
636
+ details.status === "uncertain") &&
637
+ typeof details.startedAt === "number" &&
638
+ Number.isFinite(details.startedAt) &&
639
+ Array.isArray(details.phases) &&
640
+ Array.isArray(details.agents)
641
+ );
642
+ }
643
+
269
644
  function summaryLine(details: WorkflowDetails): string {
270
- const { done, failed } = countStates(details);
645
+ const { done, failed, uncertain } = countStates(details);
271
646
  const settled = done + failed;
272
647
  // The newest narrator line beats the phase title when there is one: the
273
648
  // script wrote it precisely because it says more than the phase does.
274
649
  const latest = details.logs?.[details.logs.length - 1]?.text;
275
- return `workflow ${details.name ?? details.runId}: ${settled}/${details.agents.length} agents${
650
+ return `workflow ${details.name ?? details.runId}: ${settled}/${details.agents.length} agents${uncertain ? ` · ${uncertain} uncertain` : ""}${
276
651
  latest
277
652
  ? ` · ${latest}`
278
653
  : details.currentPhase
@@ -285,20 +660,16 @@ function writeRunFile(runDir: string, name: string, content: string) {
285
660
  writeFileAtomic(path.join(runDir, name), content);
286
661
  }
287
662
 
288
- function compactToolDetails(details: WorkflowDetails): WorkflowDetails {
289
- return {
290
- ...details,
291
- ...(details.result !== undefined
292
- ? {
293
- result: JSON.parse(
294
- safeStringify(details.result, { maxBytes: 64 * 1024 }),
295
- ),
296
- }
297
- : {}),
298
- agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
299
- };
663
+ function appendArtifactPersistenceFailure(
664
+ details: WorkflowDetails,
665
+ error: unknown,
666
+ ) {
667
+ const persistenceFailure = `Artifact persistence failed: ${errorText(error)}`;
668
+ if (details.status !== "aborted") details.status = "failed";
669
+ details.error = details.error
670
+ ? `${details.error}; ${persistenceFailure}`
671
+ : persistenceFailure;
300
672
  }
301
-
302
673
  export interface ActiveWorkflowRunLifecycle {
303
674
  details: WorkflowDetails;
304
675
  controller: Pick<RunController, "abort" | "settle">;
@@ -306,6 +677,21 @@ export interface ActiveWorkflowRunLifecycle {
306
677
  forceSettle(error: string): void;
307
678
  }
308
679
 
680
+ interface WorkflowLifecycleTestHooks {
681
+ readonly persistWorkflow?: typeof persistWorkflowJson;
682
+ readonly reclaimWorktree?: typeof reclaimWorktree;
683
+ readonly onRunStarted?: (run: ActiveWorkflowRunLifecycle) => void;
684
+ }
685
+
686
+ let workflowLifecycleTestHooks: WorkflowLifecycleTestHooks | undefined;
687
+
688
+ /** Test-only control for deterministic lifecycle race coverage. */
689
+ export function __setWorkflowTestLifecycleHooks(
690
+ hooks: WorkflowLifecycleTestHooks | undefined,
691
+ ) {
692
+ workflowLifecycleTestHooks = hooks;
693
+ }
694
+
309
695
  /** Abort every live child and bound the whole session-shutdown barrier once. */
310
696
  export async function shutdownActiveWorkflowRuns(
311
697
  runs: readonly ActiveWorkflowRunLifecycle[],
@@ -348,15 +734,8 @@ function listRuns(
348
734
  referencedRunIds: ReadonlySet<string>,
349
735
  startedSince = 0,
350
736
  ): 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
737
  const summaries: RunSummary[] = [];
359
- for (const runId of names) {
738
+ for (const runId of listPersistedRunIds()) {
360
739
  const live = activeRuns.get(runId);
361
740
  if (live) {
362
741
  const { done, failed } = countStates(live);
@@ -371,34 +750,30 @@ function listRuns(
371
750
  });
372
751
  continue;
373
752
  }
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.
753
+ // Same reader as the dashboard and workflow_status: old-format runs are
754
+ // normalized here too, so every surface reports one status per run.
755
+ const details = readPersistedWorkflowDetails(runId, {
756
+ hydrateArtifacts: false,
757
+ });
758
+ if (!details) continue;
759
+ const touchedAt = Math.max(details.startedAt, details.finishedAt ?? 0);
760
+ if (
761
+ touchedAt < startedSince ||
762
+ (details.sessionId !== sessionId && !referencedRunIds.has(runId))
763
+ ) {
764
+ continue;
401
765
  }
766
+ recoverStaleWorkflowDetails(details);
767
+ const { done, failed } = countStates(details);
768
+ summaries.push({
769
+ runId,
770
+ name: details.name,
771
+ status: details.status,
772
+ done: done + failed,
773
+ total: details.agents.length,
774
+ startedAt: details.startedAt,
775
+ active: false,
776
+ });
402
777
  }
403
778
  return summaries.sort((a, b) => b.startedAt - a.startedAt);
404
779
  }
@@ -410,33 +785,49 @@ function runDetailText(
410
785
  const runDir = path.join(getAgentDir(), "workflows", run.runId);
411
786
  const live = activeRuns.get(run.runId);
412
787
  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
- }
788
+ const details = readPersistedWorkflowDetails(run.runId, {
789
+ hydrateArtifacts: true,
790
+ });
791
+ if (details)
792
+ return buildWorkflowResultMessage(
793
+ recoverStaleWorkflowDetails(details),
794
+ runDir,
795
+ );
796
+ return `Run ${run.runId} — ${run.status}`;
421
797
  }
422
798
 
423
- export default function workflows(pi: ExtensionAPI) {
799
+ export interface WorkflowExtensionOptions {
800
+ /** Test/configuration seam for the settled session-memory projection. */
801
+ readonly settledRetention?: WorkflowSettledRunRetentionOptions;
802
+ }
803
+
804
+ const WORKFLOW_DELIVERY_DETAILS_MAX_BYTES = 128 * 1024;
805
+
806
+ export default function workflows(
807
+ pi: ExtensionAPI,
808
+ options: WorkflowExtensionOptions = {},
809
+ ) {
424
810
  /** Live background runs, for /workflows and shutdown cleanup. */
425
811
  const activeRuns = new Map<string, ActiveWorkflowRunLifecycle>();
812
+ let unregisterWebCapability: (() => void) | undefined;
813
+ let webCapabilityScope: WebCapabilityScope | undefined;
426
814
  const activeDetails = () =>
427
815
  new Map(
428
816
  [...activeRuns].map(([runId, run]) => [runId, run.details] as const),
429
817
  );
430
- const settledRuns = new Map<string, WorkflowDetails>();
431
- const hideLifecycleTools = () =>
432
- patchOwnedTools(pi, "workflows", {
433
- disable: OPENPI_TOOL_SURFACE.workflows.deferred,
434
- });
435
- const showLifecycleTools = () =>
818
+ const settledRuns = createWorkflowSettledRunRetention(
819
+ options.settledRetention,
820
+ );
821
+ /** Disk remains canonical; retained projections cover transient read failures. */
822
+ const dashboardDetails = () => activeDetails();
823
+ const dashboardRetainedDetails = () =>
824
+ new Map<string, WorkflowDetails>(settledRuns.entriesArray());
825
+ const registerStableToolFamily = () =>
436
826
  patchOwnedTools(pi, "workflows", {
437
- enable: OPENPI_TOOL_SURFACE.workflows.deferred,
827
+ enable: OPENPI_TOOL_SURFACE.workflows.entry,
438
828
  });
439
829
  const stripState = new WorkflowStripState();
830
+ const statusWriter = createStatusWriter("workflows");
440
831
  const widgetKey = "workflow-navigation";
441
832
 
442
833
  /**
@@ -444,9 +835,76 @@ export default function workflows(pi: ExtensionAPI) {
444
835
  * next explicit request acknowledges them.
445
836
  */
446
837
  let lastContext: ExtensionContext | undefined;
838
+ const completionEnvelope = (
839
+ details: WorkflowDetails,
840
+ ): WorkflowCompletionEnvelope => {
841
+ const deliveryId = details.delivery?.id;
842
+ if (!deliveryId) throw new Error("Workflow delivery identity is missing");
843
+ const projection = projectWorkflowDetails(
844
+ details,
845
+ WORKFLOW_DELIVERY_DETAILS_MAX_BYTES,
846
+ );
847
+ if (!projection) {
848
+ throw new Error(
849
+ `Workflow ${details.runId} cannot create a bounded completion projection`,
850
+ );
851
+ }
852
+ return {
853
+ deliveryId,
854
+ runId: details.runId,
855
+ details: projection,
856
+ };
857
+ };
858
+ const resultDelivery = createWorkflowResultDelivery({
859
+ isIdle: () => lastContext?.isIdle() ?? false,
860
+ persist: (details) => {
861
+ if (!details.delivery)
862
+ throw new Error("Workflow delivery identity is missing");
863
+ persistWorkflowDeliveryState(
864
+ path.join(getAgentDir(), "workflows", details.runId),
865
+ details.delivery,
866
+ );
867
+ },
868
+ deliver: async (envelopes, wake) => {
869
+ const hydrated = envelopes.map((envelope) => ({
870
+ ...envelope,
871
+ details:
872
+ readPersistedWorkflowDetails(envelope.runId, {
873
+ hydrateArtifacts: true,
874
+ }) ?? envelope.details,
875
+ }));
876
+ const sourceEntries = hydrated.map((envelope) => ({
877
+ deliveryId: envelope.deliveryId,
878
+ details: envelope.details,
879
+ runDir: path.join(getAgentDir(), "workflows", envelope.runId),
880
+ }));
881
+ const batches = buildProjectedWorkflowCompletionBatches(
882
+ sourceEntries,
883
+ lastContext?.getContextUsage?.(),
884
+ );
885
+ for (const batch of batches) {
886
+ pi.sendMessage(
887
+ {
888
+ customType: "workflow-result",
889
+ content: batch.content,
890
+ display: true,
891
+ details: buildWorkflowCompletionDisplay(batch.entries),
892
+ },
893
+ wake
894
+ ? { deliverAs: "followUp", triggerTurn: true }
895
+ : { deliverAs: "nextTurn" },
896
+ );
897
+ }
898
+ return envelopes.map((envelope) => ({
899
+ deliveryId: envelope.deliveryId,
900
+ delivered: true,
901
+ }));
902
+ },
903
+ });
447
904
  let completedRuns = 0;
448
905
  let failedRuns = 0;
449
906
  let widgetVisible = false;
907
+ let widgetEntryKey: string | undefined;
450
908
  let requestWidgetRender: (() => void) | undefined;
451
909
  let navigationLayerRegistered = false;
452
910
  let dashboardOpen = false;
@@ -477,16 +935,25 @@ export default function workflows(pi: ExtensionAPI) {
477
935
  const running = newestEntry(
478
936
  [...activeRuns].map(([runId, run]) => [runId, run.details] as const),
479
937
  );
480
- return running ?? newestEntry(settledRuns);
938
+ return running ?? newestEntry(settledRuns.entriesArray());
481
939
  };
482
940
 
483
941
  const updateWorkflowWidget = () => {
484
942
  const ctx = lastContext;
485
943
  if (!ctx || ctx.mode !== "tui") return;
486
- const visible = Boolean(stripEntry());
487
- if (visible === widgetVisible) return;
944
+ const entry = stripEntry();
945
+ const visible = Boolean(entry);
946
+ const entryKey = workflowStripEntryKey(entry);
947
+ if (visible === widgetVisible) {
948
+ if (visible && entryKey !== widgetEntryKey) {
949
+ widgetEntryKey = entryKey;
950
+ requestWidgetRender?.();
951
+ }
952
+ return;
953
+ }
488
954
  if (!visible) {
489
955
  stripState.focused = false;
956
+ widgetEntryKey = undefined;
490
957
  requestWidgetRender = undefined;
491
958
  ctx.ui.setWidget(widgetKey, undefined);
492
959
  widgetVisible = false;
@@ -501,25 +968,25 @@ export default function workflows(pi: ExtensionAPI) {
501
968
  { placement: "belowEditor" },
502
969
  );
503
970
  widgetVisible = true;
971
+ widgetEntryKey = entryKey;
504
972
  };
505
973
 
506
974
  const updateIndicator = () => {
975
+ if (webCapabilityScope) notifyWebCapabilities(webCapabilityScope);
507
976
  const ctx = lastContext;
508
977
  if (!ctx) return;
509
978
  try {
510
979
  const running = activeRuns.size;
511
- if (running === 0 && completedRuns === 0 && failedRuns === 0) {
512
- ctx.ui.setStatus("workflows", undefined);
513
- } else {
514
- ctx.ui.setStatus(
515
- "workflows",
516
- formatActivityStatus(ctx.ui.theme, "workflows", {
517
- running,
518
- done: completedRuns,
519
- failed: failedRuns,
520
- }),
521
- );
522
- }
980
+ statusWriter.write(
981
+ ctx.ui,
982
+ running === 0 && completedRuns === 0 && failedRuns === 0
983
+ ? undefined
984
+ : formatActivityStatus(ctx.ui.theme, "workflows", {
985
+ running,
986
+ done: completedRuns,
987
+ failed: failedRuns,
988
+ }),
989
+ );
523
990
  updateWorkflowWidget();
524
991
  } catch {
525
992
  // UI may be unavailable.
@@ -533,7 +1000,7 @@ export default function workflows(pi: ExtensionAPI) {
533
1000
  };
534
1001
 
535
1002
  const recordSettledRun = (details: WorkflowDetails) => {
536
- settledRuns.set(details.runId, details);
1003
+ settledRuns.set(details);
537
1004
  if (details.status === "completed") completedRuns += 1;
538
1005
  else failedRuns += 1;
539
1006
  };
@@ -556,10 +1023,11 @@ export default function workflows(pi: ExtensionAPI) {
556
1023
  try {
557
1024
  await showWorkflowDashboard(
558
1025
  ctx,
559
- activeDetails,
1026
+ dashboardDetails,
560
1027
  initialRunId,
561
1028
  startedSince,
562
1029
  stopRun,
1030
+ dashboardRetainedDetails,
563
1031
  );
564
1032
  acknowledgeSettledRuns();
565
1033
  } finally {
@@ -593,7 +1061,21 @@ export default function workflows(pi: ExtensionAPI) {
593
1061
  };
594
1062
 
595
1063
  pi.on("session_start", (_event, ctx) => {
596
- hideLifecycleTools();
1064
+ unregisterWebCapability?.();
1065
+ const scope = ctx.sessionManager;
1066
+ webCapabilityScope = scope;
1067
+ unregisterWebCapability = registerWebCapability(scope, {
1068
+ kind: "workflows",
1069
+ snapshot: () =>
1070
+ projectWorkflowCapability([
1071
+ ...activeDetails().values(),
1072
+ ...settledRuns
1073
+ .entriesArray()
1074
+ .filter(([runId]) => !activeRuns.has(runId))
1075
+ .map(([, details]) => details),
1076
+ ]),
1077
+ });
1078
+ registerStableToolFamily();
597
1079
  if (ctx.hasUI) lastContext = ctx;
598
1080
  agentTypes = loadAgentTypes({
599
1081
  agentDir: getAgentDir(),
@@ -603,9 +1085,38 @@ export default function workflows(pi: ExtensionAPI) {
603
1085
  turnStartedAt = 0;
604
1086
  completedRuns = 0;
605
1087
  failedRuns = 0;
606
- settledRuns.clear();
1088
+ settledRuns.resetSession();
607
1089
  installWorkflowNavigation(ctx);
608
1090
  updateIndicator();
1091
+
1092
+ const sessionId = ctx.sessionManager.getSessionId();
1093
+ for (const runId of listPersistedRunIds()) {
1094
+ const details = readPersistedWorkflowDetails(runId, {
1095
+ hydrateArtifacts: true,
1096
+ });
1097
+ if (!details || details.sessionId !== sessionId) {
1098
+ continue;
1099
+ }
1100
+ const wasRunning = details.status === "running";
1101
+ if (wasRunning) {
1102
+ recoverStaleWorkflowDetails(details);
1103
+ persistWorkflowJson(
1104
+ path.join(getAgentDir(), "workflows", details.runId),
1105
+ details,
1106
+ );
1107
+ }
1108
+ // Pre-V2 terminal runs have no delivery receipt. They may already have
1109
+ // been shown, so replaying them on upgrade would be a surprising
1110
+ // duplicate. Only migrate owner-lost runs, whose uncertainty matters.
1111
+ if (details.delivery) {
1112
+ resultDelivery.restore(completionEnvelope(details));
1113
+ }
1114
+ }
1115
+ void resultDelivery.flushIfIdle();
1116
+ });
1117
+
1118
+ pi.on("agent_settled", () => {
1119
+ void resultDelivery.parentSettled();
609
1120
  });
610
1121
 
611
1122
  pi.on("input", (event) => {
@@ -621,14 +1132,23 @@ export default function workflows(pi: ExtensionAPI) {
621
1132
  navigationLayerRegistered = false;
622
1133
  }
623
1134
  await shutdownActiveWorkflowRuns([...activeRuns.values()]);
1135
+ // Give deferred completions one final delivery attempt. Failed sends stay
1136
+ // durably pending; clearing first would discard an envelope whose initial
1137
+ // persistence may have failed.
1138
+ await resultDelivery.parentSettled();
624
1139
  try {
625
1140
  lastContext?.ui.setStatus("workflows", undefined);
626
1141
  lastContext?.ui.setWidget(widgetKey, undefined);
627
1142
  } catch {
628
1143
  // UI may already be disposed.
629
1144
  }
1145
+ statusWriter.reset();
1146
+ unregisterWebCapability?.();
1147
+ unregisterWebCapability = undefined;
1148
+ webCapabilityScope = undefined;
630
1149
  lastContext = undefined;
631
1150
  widgetVisible = false;
1151
+ widgetEntryKey = undefined;
632
1152
  requestWidgetRender = undefined;
633
1153
  stripState.focused = false;
634
1154
  });
@@ -736,7 +1256,13 @@ export default function workflows(pi: ExtensionAPI) {
736
1256
  const meta = prepared.meta;
737
1257
  const runId = `wf_${randomBytes(6).toString("hex")}`;
738
1258
  const runDir = path.join(getAgentDir(), "workflows", runId);
739
- const background = (params.background ?? false) && ctx.hasUI;
1259
+ const canDeliverLater = ctx.hasUI && ctx.mode === "tui";
1260
+ const launchMode = resolveWorkflowLaunchMode(
1261
+ { wait: params.wait, background: params.background },
1262
+ canDeliverLater,
1263
+ );
1264
+ const background = launchMode === "detached";
1265
+ const now = Date.now();
740
1266
 
741
1267
  const details: WorkflowDetails = {
742
1268
  runId,
@@ -745,15 +1271,21 @@ export default function workflows(pi: ExtensionAPI) {
745
1271
  description: meta.description,
746
1272
  background,
747
1273
  status: "running",
748
- startedAt: Date.now(),
1274
+ startedAt: now,
749
1275
  phases: [...meta.phases],
750
1276
  agents: [],
1277
+ delivery: {
1278
+ id: `workflow:${runId}:terminal`,
1279
+ state: launchMode === "inline" ? "held-for-inline" : "none",
1280
+ attempts: 0,
1281
+ updatedAt: now,
1282
+ },
751
1283
  };
752
1284
 
753
1285
  // Resume: replay cached results for calls whose content is unchanged.
754
1286
  // A missing or unreadable source degrades to a normal full run — resume
755
1287
  // is an optimization and must not become a new way to fail.
756
- const journalEntries: JournalEntry[] = [];
1288
+ const journal = createJournalAccumulator();
757
1289
  let replay: ReplayCache | undefined;
758
1290
  if (params.resume_from_run_id) {
759
1291
  const source = resolveRunDir(params.resume_from_run_id);
@@ -773,16 +1305,19 @@ export default function workflows(pi: ExtensionAPI) {
773
1305
  writeRunFile(runDir, "args.json", params.args);
774
1306
  persistWorkflowJson(runDir, details);
775
1307
  const persistence = createWorkflowPersistence(runDir, details, {
776
- journal: () => journalEntries,
1308
+ journal: () => journal,
1309
+ ...(workflowLifecycleTestHooks?.persistWorkflow
1310
+ ? { persist: workflowLifecycleTestHooks.persistWorkflow }
1311
+ : {}),
777
1312
  });
778
1313
 
779
- // Background runs survive Esc on the parent turn, but all runs are
780
- // aborted and settled during session shutdown.
1314
+ // A caller wait never owns the run. All runs survive an interrupted
1315
+ // launch turn and are aborted only by workflow_stop/session shutdown.
781
1316
  const workflowConfig = loadSetupConfig().workflows;
782
1317
  const projectTrusted = ctx.isProjectTrusted();
783
1318
  const runAgentTypes = agentTypes;
784
1319
  const controller = new RunController(
785
- background ? undefined : signal,
1320
+ undefined,
786
1321
  workflowConfig.concurrency,
787
1322
  workflowConfig.maxAgentCalls,
788
1323
  );
@@ -817,7 +1352,7 @@ export default function workflows(pi: ExtensionAPI) {
817
1352
  if (background) return;
818
1353
  onUpdate?.({
819
1354
  content: [{ type: "text", text: summaryLine(details) }],
820
- details: compactToolDetails(details),
1355
+ details: compactWorkflowToolDetails(details),
821
1356
  });
822
1357
  };
823
1358
  const emit = (checkpoint = true) => {
@@ -835,6 +1370,15 @@ export default function workflows(pi: ExtensionAPI) {
835
1370
  flush(terminal);
836
1371
  };
837
1372
 
1373
+ const persistTerminalRecovery = () => {
1374
+ try {
1375
+ persistWorkflowTerminalState(runDir, details);
1376
+ } catch {
1377
+ // The original persistence error remains authoritative; restart
1378
+ // reconciliation handles the remaining uncertainty.
1379
+ }
1380
+ };
1381
+
838
1382
  const terminalize = (
839
1383
  status: WorkflowDetails["status"],
840
1384
  error?: string,
@@ -870,6 +1414,13 @@ export default function workflows(pi: ExtensionAPI) {
870
1414
  }
871
1415
  details.status = status;
872
1416
  details.finishedAt = Date.now();
1417
+ if (details.delivery && background) {
1418
+ details.delivery = {
1419
+ ...details.delivery,
1420
+ state: "pending",
1421
+ updatedAt: details.finishedAt,
1422
+ };
1423
+ }
873
1424
  refreshWorkflowGraph(details);
874
1425
  if (error) details.error = sanitizeWorkflowDisplayLine(error);
875
1426
  return true;
@@ -880,7 +1431,8 @@ export default function workflows(pi: ExtensionAPI) {
880
1431
  try {
881
1432
  persistence.flush();
882
1433
  } catch (persistenceError) {
883
- details.error = `${error}; artifact persistence failed: ${errorText(persistenceError)}`;
1434
+ appendArtifactPersistenceFailure(details, persistenceError);
1435
+ persistTerminalRecovery();
884
1436
  }
885
1437
  flushNow(true);
886
1438
  };
@@ -897,15 +1449,19 @@ export default function workflows(pi: ExtensionAPI) {
897
1449
 
898
1450
  // The script's narrator. Unlike phase(), this is append-only progress
899
1451
  // text, so it never mutates the phase list a run is judged against.
900
- const logFn = (text: string) => {
1452
+ const logFn = (text: string, kind?: "pipeline-drop") => {
901
1453
  if (runSettled) return;
902
- appendLog(details, text, Date.now());
1454
+ appendLog(details, text, Date.now(), kind);
903
1455
  emit();
904
1456
  };
905
1457
 
906
1458
  // One reader per run: it carries a high-water mark, because per-agent
907
1459
  // usage is recomputed from a message list that compaction shrinks.
908
- const readUsage = createUsageReader(details.agents);
1460
+ const readBaseUsage = createUsageReader(details.agents);
1461
+ const readUsage = () => ({
1462
+ ...readBaseUsage(),
1463
+ limits: controller.capacity(),
1464
+ });
909
1465
 
910
1466
  let agentCounter = 0;
911
1467
  const agentFn = async (
@@ -1216,34 +1772,96 @@ export default function workflows(pi: ExtensionAPI) {
1216
1772
  });
1217
1773
  const callKey = replayIdentity ? replayKey(replayIdentity) : undefined;
1218
1774
  let replayBoundaryViolated = false;
1775
+ const persistAgentResult = (result: {
1776
+ output: string;
1777
+ structured?: unknown;
1778
+ }) => {
1779
+ try {
1780
+ return {
1781
+ ok: true as const,
1782
+ artifact: persistWorkflowAgentResult(runDir, index, result),
1783
+ };
1784
+ } catch (error) {
1785
+ return { ok: false as const, error: errorText(error) };
1786
+ }
1787
+ };
1219
1788
  // Checked before controller.schedule on purpose: schedule() charges the
1220
1789
  // run's agent-call budget on entry, and a replayed call runs no agent.
1221
1790
  const cached =
1222
1791
  callKey && replayLease.canReplay ? replay?.take(callKey) : undefined;
1223
1792
  if (cached) {
1224
1793
  const finishedAt = Date.now();
1225
- record.invocation = transitionInvocation(record.invocation!, {
1226
- status: "replayed",
1227
- at: finishedAt,
1228
- });
1229
- record.state = "done";
1230
- record.replayed = true;
1231
1794
  record.finishedAt = finishedAt;
1232
1795
  record.preview = sanitizeWorkflowDisplayText(
1233
1796
  cached.output,
1234
1797
  PREVIEW_LENGTH,
1235
1798
  );
1236
- if (acceptanceContract) {
1237
- record.acceptance = evaluateAcceptance(
1238
- acceptanceContract,
1239
- cached.structured,
1799
+ const judged = applyAcceptance({
1800
+ contract: acceptanceContract,
1801
+ structured: cached.structured,
1802
+ agentOk: true,
1803
+ });
1804
+ if (judged.ledger) record.acceptance = judged.ledger;
1805
+ if (!judged.ok) {
1806
+ const error = sanitizeWorkflowDisplayLine(
1807
+ judged.error ?? "Agent failed",
1240
1808
  );
1809
+ record.invocation = transitionInvocation(record.invocation!, {
1810
+ status: "rejected",
1811
+ at: finishedAt,
1812
+ });
1813
+ record.state = "error";
1814
+ record.error = error;
1815
+ emit();
1816
+ replayLease.end();
1817
+ return {
1818
+ ok: false,
1819
+ output: cached.output,
1820
+ ...(cached.structured !== undefined
1821
+ ? { structured: cached.structured }
1822
+ : {}),
1823
+ ...(record.acceptance ? { acceptance: record.acceptance } : {}),
1824
+ error,
1825
+ };
1241
1826
  }
1827
+ const persisted = persistAgentResult({
1828
+ output: cached.output,
1829
+ ...(cached.structured !== undefined
1830
+ ? { structured: cached.structured }
1831
+ : {}),
1832
+ });
1833
+ if (!persisted.ok) {
1834
+ record.invocation = transitionInvocation(record.invocation!, {
1835
+ status: "rejected",
1836
+ at: finishedAt,
1837
+ });
1838
+ record.state = "error";
1839
+ record.error = sanitizeWorkflowDisplayLine(persisted.error);
1840
+ emit();
1841
+ replayLease.end();
1842
+ return {
1843
+ ok: false,
1844
+ output: cached.output,
1845
+ ...(cached.structured !== undefined
1846
+ ? { structured: cached.structured }
1847
+ : {}),
1848
+ ...(record.acceptance ? { acceptance: record.acceptance } : {}),
1849
+ error: persisted.error,
1850
+ };
1851
+ }
1852
+ record.invocation = transitionInvocation(record.invocation!, {
1853
+ status: "replayed",
1854
+ at: finishedAt,
1855
+ });
1856
+ record.state = "done";
1857
+ record.replayed = true;
1858
+ record.resultArtifact = persisted.artifact;
1242
1859
  const ref = handoffs.register({
1243
1860
  callId,
1244
1861
  settled: true,
1245
1862
  ok: true,
1246
1863
  output: cached.output,
1864
+ resultArtifact: record.resultArtifact,
1247
1865
  ...(cached.structured !== undefined
1248
1866
  ? { structured: cached.structured }
1249
1867
  : {}),
@@ -1252,7 +1870,7 @@ export default function workflows(pi: ExtensionAPI) {
1252
1870
  emit();
1253
1871
  // Re-journal so a chain of resumes keeps working: run C resuming from
1254
1872
  // B still finds what B replayed from A.
1255
- journalEntries.push(cached);
1873
+ journal.append(cached);
1256
1874
  replayLease.end();
1257
1875
  return {
1258
1876
  ok: true,
@@ -1365,6 +1983,9 @@ export default function workflows(pi: ExtensionAPI) {
1365
1983
  ...(sessionManager ? { sessionManager } : {}),
1366
1984
  modelRegistry: ctx.modelRegistry,
1367
1985
  ...(agentType?.tools ? { tools: agentType.tools } : {}),
1986
+ ...(testAgentSessionFactory
1987
+ ? { sessionFactory: testAgentSessionFactory }
1988
+ : {}),
1368
1989
  ...(replayIdentity
1369
1990
  ? {
1370
1991
  replayFilesystemBoundary: {
@@ -1429,7 +2050,18 @@ export default function workflows(pi: ExtensionAPI) {
1429
2050
  });
1430
2051
  const acceptance = judged.ledger;
1431
2052
  if (acceptance) record.acceptance = acceptance;
1432
- const outcomeOk = judged.ok;
2053
+ let artifactError: string | undefined;
2054
+ if (judged.ok) {
2055
+ const persisted = persistAgentResult({
2056
+ output: outcome.output,
2057
+ ...(outcome.structured !== undefined
2058
+ ? { structured: outcome.structured }
2059
+ : {}),
2060
+ });
2061
+ if (persisted.ok) record.resultArtifact = persisted.artifact;
2062
+ else artifactError = persisted.error;
2063
+ }
2064
+ const outcomeOk = judged.ok && artifactError === undefined;
1433
2065
  record.invocation = transitionInvocation(record.invocation!, {
1434
2066
  status: "settled",
1435
2067
  outcome: outcomeOk ? "success" : "error",
@@ -1437,15 +2069,20 @@ export default function workflows(pi: ExtensionAPI) {
1437
2069
  });
1438
2070
  record.state = outcomeOk ? "done" : "error";
1439
2071
  if (outcomeOk) delete record.error;
1440
- else
1441
- record.error = judged.error
1442
- ? sanitizeWorkflowDisplayLine(judged.error)
2072
+ else {
2073
+ const failureError = artifactError ?? judged.error;
2074
+ record.error = failureError
2075
+ ? sanitizeWorkflowDisplayLine(failureError)
1443
2076
  : undefined;
2077
+ }
1444
2078
  const ref = handoffs.register({
1445
2079
  callId,
1446
2080
  settled: true,
1447
2081
  ok: outcomeOk,
1448
2082
  output: outcome.output,
2083
+ ...(record.resultArtifact
2084
+ ? { resultArtifact: record.resultArtifact }
2085
+ : {}),
1449
2086
  ...(outcome.structured !== undefined
1450
2087
  ? { structured: outcome.structured }
1451
2088
  : {}),
@@ -1477,7 +2114,7 @@ export default function workflows(pi: ExtensionAPI) {
1477
2114
  !replayBoundaryViolated &&
1478
2115
  replayLease.canJournal()
1479
2116
  ) {
1480
- journalEntries.push({
2117
+ journal.append({
1481
2118
  key: completedKey,
1482
2119
  output: outcome.output,
1483
2120
  ...(outcome.structured !== undefined
@@ -1521,7 +2158,10 @@ export default function workflows(pi: ExtensionAPI) {
1521
2158
  detached: false,
1522
2159
  };
1523
2160
  } else {
1524
- cleanup = await reclaimWorktree(ctx.cwd, worktree).catch(
2161
+ const reclaimer =
2162
+ workflowLifecycleTestHooks?.reclaimWorktree ??
2163
+ reclaimWorktree;
2164
+ cleanup = await reclaimer(ctx.cwd, worktree).catch(
1525
2165
  (error): WorktreeCleanup => ({
1526
2166
  removed: false,
1527
2167
  branchDeleted: false,
@@ -1543,13 +2183,21 @@ export default function workflows(pi: ExtensionAPI) {
1543
2183
  };
1544
2184
  }
1545
2185
  }
1546
- if (!runSettled) {
1547
- record.worktreeCleanup = cleanup;
1548
- if (cleanup.branchDeleted) delete record.worktreeBranch;
1549
- else record.worktreeBranch = cleanup.branch;
1550
- if (!cleanup.removed) record.worktreePath = worktree.path;
1551
- emit();
1552
- }
2186
+ record.worktreeCleanup = cleanup;
2187
+ if (cleanup.branchDeleted) delete record.worktreeBranch;
2188
+ else record.worktreeBranch = cleanup.branch;
2189
+ if (!cleanup.removed) record.worktreePath = worktree.path;
2190
+ // Forced settlement fixes the execution verdict, but cleanup
2191
+ // provenance discovered afterward still belongs in the run.
2192
+ // No later final flush remains, so failures must be observable.
2193
+ if (runSettled) {
2194
+ try {
2195
+ persistence.flush();
2196
+ } catch (error) {
2197
+ appendArtifactPersistenceFailure(details, error);
2198
+ persistTerminalRecovery();
2199
+ }
2200
+ } else emit();
1553
2201
  }
1554
2202
  }
1555
2203
  }, invocationSignal)
@@ -1603,8 +2251,8 @@ export default function workflows(pi: ExtensionAPI) {
1603
2251
  try {
1604
2252
  persistence.flush();
1605
2253
  } catch (error) {
1606
- details.status = "failed";
1607
- details.error = `Artifact persistence failed: ${errorText(error)}`;
2254
+ appendArtifactPersistenceFailure(details, error);
2255
+ persistTerminalRecovery();
1608
2256
  throw new Error(details.error);
1609
2257
  } finally {
1610
2258
  flushNow(true);
@@ -1621,51 +2269,33 @@ export default function workflows(pi: ExtensionAPI) {
1621
2269
  activeRuns.set(runId, activeRun);
1622
2270
  const completion = runScript();
1623
2271
  activeRun.completion = completion;
2272
+ workflowLifecycleTestHooks?.onRunStarted?.(activeRun);
1624
2273
  if (ctx.hasUI) lastContext = ctx;
1625
2274
  updateIndicator();
1626
2275
 
2276
+ const recordTerminalRun = () => {
2277
+ activeRuns.delete(runId);
2278
+ recordSettledRun(details);
2279
+ updateIndicator();
2280
+ };
2281
+
2282
+ const settleForLaterDelivery = async (inlineReleased: boolean) => {
2283
+ try {
2284
+ await completion;
2285
+ } catch (error) {
2286
+ if (details.status === "running") details.status = "failed";
2287
+ details.finishedAt ??= Date.now();
2288
+ details.error = details.error ?? errorText(error);
2289
+ } finally {
2290
+ recordTerminalRun();
2291
+ const envelope = completionEnvelope(details);
2292
+ if (inlineReleased) resultDelivery.releaseInline(envelope);
2293
+ else resultDelivery.defer(envelope);
2294
+ }
2295
+ };
2296
+
1627
2297
  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();
2298
+ void settleForLaterDelivery(false);
1669
2299
  return {
1670
2300
  content: [
1671
2301
  {
@@ -1677,17 +2307,19 @@ export default function workflows(pi: ExtensionAPI) {
1677
2307
  }),
1678
2308
  },
1679
2309
  ],
1680
- details: compactToolDetails(details),
2310
+ details: compactWorkflowToolDetails(details),
1681
2311
  };
1682
2312
  }
1683
2313
 
1684
- try {
1685
- await completion;
1686
- } finally {
1687
- activeRuns.delete(runId);
1688
- recordSettledRun(details);
1689
- updateIndicator();
2314
+ const waitOutcome = await waitForWorkflowCompletion(completion, signal);
2315
+ if (waitOutcome === "aborted") {
2316
+ void settleForLaterDelivery(true);
2317
+ throw new Error(
2318
+ `Workflow wait interrupted; run ${runId} continues in the background.`,
2319
+ );
1690
2320
  }
2321
+ recordTerminalRun();
2322
+ resultDelivery.consumeInline(details);
1691
2323
  if (details.status !== "completed") {
1692
2324
  // Pi marks tool failures only when execute throws; returning isError is
1693
2325
  // ignored by the extension API.
@@ -1697,10 +2329,14 @@ export default function workflows(pi: ExtensionAPI) {
1697
2329
  content: [
1698
2330
  {
1699
2331
  type: "text",
1700
- text: buildWorkflowResultMessage(details, runDir),
2332
+ text: buildProjectedWorkflowResultMessage(
2333
+ details,
2334
+ runDir,
2335
+ ctx.getContextUsage?.(),
2336
+ ),
1701
2337
  },
1702
2338
  ],
1703
- details: compactToolDetails(details),
2339
+ details: compactWorkflowToolDetails(details),
1704
2340
  };
1705
2341
  },
1706
2342
 
@@ -1712,20 +2348,24 @@ export default function workflows(pi: ExtensionAPI) {
1712
2348
  let text =
1713
2349
  theme.fg("toolTitle", theme.bold("workflow ")) +
1714
2350
  theme.fg("accent", (meta as WorkflowMeta).name ?? "(script)");
1715
- if (args.background) text += theme.fg("dim", " (background)");
2351
+ if (args.background !== undefined) {
2352
+ text += theme.fg("dim", ` (deprecated: use wait: ${!args.background})`);
2353
+ } else if (args.wait === true) {
2354
+ text += theme.fg("dim", " (wait)");
2355
+ }
1716
2356
  const description = (meta as WorkflowMeta).description;
1717
2357
  if (description) text += `\n ${theme.fg("dim", description)}`;
1718
2358
  for (const phase of meta.phases.slice(0, 8)) {
1719
- text += `\n ${theme.fg("dim", SQUARE)} ${theme.fg("accent", phase.title)}${
2359
+ text += `\n ${theme.fg("dim", "○")} ${theme.fg("accent", phase.title)}${
1720
2360
  phase.detail ? theme.fg("dim", ` — ${phase.detail}`) : ""
1721
2361
  }`;
1722
2362
  }
1723
2363
  return new Text(text, 0, 0);
1724
2364
  },
1725
2365
 
1726
- renderResult(result, { expanded }, theme) {
1727
- const details = result.details as WorkflowDetails | undefined;
1728
- if (!details) {
2366
+ renderResult(result, { expanded, isPartial }, theme, context) {
2367
+ const details = result.details;
2368
+ if (!isWorkflowRenderDetails(details)) {
1729
2369
  const first = result.content[0];
1730
2370
  return new Text(
1731
2371
  first?.type === "text" ? first.text : "(no output)",
@@ -1733,225 +2373,53 @@ export default function workflows(pi: ExtensionAPI) {
1733
2373
  0,
1734
2374
  );
1735
2375
  }
2376
+ // A settled Pi tool result is committed transcript history. Keep its
2377
+ // launch snapshot stable; live run state belongs to the strip/dashboard.
2378
+ const settledAt = Date.now();
2379
+ const currentDetails = () =>
2380
+ isPartial
2381
+ ? (activeRuns.get(details.runId)?.details ??
2382
+ settledRuns.get(details.runId) ??
2383
+ details)
2384
+ : details;
2385
+ syncWorkflowSpinner(
2386
+ context.state as WorkflowRenderState,
2387
+ () => isPartial && currentDetails().status === "running",
2388
+ context.invalidate,
2389
+ );
1736
2390
 
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
- ),
1844
- );
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));
2391
+ return {
2392
+ render(width: number) {
2393
+ const current = currentDetails();
2394
+ const totals = formatUsage(aggregateUsage(current.agents));
2395
+ const now = isPartial ? Date.now() : settledAt;
2396
+ if (!expanded) {
2397
+ return buildCollapsedRows(current, theme, width, now, totals);
1854
2398
  }
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
- ),
2399
+ return buildExpandedWorkflow(current, theme, now, totals).render(
2400
+ width,
1871
2401
  );
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;
2402
+ },
2403
+ invalidate() {},
2404
+ };
1919
2405
  },
1920
2406
  });
1921
2407
 
1922
2408
  /** Resolve one run from live, settled, or persisted state. */
1923
2409
  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
2410
  const resolution = resolveWorkflowRunTarget(target, [
1932
2411
  ...activeRuns.keys(),
1933
2412
  ...settledRuns.keys(),
1934
- ...persistedIds,
2413
+ ...listPersistedRunIds(),
1935
2414
  ]);
1936
2415
  if (!resolution.ok) return resolution;
1937
2416
 
1938
2417
  const active = activeRuns.get(resolution.runId);
1939
2418
  if (active) return { ok: true, details: active.details } as const;
1940
- const settled = settledRuns.get(resolution.runId);
1941
- if (settled) return { ok: true, details: settled } as const;
1942
-
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");
2419
+ const details = readPersistedWorkflowDetails(resolution.runId, {
2420
+ hydrateArtifacts: true,
2421
+ });
2422
+ if (details) {
1955
2423
  // A run absent from activeRuns cannot still be running this session; a
1956
2424
  // persisted "running" is a run that was hard-killed or missed the
1957
2425
  // shutdown settle deadline.
@@ -1959,12 +2427,15 @@ export default function workflows(pi: ExtensionAPI) {
1959
2427
  ok: true,
1960
2428
  details: recoverStaleWorkflowDetails(details),
1961
2429
  } as const;
1962
- } catch {
1963
- return {
1964
- ok: false,
1965
- error: `Workflow run ${resolution.runId} could not be read.`,
1966
- } as const;
1967
2430
  }
2431
+ // Keep the bounded projection as a diagnostic fallback when an artifact is
2432
+ // temporarily unreadable. An explicit id still resolves to a known run.
2433
+ const settled = settledRuns.get(resolution.runId);
2434
+ if (settled) return { ok: true, details: settled } as const;
2435
+ return {
2436
+ ok: false,
2437
+ error: `Workflow run ${resolution.runId} could not be read.`,
2438
+ } as const;
1968
2439
  };
1969
2440
 
1970
2441
  pi.registerTool({
@@ -1978,23 +2449,29 @@ export default function workflows(pi: ExtensionAPI) {
1978
2449
  }),
1979
2450
  }),
1980
2451
  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
- );
2452
+ const resolution = resolveRunDetails(params.runId);
1988
2453
  if (!resolution.ok) throw new Error(resolution.error);
1989
- stopRun(resolution.runId);
2454
+ const details = resolution.details;
2455
+ if (details.status !== "running") {
2456
+ return Promise.resolve({
2457
+ content: [
2458
+ {
2459
+ type: "text",
2460
+ text: `Workflow ${details.runId} is already ${details.status}.`,
2461
+ },
2462
+ ],
2463
+ details: { runId: details.runId, status: details.status },
2464
+ });
2465
+ }
2466
+ stopRun(details.runId);
1990
2467
  return Promise.resolve({
1991
2468
  content: [
1992
2469
  {
1993
2470
  type: "text",
1994
- text: `Stopping workflow ${resolution.runId}.`,
2471
+ text: `Stopping workflow ${details.runId}.`,
1995
2472
  },
1996
2473
  ],
1997
- details: { runId: resolution.runId, status: "aborting" },
2474
+ details: { runId: details.runId, status: "aborting" },
1998
2475
  });
1999
2476
  },
2000
2477
  });
@@ -2014,13 +2491,14 @@ export default function workflows(pi: ExtensionAPI) {
2014
2491
  // Details are a uniform run-summary array (one entry for a single-id peek)
2015
2492
  // so the tool has a single result shape; the text carries the detail.
2016
2493
  const summarize = (d: WorkflowDetails) => {
2017
- const { done, failed } = countStates(d);
2494
+ const { done, failed, uncertain } = countStates(d);
2018
2495
  return {
2019
2496
  runId: d.runId,
2020
2497
  name: d.name,
2021
2498
  status: d.status,
2022
2499
  done,
2023
2500
  failed,
2501
+ uncertain,
2024
2502
  total: d.agents.length,
2025
2503
  };
2026
2504
  };
@@ -2029,32 +2507,57 @@ export default function workflows(pi: ExtensionAPI) {
2029
2507
  if (!resolution.ok) throw new Error(resolution.error);
2030
2508
  const details = resolution.details;
2031
2509
  const runDir = path.join(getAgentDir(), "workflows", details.runId);
2510
+ const retention = settledRuns.stats;
2032
2511
  return Promise.resolve({
2033
2512
  content: [
2034
- { type: "text", text: buildWorkflowResultMessage(details, runDir) },
2513
+ { type: "text", text: buildWorkflowStatusSummary(details, runDir) },
2035
2514
  ],
2036
- details: { runs: [summarize(details)] },
2515
+ details: {
2516
+ runs: [summarize(details)],
2517
+ retention,
2518
+ settledRunsEvicted: retention.settledRunsEvicted,
2519
+ },
2037
2520
  });
2038
2521
  }
2039
2522
  const runs = [
2040
2523
  ...[...activeRuns.values()].map((run) => run.details),
2041
2524
  ...settledRuns.values(),
2042
2525
  ];
2526
+ const retention = settledRuns.stats;
2043
2527
  if (runs.length === 0) {
2044
2528
  return Promise.resolve({
2045
2529
  content: [
2046
- { type: "text", text: "No active or recently finished workflows." },
2530
+ {
2531
+ type: "text",
2532
+ text:
2533
+ retention.evictedRuns > 0
2534
+ ? `No active or retained workflows. ${retention.evictedRuns} settled run(s) omitted from memory in the current session; canonical artifacts remain available on disk.`
2535
+ : "No active or recently finished workflows.",
2536
+ },
2047
2537
  ],
2048
- details: { runs: [] },
2538
+ details: {
2539
+ runs: [],
2540
+ retention,
2541
+ settledRunsEvicted: retention.settledRunsEvicted,
2542
+ },
2049
2543
  });
2050
2544
  }
2051
2545
  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` : ""}`;
2546
+ const { done, failed, uncertain } = countStates(d);
2547
+ return `${d.runId}${d.name ? ` "${d.name}"` : ""} — ${statusWord(d.status)} · ${done + failed}/${d.agents.length} agents${failed ? `, ${failed} failed` : ""}${uncertain ? `, ${uncertain} uncertain` : ""}`;
2054
2548
  });
2549
+ if (retention.evictedRuns > 0) {
2550
+ lines.push(
2551
+ `Retention (current session): ${retention.retainedRuns} settled projection(s) retained; ${retention.evictedRuns} evicted/omitted (${retention.evictedBytes} UTF-8 bytes). Canonical artifacts remain available on disk.`,
2552
+ );
2553
+ }
2055
2554
  return Promise.resolve({
2056
2555
  content: [{ type: "text", text: lines.join("\n") }],
2057
- details: { runs: runs.map(summarize) },
2556
+ details: {
2557
+ runs: runs.map(summarize),
2558
+ retention,
2559
+ settledRunsEvicted: retention.settledRunsEvicted,
2560
+ },
2058
2561
  });
2059
2562
  },
2060
2563
  });
@@ -2062,7 +2565,6 @@ export default function workflows(pi: ExtensionAPI) {
2062
2565
  pi.registerMessageRenderer(
2063
2566
  "workflow-result",
2064
2567
  (message, { expanded }, theme) => {
2065
- const details = message.details as WorkflowDetails | undefined;
2066
2568
  const body =
2067
2569
  typeof message.content === "string"
2068
2570
  ? message.content
@@ -2070,25 +2572,73 @@ export default function workflows(pi: ExtensionAPI) {
2070
2572
  ?.map((part) => (part.type === "text" ? part.text : ""))
2071
2573
  .join("") ?? "");
2072
2574
  const safeBody = sanitizeWorkflowDisplayText(body);
2073
- 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`);
2085
- if (expanded) return new Text(`${header}\n\n${safeBody}`, 0, 0);
2086
- const preview = safeBody.split("\n").slice(0, 8).join("\n");
2087
- return new Text(
2088
- `${header}\n${preview}\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`,
2089
- 0,
2090
- 0,
2091
- );
2575
+ const display = isWorkflowCompletionDisplay(message.details)
2576
+ ? message.details
2577
+ : undefined;
2578
+ const legacyDetails = isWorkflowRenderDetails(message.details)
2579
+ ? message.details
2580
+ : undefined;
2581
+ if (!display && !legacyDetails) {
2582
+ return new Text(safeBody, 0, 0);
2583
+ }
2584
+ if (legacyDetails) {
2585
+ const headerParts = runHeader(legacyDetails, theme, Date.now());
2586
+ const header = headerParts.right
2587
+ ? `${headerParts.left} ${headerParts.right}`
2588
+ : headerParts.left;
2589
+ if (expanded) return new Text(`${header}\n\n${safeBody}`, 0, 0);
2590
+ const preview = safeBody.split("\n").slice(0, 8).join("\n");
2591
+ return new Text(
2592
+ `${header}\n${preview}\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`,
2593
+ 0,
2594
+ 0,
2595
+ );
2596
+ }
2597
+ if (!display) return new Text(safeBody, 0, 0);
2598
+ if (expanded) {
2599
+ return new Text(buildExpandedWorkflowCompletion(display), 0, 0);
2600
+ }
2601
+ return {
2602
+ render(width: number) {
2603
+ const rows: string[] = [];
2604
+ for (const entry of display.entries) {
2605
+ rows.push(
2606
+ truncateToWidth(
2607
+ `${statusGlyph(entry.status, theme, Date.now())} ${workflowCompletionSummary(entry)}`,
2608
+ width,
2609
+ "…",
2610
+ ),
2611
+ );
2612
+ for (const alert of workflowCompletionAlerts(entry)) {
2613
+ rows.push(
2614
+ truncateToWidth(` ${theme.fg("error", alert)}`, width, "…"),
2615
+ );
2616
+ }
2617
+ const result = workflowCompletionResultPreview(entry);
2618
+ if (result) {
2619
+ rows.push(
2620
+ truncateToWidth(
2621
+ ` ${theme.fg("accent", "Result:")} ${result}`,
2622
+ width,
2623
+ "…",
2624
+ ),
2625
+ );
2626
+ }
2627
+ }
2628
+ rows.push(
2629
+ truncateToWidth(
2630
+ theme.fg(
2631
+ "muted",
2632
+ `(${keyHint("app.tools.expand", "to expand")})`,
2633
+ ),
2634
+ width,
2635
+ "…",
2636
+ ),
2637
+ );
2638
+ return rows;
2639
+ },
2640
+ invalidate() {},
2641
+ };
2092
2642
  },
2093
2643
  );
2094
2644
  }