@tt-a1i/openpi 0.1.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 (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,1884 @@
1
+ /**
2
+ * workflows: model-authored multi-agent orchestration.
3
+ *
4
+ * A `workflow` tool that runs a JavaScript orchestration script written inline
5
+ * by the model. The script executes ordered phases, fanning work out to
6
+ * isolated subagents:
7
+ *
8
+ * export const meta = { name, description, phases: [{ title, detail? }] }
9
+ * phase(title) // mark runtime phase progression
10
+ * log(message) // narrate progress to the user and the report
11
+ * usage() // cumulative token spend so far (read-only)
12
+ * await agent(prompt, { agent_type?, label?, phase?, schema?, model?, provider?, effort? })
13
+ * await pipeline(items, stage1, stage2, ...) // per-item, no barrier between stages
14
+ * await parallel([() => agent(...), ...], { concurrency? }) // barrier
15
+ * args // parsed JSON args passed with the tool call
16
+ *
17
+ * `agent()` always resolves to `{ ok, output, structured?, error? }` — it
18
+ * never throws into the script. Scripts branch on `ok` explicitly.
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
23
+ * under `~/.pi/agent/workflows/<runId>/` for inspection; result and bounded
24
+ * transcripts use separate artifacts.
25
+ *
26
+ * `resume_from_run_id` replays a prior run's cached agent results. Matching is
27
+ * by call CONTENT, not by ordinal: `pipeline()` issues calls in an order that
28
+ * depends on real agent latency, so an ordinal would drift between runs and
29
+ * hand one item's result to another. See `journal.ts`.
30
+ */
31
+
32
+ import { randomBytes } from "node:crypto";
33
+ import * as fs from "node:fs";
34
+ import * as path from "node:path";
35
+ import {
36
+ CustomEditor,
37
+ getAgentDir,
38
+ getMarkdownTheme,
39
+ keyHint,
40
+ type ExtensionAPI,
41
+ type ExtensionContext,
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";
46
+ import { waitBounded } from "../shared/child-session.ts";
47
+ import { loadSetupConfig } from "../shared/setup-config.ts";
48
+ import {
49
+ loadAgentTypes,
50
+ resolveAgentModel,
51
+ roleModelForAgentType,
52
+ selectSubagentModel,
53
+ } from "../subagents/src/agent-types.ts";
54
+ import {
55
+ createWorktree,
56
+ reclaimWorktree,
57
+ type Worktree,
58
+ type WorktreeCleanup,
59
+ } from "../shared/worktree.ts";
60
+ import {
61
+ createWorkflowPersistence,
62
+ loadJournal,
63
+ persistWorkflowJson,
64
+ } from "./artifacts.ts";
65
+ import {
66
+ agentCallKey,
67
+ createReplayCache,
68
+ type JournalEntry,
69
+ type ReplayCache,
70
+ } from "./journal.ts";
71
+ import { RunController } from "./controller.ts";
72
+ import {
73
+ normalizePersistedWorkflowDetails,
74
+ sessionWorkflowRunIds,
75
+ showWorkflowDashboard,
76
+ } from "./dashboard.ts";
77
+ import {
78
+ extractMeta,
79
+ prepareWorkflowScript,
80
+ type WorkflowMeta,
81
+ } from "./meta.ts";
82
+ import {
83
+ agentContext,
84
+ aggregateUsage,
85
+ appendLog,
86
+ countStates,
87
+ emptyUsage,
88
+ formatElapsed,
89
+ formatUsage,
90
+ isWorkflowRunId,
91
+ phaseGroups,
92
+ resolveWorkflowRunTarget,
93
+ resultJson,
94
+ sanitizeLine,
95
+ sanitizeWorkflowDisplayLine,
96
+ sanitizeWorkflowDisplayText,
97
+ stateSquare,
98
+ statusColor,
99
+ statusWord,
100
+ createUsageReader,
101
+ SQUARE,
102
+ type AgentRecord,
103
+ type WorkflowDetails,
104
+ } from "./model.ts";
105
+ import {
106
+ buildBackgroundWorkflowFollowUp,
107
+ buildBackgroundWorkflowLaunchResult,
108
+ buildWorkflowAgentPrompt,
109
+ buildWorkflowResultMessage,
110
+ WORKFLOW_LIFECYCLE_PROMPT_SNIPPET,
111
+ WORKFLOW_PARAMETER_DESCRIPTIONS,
112
+ WORKFLOW_PROMPT_GUIDELINES,
113
+ WORKFLOW_PROMPT_SNIPPET,
114
+ WORKFLOW_STATUS_PARAMETER_DESCRIPTIONS,
115
+ WORKFLOW_STATUS_TOOL_DESCRIPTION,
116
+ WORKFLOW_STOP_PARAMETER_DESCRIPTIONS,
117
+ WORKFLOW_STOP_TOOL_DESCRIPTION,
118
+ WORKFLOW_TOOL_DESCRIPTION,
119
+ } from "./prompt.ts";
120
+ import {
121
+ WorkflowNavigationEditor,
122
+ WorkflowStripState,
123
+ WorkflowStripWidget,
124
+ type WorkflowStripEntry,
125
+ } from "./navigation.ts";
126
+ import {
127
+ createWorkflowResources,
128
+ runAgent,
129
+ type ThinkingLevel,
130
+ type WorkflowModel,
131
+ } from "./runner.ts";
132
+ import {
133
+ beginProcessReplayWorkspaceLease,
134
+ createReplayIdentity,
135
+ isReplaySafeAgentCall,
136
+ } from "./replay-safety.ts";
137
+ import { runWorkflowSandbox } from "./sandbox.ts";
138
+ import {
139
+ acceptanceInstruction,
140
+ acceptanceSchema,
141
+ applyAcceptance,
142
+ evaluateAcceptance,
143
+ parseAcceptanceContract,
144
+ } from "./acceptance.ts";
145
+ import {
146
+ finalizeWorktreeHandoff,
147
+ prepareWorktreeHandoff,
148
+ } from "./worktree-handoff.ts";
149
+ import { safeStringify, writeFileAtomic } from "./serialization.ts";
150
+
151
+ const PREVIEW_LENGTH = 200;
152
+ const EMIT_INTERVAL_MS = 120;
153
+
154
+ const THINKING_LEVELS = [
155
+ "off",
156
+ "minimal",
157
+ "low",
158
+ "medium",
159
+ "high",
160
+ "xhigh",
161
+ "max",
162
+ ] as const;
163
+
164
+ /** What `agent()` resolves to inside the script. */
165
+ interface ScriptAgentResult {
166
+ ok: boolean;
167
+ output: string;
168
+ structured?: unknown;
169
+ acceptance?: AgentRecord["acceptance"];
170
+ error?: string;
171
+ }
172
+
173
+ interface AgentCallOptions {
174
+ agent_type?: unknown;
175
+ label?: unknown;
176
+ phase?: unknown;
177
+ schema?: unknown;
178
+ acceptance?: unknown;
179
+ model?: unknown;
180
+ provider?: unknown;
181
+ effort?: unknown;
182
+ isolation?: unknown;
183
+ }
184
+
185
+ const WorkflowParams = Type.Object({
186
+ script: Type.String({
187
+ description: WORKFLOW_PARAMETER_DESCRIPTIONS.script,
188
+ }),
189
+ args: Type.Optional(
190
+ Type.String({
191
+ description: WORKFLOW_PARAMETER_DESCRIPTIONS.args,
192
+ }),
193
+ ),
194
+ background: Type.Optional(
195
+ Type.Boolean({
196
+ description: WORKFLOW_PARAMETER_DESCRIPTIONS.background,
197
+ }),
198
+ ),
199
+ resume_from_run_id: Type.Optional(
200
+ Type.String({
201
+ description: WORKFLOW_PARAMETER_DESCRIPTIONS.resumeFromRunId,
202
+ }),
203
+ ),
204
+ });
205
+
206
+ type WorkflowInput = Static<typeof WorkflowParams>;
207
+
208
+ /** Resolve a persisted run without letting a suffix collision choose one. */
209
+ export function resolveRunDir(target: string) {
210
+ const base = path.join(getAgentDir(), "workflows");
211
+ let names: string[] = [];
212
+ try {
213
+ names = fs
214
+ .readdirSync(base, { withFileTypes: true })
215
+ .filter((entry) => entry.isDirectory())
216
+ .map((entry) => entry.name)
217
+ .filter(isWorkflowRunId);
218
+ } catch {
219
+ // The shared resolver reports the empty candidate set usefully.
220
+ }
221
+
222
+ // Shape validation happens before any target-derived path is built. This
223
+ // value is model-supplied, so traversal must never reach a planted journal.
224
+ const resolution = resolveWorkflowRunTarget(target, names);
225
+ return resolution.ok
226
+ ? { ...resolution, runDir: path.join(base, resolution.runId) }
227
+ : resolution;
228
+ }
229
+
230
+ /** Backward-compatible directory lookup for callers that only need a hit. */
231
+ export function findRunDir(target: string) {
232
+ const resolution = resolveRunDir(target);
233
+ return resolution.ok ? resolution.runDir : undefined;
234
+ }
235
+
236
+ function errorText(error: unknown): string {
237
+ return sanitizeWorkflowDisplayLine(
238
+ error instanceof Error ? error.message : String(error),
239
+ );
240
+ }
241
+
242
+ function summaryLine(details: WorkflowDetails): string {
243
+ const { done, failed } = countStates(details);
244
+ const settled = done + failed;
245
+ // The newest narrator line beats the phase title when there is one: the
246
+ // script wrote it precisely because it says more than the phase does.
247
+ const latest = details.logs?.[details.logs.length - 1]?.text;
248
+ return `workflow ${details.name ?? details.runId}: ${settled}/${details.agents.length} agents${
249
+ latest
250
+ ? ` · ${latest}`
251
+ : details.currentPhase
252
+ ? ` · ${details.currentPhase}`
253
+ : ""
254
+ }`;
255
+ }
256
+
257
+ function writeRunFile(runDir: string, name: string, content: string) {
258
+ writeFileAtomic(path.join(runDir, name), content);
259
+ }
260
+
261
+ function compactToolDetails(details: WorkflowDetails): WorkflowDetails {
262
+ return {
263
+ ...details,
264
+ ...(details.result !== undefined
265
+ ? {
266
+ result: JSON.parse(
267
+ safeStringify(details.result, { maxBytes: 64 * 1024 }),
268
+ ),
269
+ }
270
+ : {}),
271
+ agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
272
+ };
273
+ }
274
+
275
+ export interface ActiveWorkflowRunLifecycle {
276
+ details: WorkflowDetails;
277
+ controller: Pick<RunController, "abort" | "settle">;
278
+ completion?: Promise<void>;
279
+ forceSettle(error: string): void;
280
+ }
281
+
282
+ /** Abort every live child and bound the whole session-shutdown barrier once. */
283
+ export async function shutdownActiveWorkflowRuns(
284
+ runs: readonly ActiveWorkflowRunLifecycle[],
285
+ timeoutMs = 8_000,
286
+ ) {
287
+ for (const run of runs) run.controller.abort("Session is shutting down");
288
+ const completions = runs
289
+ .map((run) => run.completion)
290
+ .filter(
291
+ (completion): completion is Promise<void> => completion !== undefined,
292
+ );
293
+ const completed = await waitBounded(
294
+ Promise.allSettled([
295
+ ...runs.map((run) => run.controller.settle({ abort: true })),
296
+ ...completions,
297
+ ]),
298
+ timeoutMs,
299
+ );
300
+ if (!completed) {
301
+ for (const run of runs) {
302
+ run.forceSettle("Session shutdown deadline exceeded");
303
+ }
304
+ }
305
+ return completed;
306
+ }
307
+
308
+ interface RunSummary {
309
+ runId: string;
310
+ name?: string;
311
+ status: string;
312
+ done: number;
313
+ total: number;
314
+ startedAt: number;
315
+ active: boolean;
316
+ }
317
+
318
+ function listRuns(
319
+ activeRuns: Map<string, WorkflowDetails>,
320
+ sessionId: string,
321
+ referencedRunIds: ReadonlySet<string>,
322
+ startedSince = 0,
323
+ ): RunSummary[] {
324
+ const base = path.join(getAgentDir(), "workflows");
325
+ let names: string[] = [];
326
+ try {
327
+ names = fs.readdirSync(base).filter(isWorkflowRunId);
328
+ } catch {
329
+ // No runs yet.
330
+ }
331
+ const summaries: RunSummary[] = [];
332
+ for (const runId of names) {
333
+ const live = activeRuns.get(runId);
334
+ if (live) {
335
+ const { done, failed } = countStates(live);
336
+ summaries.push({
337
+ runId,
338
+ name: live.name,
339
+ status: live.status,
340
+ done: done + failed,
341
+ total: live.agents.length,
342
+ startedAt: live.startedAt,
343
+ active: true,
344
+ });
345
+ continue;
346
+ }
347
+ try {
348
+ const parsed = JSON.parse(
349
+ fs.readFileSync(path.join(base, runId, "workflow.json"), "utf8"),
350
+ ) as Partial<WorkflowDetails>;
351
+ const startedAt = parsed.startedAt ?? 0;
352
+ const touchedAt = Math.max(startedAt, parsed.finishedAt ?? 0);
353
+ if (
354
+ touchedAt < startedSince ||
355
+ (parsed.sessionId !== sessionId && !referencedRunIds.has(runId))
356
+ ) {
357
+ continue;
358
+ }
359
+ const agents = parsed.agents ?? [];
360
+ summaries.push({
361
+ runId,
362
+ name: parsed.name,
363
+ status:
364
+ parsed.status === "running"
365
+ ? "aborted"
366
+ : (parsed.status ?? "unknown"),
367
+ done: agents.filter((agent) => agent.state !== "running").length,
368
+ total: agents.length,
369
+ startedAt: parsed.startedAt ?? 0,
370
+ active: false,
371
+ });
372
+ } catch {
373
+ // Ignore unreadable artifacts because their session cannot be verified.
374
+ }
375
+ }
376
+ return summaries.sort((a, b) => b.startedAt - a.startedAt);
377
+ }
378
+
379
+ function runDetailText(
380
+ run: RunSummary,
381
+ activeRuns: Map<string, WorkflowDetails>,
382
+ ): string {
383
+ const runDir = path.join(getAgentDir(), "workflows", run.runId);
384
+ const live = activeRuns.get(run.runId);
385
+ if (live) return buildWorkflowResultMessage(live, runDir);
386
+ try {
387
+ const parsed = JSON.parse(
388
+ fs.readFileSync(path.join(runDir, "workflow.json"), "utf8"),
389
+ ) as WorkflowDetails;
390
+ return buildWorkflowResultMessage(parsed, runDir);
391
+ } catch {
392
+ return `Run ${run.runId} — ${run.status}`;
393
+ }
394
+ }
395
+
396
+ export default function workflows(pi: ExtensionAPI) {
397
+ /** Live background runs, for /workflows and shutdown cleanup. */
398
+ const activeRuns = new Map<string, ActiveWorkflowRunLifecycle>();
399
+ const activeDetails = () =>
400
+ new Map(
401
+ [...activeRuns].map(([runId, run]) => [runId, run.details] as const),
402
+ );
403
+ const settledRuns = new Map<string, WorkflowDetails>();
404
+ const stripState = new WorkflowStripState();
405
+ const widgetKey = "workflow-navigation";
406
+
407
+ /**
408
+ * Finished counts are an unread notice: opening the dashboard or sending the
409
+ * next explicit request acknowledges them.
410
+ */
411
+ let lastContext: ExtensionContext | undefined;
412
+ let completedRuns = 0;
413
+ let failedRuns = 0;
414
+ let widgetVisible = false;
415
+ let requestWidgetRender: (() => void) | undefined;
416
+ let dashboardOpen = false;
417
+ /**
418
+ * Start of the current request. The dashboard reports the work belonging to
419
+ * it, not the whole session's run history.
420
+ */
421
+ let turnStartedAt = 0;
422
+ let agentTypes = loadAgentTypes({
423
+ agentDir: getAgentDir(),
424
+ cwd: process.cwd(),
425
+ projectTrusted: false,
426
+ }).agentTypes;
427
+
428
+ const newestEntry = (
429
+ entries: Iterable<readonly [string, WorkflowDetails]>,
430
+ ): WorkflowStripEntry | undefined => {
431
+ let newest: WorkflowStripEntry | undefined;
432
+ for (const [runId, details] of entries) {
433
+ if (!newest || details.startedAt > newest.details.startedAt) {
434
+ newest = { runId, details };
435
+ }
436
+ }
437
+ return newest;
438
+ };
439
+
440
+ const stripEntry = () => {
441
+ const running = newestEntry(
442
+ [...activeRuns].map(([runId, run]) => [runId, run.details] as const),
443
+ );
444
+ return running ?? newestEntry(settledRuns);
445
+ };
446
+
447
+ const updateWorkflowWidget = () => {
448
+ const ctx = lastContext;
449
+ if (!ctx || ctx.mode !== "tui") return;
450
+ const visible = Boolean(stripEntry());
451
+ if (visible === widgetVisible) return;
452
+ if (!visible) {
453
+ stripState.focused = false;
454
+ requestWidgetRender = undefined;
455
+ ctx.ui.setWidget(widgetKey, undefined);
456
+ widgetVisible = false;
457
+ return;
458
+ }
459
+ ctx.ui.setWidget(
460
+ widgetKey,
461
+ (tui, theme) => {
462
+ requestWidgetRender = () => tui.requestRender();
463
+ return new WorkflowStripWidget(tui, theme, stripState, stripEntry);
464
+ },
465
+ { placement: "belowEditor" },
466
+ );
467
+ widgetVisible = true;
468
+ };
469
+
470
+ const updateIndicator = () => {
471
+ const ctx = lastContext;
472
+ if (!ctx) return;
473
+ try {
474
+ const running = activeRuns.size;
475
+ if (running === 0 && completedRuns === 0 && failedRuns === 0) {
476
+ ctx.ui.setStatus("workflows", undefined);
477
+ } else {
478
+ ctx.ui.setStatus(
479
+ "workflows",
480
+ formatActivityStatus(ctx.ui.theme, "workflows", {
481
+ running,
482
+ done: completedRuns,
483
+ failed: failedRuns,
484
+ }),
485
+ );
486
+ }
487
+ updateWorkflowWidget();
488
+ } catch {
489
+ // UI may be unavailable.
490
+ }
491
+ };
492
+
493
+ const acknowledgeSettledRuns = () => {
494
+ completedRuns = 0;
495
+ failedRuns = 0;
496
+ settledRuns.clear();
497
+ };
498
+
499
+ const recordSettledRun = (details: WorkflowDetails) => {
500
+ settledRuns.set(details.runId, details);
501
+ if (details.status === "completed") completedRuns += 1;
502
+ else failedRuns += 1;
503
+ };
504
+
505
+ const stopRun = (runId: string) => {
506
+ const run = activeRuns.get(runId);
507
+ if (!run || run.details.status !== "running") return false;
508
+ run.controller.abort("Stopped by user");
509
+ return true;
510
+ };
511
+
512
+ const openDashboard = async (
513
+ ctx: ExtensionContext,
514
+ initialRunId?: string,
515
+ startedSince = turnStartedAt,
516
+ ) => {
517
+ if (dashboardOpen || ctx.mode !== "tui") return;
518
+ dashboardOpen = true;
519
+ stripState.focused = false;
520
+ try {
521
+ await showWorkflowDashboard(
522
+ ctx,
523
+ activeDetails,
524
+ initialRunId,
525
+ startedSince,
526
+ stopRun,
527
+ );
528
+ acknowledgeSettledRuns();
529
+ } finally {
530
+ dashboardOpen = false;
531
+ updateIndicator();
532
+ }
533
+ };
534
+
535
+ const installWorkflowNavigation = (ctx: ExtensionContext) => {
536
+ if (ctx.mode !== "tui") return;
537
+ const previous = ctx.ui.getEditorComponent();
538
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
539
+ const base =
540
+ previous?.(tui, theme, keybindings) ??
541
+ new CustomEditor(tui, theme, keybindings);
542
+ return new WorkflowNavigationEditor(
543
+ base,
544
+ keybindings,
545
+ stripState,
546
+ () => Boolean(stripEntry()),
547
+ () => {
548
+ const entry = stripEntry();
549
+ if (entry) void openDashboard(ctx, entry.runId);
550
+ },
551
+ () => {
552
+ requestWidgetRender?.();
553
+ tui.requestRender();
554
+ },
555
+ );
556
+ });
557
+ };
558
+
559
+ pi.on("session_start", (_event, ctx) => {
560
+ if (ctx.hasUI) lastContext = ctx;
561
+ agentTypes = loadAgentTypes({
562
+ agentDir: getAgentDir(),
563
+ cwd: ctx.cwd,
564
+ projectTrusted: ctx.isProjectTrusted(),
565
+ }).agentTypes;
566
+ turnStartedAt = 0;
567
+ completedRuns = 0;
568
+ failedRuns = 0;
569
+ settledRuns.clear();
570
+ installWorkflowNavigation(ctx);
571
+ updateIndicator();
572
+ });
573
+
574
+ pi.on("input", (event) => {
575
+ if (event.source === "extension") return;
576
+ turnStartedAt = Date.now();
577
+ acknowledgeSettledRuns();
578
+ updateIndicator();
579
+ });
580
+
581
+ pi.on("session_shutdown", async () => {
582
+ await shutdownActiveWorkflowRuns([...activeRuns.values()]);
583
+ try {
584
+ lastContext?.ui.setStatus("workflows", undefined);
585
+ lastContext?.ui.setWidget(widgetKey, undefined);
586
+ } catch {
587
+ // UI may already be disposed.
588
+ }
589
+ lastContext = undefined;
590
+ widgetVisible = false;
591
+ requestWidgetRender = undefined;
592
+ stripState.focused = false;
593
+ });
594
+
595
+ pi.registerCommand("workflows", {
596
+ description:
597
+ "List workflow runs (`/workflows <runId>` for detail, `/workflows <runId> stop` to cancel)",
598
+ handler: async (rawArgs, ctx) => {
599
+ const arg = rawArgs.trim();
600
+
601
+ // `/workflows <runId> stop` (or `stop <runId>`) cancels a running
602
+ // workflow. Background runs otherwise only stop at session shutdown.
603
+ const stopMatch = arg.match(/^(?:stop\s+(\S+)|(\S+)\s+stop)$/i);
604
+ if (stopMatch) {
605
+ const target = stopMatch[1] ?? stopMatch[2];
606
+ const running = [...activeRuns].filter(
607
+ ([, run]) => run.details.status === "running",
608
+ );
609
+ const resolution = resolveWorkflowRunTarget(
610
+ target,
611
+ running.map(([runId]) => runId),
612
+ );
613
+ if (!resolution.ok) {
614
+ ctx.ui.notify(resolution.error, "warning");
615
+ return;
616
+ }
617
+ activeRuns.get(resolution.runId)?.controller.abort("Stopped by user");
618
+ ctx.ui.notify(`Stopping workflow ${resolution.runId}…`, "info");
619
+ return;
620
+ }
621
+
622
+ // An explicit run id is a deliberate lookup, so it reaches session history.
623
+ const startedSince = arg ? 0 : turnStartedAt;
624
+ if (ctx.mode === "tui") {
625
+ lastContext = ctx;
626
+ await openDashboard(ctx, arg || undefined, startedSince);
627
+ return;
628
+ }
629
+ // Non-TUI fallback: plain text listing.
630
+ const runs = listRuns(
631
+ activeDetails(),
632
+ ctx.sessionManager.getSessionId(),
633
+ sessionWorkflowRunIds(ctx),
634
+ startedSince,
635
+ );
636
+ if (arg) {
637
+ const resolution = resolveWorkflowRunTarget(
638
+ arg,
639
+ runs.map((run) => run.runId),
640
+ );
641
+ if (!resolution.ok) {
642
+ ctx.ui.notify(resolution.error, "warning");
643
+ return;
644
+ }
645
+ const run = runs.find(
646
+ (candidate) => candidate.runId === resolution.runId,
647
+ );
648
+ if (run) ctx.ui.notify(runDetailText(run, activeDetails()), "info");
649
+ return;
650
+ }
651
+ if (runs.length === 0) {
652
+ ctx.ui.notify("No workflow runs for this request.", "info");
653
+ return;
654
+ }
655
+ const labels = runs.map(
656
+ (r) =>
657
+ `${r.active ? "* " : " "}${r.runId} ${r.status} ${r.name ?? ""} ${r.done}/${r.total}`,
658
+ );
659
+ if (!ctx.hasUI) {
660
+ ctx.ui.notify(labels.join("\n"), "info");
661
+ return;
662
+ }
663
+ const choice = await ctx.ui.select("Workflow runs", labels);
664
+ if (!choice) return;
665
+ const run = runs[labels.indexOf(choice)];
666
+ if (run) ctx.ui.notify(runDetailText(run, activeDetails()), "info");
667
+ },
668
+ });
669
+
670
+ pi.registerTool({
671
+ name: "workflow",
672
+ label: "Workflow",
673
+ description: WORKFLOW_TOOL_DESCRIPTION,
674
+ promptSnippet: WORKFLOW_PROMPT_SNIPPET,
675
+ promptGuidelines: WORKFLOW_PROMPT_GUIDELINES,
676
+ parameters: WorkflowParams,
677
+
678
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
679
+ let prepared: ReturnType<typeof prepareWorkflowScript>;
680
+ try {
681
+ prepared = prepareWorkflowScript(params.script);
682
+ } catch (error) {
683
+ throw new Error(`Workflow script failed to parse: ${errorText(error)}`);
684
+ }
685
+
686
+ let args: unknown;
687
+ if (params.args !== undefined) {
688
+ try {
689
+ args = JSON.parse(params.args);
690
+ } catch {
691
+ args = params.args;
692
+ }
693
+ }
694
+
695
+ const meta = prepared.meta;
696
+ const runId = `wf_${randomBytes(6).toString("hex")}`;
697
+ const runDir = path.join(getAgentDir(), "workflows", runId);
698
+ const background = (params.background ?? false) && ctx.hasUI;
699
+
700
+ const details: WorkflowDetails = {
701
+ runId,
702
+ sessionId: ctx.sessionManager.getSessionId(),
703
+ name: meta.name,
704
+ description: meta.description,
705
+ background,
706
+ status: "running",
707
+ startedAt: Date.now(),
708
+ phases: [...meta.phases],
709
+ agents: [],
710
+ };
711
+
712
+ // Resume: replay cached results for calls whose content is unchanged.
713
+ // A missing or unreadable source degrades to a normal full run — resume
714
+ // is an optimization and must not become a new way to fail.
715
+ const journalEntries: JournalEntry[] = [];
716
+ let replay: ReplayCache | undefined;
717
+ if (params.resume_from_run_id) {
718
+ const source = resolveRunDir(params.resume_from_run_id);
719
+ const journal = source.ok ? loadJournal(source.runDir) : undefined;
720
+ if (source.ok && journal && journal.entries.length > 0) {
721
+ replay = createReplayCache(journal);
722
+ details.resumedFrom = source.runId;
723
+ } else {
724
+ details.resumeNote = source.ok
725
+ ? `No replayable results found in ${source.runId}; ran everything fresh.`
726
+ : `${source.error}; ran everything fresh.`;
727
+ }
728
+ }
729
+
730
+ writeRunFile(runDir, "script.js", params.script);
731
+ if (params.args !== undefined)
732
+ writeRunFile(runDir, "args.json", params.args);
733
+ persistWorkflowJson(runDir, details);
734
+ const persistence = createWorkflowPersistence(runDir, details, {
735
+ journal: () => journalEntries,
736
+ });
737
+
738
+ // Background runs survive Esc on the parent turn, but all runs are
739
+ // aborted and settled during session shutdown.
740
+ const workflowConfig = loadSetupConfig().workflows;
741
+ const projectTrusted = ctx.isProjectTrusted();
742
+ const runAgentTypes = agentTypes;
743
+ const controller = new RunController(
744
+ background ? undefined : signal,
745
+ workflowConfig.concurrency,
746
+ workflowConfig.maxAgentCalls,
747
+ );
748
+
749
+ // Each concurrent child gets its own extension runtime. Children use the
750
+ // parent's live trust decision; an isolated child gets its own cwd (its
751
+ // worktree) but keeps that decision, since it is the same project at the
752
+ // same commit.
753
+ const getResources = (
754
+ structured: boolean,
755
+ cwd: string,
756
+ agentTypePrompt?: string,
757
+ ) =>
758
+ createWorkflowResources(
759
+ cwd,
760
+ structured ? "structured" : "plain",
761
+ projectTrusted,
762
+ agentTypePrompt,
763
+ );
764
+
765
+ // Throttled progress: tool-block updates when blocking. Background
766
+ // runs are covered by the below-editor indicator and /workflows.
767
+ let emitTimer: ReturnType<typeof setTimeout> | undefined;
768
+ let lastEmit = 0;
769
+ let runSettled = false;
770
+ const flush = (terminal = false) => {
771
+ emitTimer = undefined;
772
+ if (runSettled && !terminal) return;
773
+ lastEmit = Date.now();
774
+ if (background) return;
775
+ onUpdate?.({
776
+ content: [{ type: "text", text: summaryLine(details) }],
777
+ details: compactToolDetails(details),
778
+ });
779
+ };
780
+ const emit = (checkpoint = true) => {
781
+ if (runSettled) return;
782
+ if (checkpoint) persistence.checkpoint();
783
+ if (emitTimer) return;
784
+ emitTimer = setTimeout(
785
+ flush,
786
+ Math.max(0, EMIT_INTERVAL_MS - (Date.now() - lastEmit)),
787
+ );
788
+ };
789
+ const flushNow = (terminal = false) => {
790
+ if (emitTimer) clearTimeout(emitTimer);
791
+ flush(terminal);
792
+ };
793
+
794
+ const terminalize = (
795
+ status: WorkflowDetails["status"],
796
+ error?: string,
797
+ ) => {
798
+ if (runSettled) return false;
799
+ runSettled = true;
800
+ if (emitTimer) {
801
+ clearTimeout(emitTimer);
802
+ emitTimer = undefined;
803
+ }
804
+ controller.abort(
805
+ error ??
806
+ (status === "completed"
807
+ ? "Workflow completed"
808
+ : "Workflow was settled"),
809
+ );
810
+ for (const record of details.agents) {
811
+ if (record.state !== "running") continue;
812
+ record.state = "error";
813
+ record.error =
814
+ record.error ?? "Agent did not settle before run cleanup";
815
+ record.finishedAt = Date.now();
816
+ }
817
+ details.status = status;
818
+ details.finishedAt = Date.now();
819
+ if (error) details.error = sanitizeWorkflowDisplayLine(error);
820
+ return true;
821
+ };
822
+
823
+ const forceSettle = (error: string) => {
824
+ if (!terminalize("failed", error)) return;
825
+ try {
826
+ persistence.flush();
827
+ } catch (persistenceError) {
828
+ details.error = `${error}; artifact persistence failed: ${errorText(persistenceError)}`;
829
+ }
830
+ flushNow(true);
831
+ };
832
+
833
+ const phaseFn = (title: unknown) => {
834
+ if (runSettled) return;
835
+ const text = sanitizeLine(String(title), 160);
836
+ if (!text) return;
837
+ details.currentPhase = text;
838
+ if (!details.phases.some((p) => p.title === text))
839
+ details.phases.push({ title: text });
840
+ emit();
841
+ };
842
+
843
+ // The script's narrator. Unlike phase(), this is append-only progress
844
+ // text, so it never mutates the phase list a run is judged against.
845
+ const logFn = (text: string) => {
846
+ if (runSettled) return;
847
+ appendLog(details, text, Date.now());
848
+ emit();
849
+ };
850
+
851
+ // One reader per run: it carries a high-water mark, because per-agent
852
+ // usage is recomputed from a message list that compaction shrinks.
853
+ const readUsage = createUsageReader(details.agents);
854
+
855
+ let agentCounter = 0;
856
+ const agentFn = async (
857
+ promptValue: unknown,
858
+ optsValue: unknown = {},
859
+ invocationSignal?: AbortSignal,
860
+ ): Promise<ScriptAgentResult> => {
861
+ const index = ++agentCounter;
862
+ const opts: AgentCallOptions =
863
+ optsValue && typeof optsValue === "object"
864
+ ? (optsValue as AgentCallOptions)
865
+ : {};
866
+ const requestedLabel =
867
+ typeof opts.label === "string" ? sanitizeLine(opts.label, 160) : "";
868
+ const label = requestedLabel || `agent-${index}`;
869
+
870
+ if (runSettled || controller.signal.aborted) {
871
+ return {
872
+ ok: false,
873
+ output: "",
874
+ error: "Workflow was aborted before this agent started",
875
+ };
876
+ }
877
+ const record: AgentRecord = {
878
+ index,
879
+ label,
880
+ phase:
881
+ typeof opts.phase === "string"
882
+ ? sanitizeLine(opts.phase, 160) || undefined
883
+ : details.currentPhase,
884
+ state: "running",
885
+ model: ctx.model?.id,
886
+ contextWindow: ctx.model?.contextWindow,
887
+ startedAt: Date.now(),
888
+ preview: "",
889
+ usage: emptyUsage(),
890
+ transcript: [],
891
+ };
892
+ details.agents.push(record);
893
+ persistence.checkpoint({ immediate: true });
894
+ emit(false);
895
+
896
+ const fail = (error: string): ScriptAgentResult => {
897
+ if (record.state === "running" && !runSettled) {
898
+ record.state = "error";
899
+ record.error = sanitizeWorkflowDisplayLine(error);
900
+ record.finishedAt = Date.now();
901
+ emit();
902
+ }
903
+ return { ok: false, output: "", error };
904
+ };
905
+
906
+ const basePrompt =
907
+ typeof promptValue === "string"
908
+ ? promptValue
909
+ : String(promptValue ?? "");
910
+ if (!basePrompt.trim())
911
+ return fail("agent() requires a non-empty prompt string");
912
+ let acceptanceContract: ReturnType<typeof parseAcceptanceContract>;
913
+ let effectiveSchema: unknown;
914
+ try {
915
+ acceptanceContract = parseAcceptanceContract(opts.acceptance);
916
+ effectiveSchema = acceptanceContract
917
+ ? acceptanceSchema(opts.schema, acceptanceContract)
918
+ : opts.schema;
919
+ } catch (error) {
920
+ return fail(`agent "${label}": ${errorText(error)}`);
921
+ }
922
+ const prompt = buildWorkflowAgentPrompt(
923
+ acceptanceContract
924
+ ? `${basePrompt}\n\n${acceptanceInstruction(acceptanceContract)}`
925
+ : basePrompt,
926
+ );
927
+ if (controller.signal.aborted)
928
+ return fail("Workflow was aborted before this agent started");
929
+
930
+ const requestedType =
931
+ typeof opts.agent_type === "string"
932
+ ? opts.agent_type.trim()
933
+ : undefined;
934
+ if (opts.agent_type !== undefined && !requestedType) {
935
+ return fail(
936
+ `agent "${label}": agent_type must be a non-empty string`,
937
+ );
938
+ }
939
+ const agentType = requestedType
940
+ ? runAgentTypes.get(requestedType)
941
+ : undefined;
942
+ if (requestedType && !agentType) {
943
+ return fail(
944
+ `agent "${label}": unknown agent_type "${requestedType}" (available: ${[...runAgentTypes.keys()].join(", ")})`,
945
+ );
946
+ }
947
+
948
+ const explicitModel =
949
+ typeof opts.model === "string" && opts.model.trim()
950
+ ? opts.model.trim()
951
+ : undefined;
952
+ const explicitProvider =
953
+ typeof opts.provider === "string" && opts.provider.trim()
954
+ ? opts.provider.trim()
955
+ : undefined;
956
+ if (opts.model !== undefined && !explicitModel) {
957
+ return fail(`agent "${label}": model must be a non-empty string`);
958
+ }
959
+ if (opts.provider !== undefined && !explicitProvider) {
960
+ return fail(`agent "${label}": provider must be a non-empty string`);
961
+ }
962
+ if (explicitProvider && !explicitModel) {
963
+ return fail(
964
+ `agent "${label}": \`provider\` requires \`model\` as well`,
965
+ );
966
+ }
967
+
968
+ const explicitModelHint =
969
+ explicitModel && explicitProvider
970
+ ? `${explicitProvider}/${explicitModel}`
971
+ : explicitModel;
972
+ const modelHint = selectSubagentModel(
973
+ explicitModelHint,
974
+ agentType,
975
+ roleModelForAgentType(
976
+ agentType,
977
+ loadSetupConfig().subagents.roleModels,
978
+ ),
979
+ );
980
+ const explicitEffort =
981
+ typeof opts.effort === "string" && opts.effort.trim()
982
+ ? opts.effort.trim()
983
+ : undefined;
984
+ if (opts.effort !== undefined && !explicitEffort) {
985
+ return fail(`agent "${label}": effort must be a non-empty string`);
986
+ }
987
+ // Resolve every output-affecting default before replay lookup.
988
+ let model: WorkflowModel | undefined = ctx.model;
989
+ if (modelHint !== undefined) {
990
+ try {
991
+ model = resolveAgentModel(
992
+ ctx.modelRegistry,
993
+ modelHint,
994
+ ctx.model
995
+ ? { provider: ctx.model.provider, id: ctx.model.id }
996
+ : undefined,
997
+ );
998
+ } catch (error) {
999
+ return fail(`agent "${label}": ${errorText(error)}`);
1000
+ }
1001
+ }
1002
+ const effectiveEffort = explicitEffort ?? agentType?.reasoningEffort;
1003
+ let thinkingLevel: ThinkingLevel = pi.getThinkingLevel();
1004
+ if (effectiveEffort !== undefined) {
1005
+ const effort = String(effectiveEffort);
1006
+ if (!(THINKING_LEVELS as readonly string[]).includes(effort)) {
1007
+ return fail(
1008
+ `agent "${label}": invalid effort "${effort}" (use ${THINKING_LEVELS.join("|")})`,
1009
+ );
1010
+ }
1011
+ thinkingLevel = effort as ThinkingLevel;
1012
+ }
1013
+ record.model = model?.id;
1014
+ record.contextWindow = model?.contextWindow;
1015
+
1016
+ // Replay is deliberately narrower than execution: only a named type
1017
+ // whose effective tool allowlist is entirely known read-only can be
1018
+ // cached. General-purpose children inherit bash/edit/write, custom
1019
+ // tools have unknown effects, and worktrees have state a string result
1020
+ // cannot restore.
1021
+ const replaySafe = isReplaySafeAgentCall({
1022
+ tools: agentType?.tools,
1023
+ isolation: opts.isolation,
1024
+ });
1025
+ const replayLease = beginProcessReplayWorkspaceLease(replaySafe);
1026
+ let replayResources:
1027
+ Awaited<ReturnType<typeof getResources>> | undefined;
1028
+ let replayIdentity: ReturnType<typeof createReplayIdentity> | undefined;
1029
+ if (replaySafe) {
1030
+ try {
1031
+ replayResources = await getResources(
1032
+ effectiveSchema !== undefined,
1033
+ ctx.cwd,
1034
+ agentType?.body,
1035
+ );
1036
+ replayIdentity = createReplayIdentity(
1037
+ ctx.cwd,
1038
+ replayResources.loader,
1039
+ projectTrusted,
1040
+ );
1041
+ } catch {
1042
+ // Fingerprinting is an optimization boundary. If resources cannot
1043
+ // be resolved, run normally and let the execution path report any
1044
+ // real resource error instead of trusting an unverifiable hit.
1045
+ }
1046
+ }
1047
+ const replayKey = (
1048
+ identity: NonNullable<ReturnType<typeof createReplayIdentity>>,
1049
+ ) =>
1050
+ agentCallKey(prompt, {
1051
+ ...opts,
1052
+ execution: {
1053
+ agentType: agentType
1054
+ ? {
1055
+ name: agentType.name,
1056
+ body: agentType.body,
1057
+ tools: agentType.tools,
1058
+ }
1059
+ : undefined,
1060
+ model: model ? `${model.provider}/${model.id}` : undefined,
1061
+ effort: thinkingLevel,
1062
+ acceptance: acceptanceContract,
1063
+ replayIdentity: identity,
1064
+ },
1065
+ });
1066
+ const callKey = replayIdentity ? replayKey(replayIdentity) : undefined;
1067
+ let replayBoundaryViolated = false;
1068
+ // Checked before controller.schedule on purpose: schedule() charges the
1069
+ // run's agent-call budget on entry, and a replayed call runs no agent.
1070
+ const cached =
1071
+ callKey && replayLease.canReplay ? replay?.take(callKey) : undefined;
1072
+ if (cached) {
1073
+ record.state = "done";
1074
+ record.replayed = true;
1075
+ record.finishedAt = Date.now();
1076
+ record.preview = sanitizeWorkflowDisplayText(
1077
+ cached.output,
1078
+ PREVIEW_LENGTH,
1079
+ );
1080
+ if (acceptanceContract) {
1081
+ record.acceptance = evaluateAcceptance(
1082
+ acceptanceContract,
1083
+ cached.structured,
1084
+ );
1085
+ }
1086
+ emit();
1087
+ // Re-journal so a chain of resumes keeps working: run C resuming from
1088
+ // B still finds what B replayed from A.
1089
+ journalEntries.push(cached);
1090
+ replayLease.end();
1091
+ return {
1092
+ ok: true,
1093
+ output: cached.output,
1094
+ ...(cached.structured !== undefined
1095
+ ? { structured: cached.structured }
1096
+ : {}),
1097
+ ...(record.acceptance ? { acceptance: record.acceptance } : {}),
1098
+ };
1099
+ }
1100
+
1101
+ return controller
1102
+ .schedule(async (runSignal) => {
1103
+ record.model = model?.id;
1104
+ record.contextWindow = model?.contextWindow;
1105
+ emit();
1106
+
1107
+ /**
1108
+ * Isolation is requested, not best-effort: the caller asks for a
1109
+ * worktree exactly because concurrent stages would otherwise
1110
+ * collide in one checkout, so silently sharing `ctx.cwd` would
1111
+ * hand back the hazard they were avoiding. Fail this one agent
1112
+ * with git's reason; siblings are unaffected.
1113
+ */
1114
+ let worktree: Worktree | undefined;
1115
+ if (opts.isolation !== undefined) {
1116
+ if (opts.isolation !== "worktree") {
1117
+ return fail(
1118
+ `agent "${label}": invalid isolation "${String(opts.isolation)}" (the only value is "worktree")`,
1119
+ );
1120
+ }
1121
+ const created = await createWorktree({
1122
+ cwd: ctx.cwd,
1123
+ label,
1124
+ id: `${details.runId}-${record.index}`,
1125
+ });
1126
+ if (!created.ok) {
1127
+ return fail(
1128
+ `agent "${label}": isolation "worktree" requested but could not be created (${created.reason})`,
1129
+ );
1130
+ }
1131
+ worktree = created.worktree;
1132
+ if (!runSettled) record.worktreeBranch = worktree.branch;
1133
+ }
1134
+ if (runSignal.aborted || runSettled) {
1135
+ throw runSignal.reason instanceof Error
1136
+ ? runSignal.reason
1137
+ : new Error("Workflow was aborted");
1138
+ }
1139
+ const agentCwd = worktree?.path ?? ctx.cwd;
1140
+
1141
+ // Inside the try, not before it: building resources can throw
1142
+ // (bad settings, an unreadable skills dir), and a throw out here
1143
+ // would skip the finally and leak the worktree permanently —
1144
+ // nothing sweeps `.git/pi-worktrees/` afterwards.
1145
+ try {
1146
+ let rejectResourceLoad: (() => void) | undefined;
1147
+ const resourceAbort = new Promise<never>((_resolve, reject) => {
1148
+ rejectResourceLoad = () =>
1149
+ reject(
1150
+ runSignal.reason instanceof Error
1151
+ ? runSignal.reason
1152
+ : new Error("Workflow was aborted"),
1153
+ );
1154
+ runSignal.addEventListener("abort", rejectResourceLoad, {
1155
+ once: true,
1156
+ });
1157
+ if (runSignal.aborted) queueMicrotask(rejectResourceLoad);
1158
+ });
1159
+ const resources = await Promise.race([
1160
+ replayResources ??
1161
+ getResources(
1162
+ effectiveSchema !== undefined,
1163
+ agentCwd,
1164
+ agentType?.body,
1165
+ ),
1166
+ resourceAbort,
1167
+ ]).finally(() => {
1168
+ if (rejectResourceLoad) {
1169
+ runSignal.removeEventListener("abort", rejectResourceLoad);
1170
+ }
1171
+ });
1172
+ if (runSettled) {
1173
+ throw new Error("Workflow was settled before agent creation");
1174
+ }
1175
+ const outcome = await runAgent({
1176
+ prompt,
1177
+ schema: effectiveSchema,
1178
+ model,
1179
+ thinkingLevel,
1180
+ // Replay-safe calls use the same canonical cwd as the
1181
+ // identity and filesystem boundary, so a symlink spelling of
1182
+ // the checkout cannot retarget relative tool paths.
1183
+ cwd: replayIdentity?.cwd ?? agentCwd,
1184
+ loader: resources.loader,
1185
+ settingsManager: resources.settingsManager,
1186
+ modelRegistry: ctx.modelRegistry,
1187
+ ...(agentType?.tools ? { tools: agentType.tools } : {}),
1188
+ ...(replayIdentity
1189
+ ? {
1190
+ replayFilesystemBoundary: {
1191
+ repositoryRoot: replayIdentity.repositoryRoot,
1192
+ cwd: replayIdentity.cwd,
1193
+ onViolation: () => {
1194
+ replayBoundaryViolated = true;
1195
+ },
1196
+ },
1197
+ }
1198
+ : {}),
1199
+ signal: runSignal,
1200
+ onProgress: (progress) => {
1201
+ if (runSettled || record.state !== "running") return;
1202
+ record.preview = sanitizeWorkflowDisplayText(
1203
+ progress.preview,
1204
+ PREVIEW_LENGTH,
1205
+ );
1206
+ record.usage = progress.usage;
1207
+ record.model = progress.model ?? record.model;
1208
+ record.contextWindow =
1209
+ progress.contextWindow ?? record.contextWindow;
1210
+ record.transcript = progress.transcript;
1211
+ emit();
1212
+ },
1213
+ });
1214
+
1215
+ if (runSettled || record.state !== "running") {
1216
+ return {
1217
+ ok: false,
1218
+ output: "",
1219
+ error: "Agent completed after workflow settlement",
1220
+ };
1221
+ }
1222
+ record.usage = outcome.usage;
1223
+ record.model = outcome.model ?? record.model;
1224
+ record.contextWindow =
1225
+ outcome.contextWindow ?? record.contextWindow;
1226
+ record.transcript = outcome.transcript;
1227
+ record.preview = sanitizeWorkflowDisplayText(
1228
+ outcome.output || record.preview,
1229
+ PREVIEW_LENGTH,
1230
+ );
1231
+ record.finishedAt = Date.now();
1232
+ const judged = applyAcceptance({
1233
+ contract: acceptanceContract,
1234
+ structured: outcome.structured,
1235
+ agentOk: outcome.ok,
1236
+ ...(outcome.error ? { agentError: outcome.error } : {}),
1237
+ });
1238
+ const acceptance = judged.ledger;
1239
+ if (acceptance) record.acceptance = acceptance;
1240
+ const outcomeOk = judged.ok;
1241
+ record.state = outcomeOk ? "done" : "error";
1242
+ if (outcomeOk) delete record.error;
1243
+ else
1244
+ record.error = judged.error
1245
+ ? sanitizeWorkflowDisplayLine(judged.error)
1246
+ : undefined;
1247
+ emit();
1248
+
1249
+ // Only provably read-only successes with a complete, stable
1250
+ // identity are journaled. Recheck after execution so a concurrent
1251
+ // writer cannot leave a result keyed to the state from before it
1252
+ // ran. Re-running a failure is usually why someone resumes;
1253
+ // writable, unrestricted, unknown-tool, isolated, changed, and
1254
+ // unfingerprintable calls always run for real.
1255
+ const completedIdentity = callKey
1256
+ ? createReplayIdentity(
1257
+ ctx.cwd,
1258
+ resources.loader,
1259
+ projectTrusted,
1260
+ )
1261
+ : undefined;
1262
+ const completedKey = completedIdentity
1263
+ ? replayKey(completedIdentity)
1264
+ : undefined;
1265
+ if (
1266
+ !runSettled &&
1267
+ outcomeOk &&
1268
+ completedKey !== undefined &&
1269
+ completedKey === callKey &&
1270
+ !replayBoundaryViolated &&
1271
+ replayLease.canJournal()
1272
+ ) {
1273
+ journalEntries.push({
1274
+ key: completedKey,
1275
+ output: outcome.output,
1276
+ ...(outcome.structured !== undefined
1277
+ ? { structured: outcome.structured }
1278
+ : {}),
1279
+ });
1280
+ }
1281
+
1282
+ return {
1283
+ ok: outcomeOk,
1284
+ output: outcome.output,
1285
+ ...(outcome.structured !== undefined
1286
+ ? { structured: outcome.structured }
1287
+ : {}),
1288
+ ...(acceptance ? { acceptance } : {}),
1289
+ ...(record.error !== undefined ? { error: record.error } : {}),
1290
+ };
1291
+ } finally {
1292
+ // Reclaim as this agent settles, not at run end: a pipeline can
1293
+ // hold many worktrees open at once. Cleanup must never turn a
1294
+ // finished agent into a failed one, so failures only downgrade
1295
+ // what we report about the worktree.
1296
+ if (worktree) {
1297
+ const prepared = prepareWorktreeHandoff({
1298
+ runDir,
1299
+ runId: details.runId,
1300
+ agentIndex: record.index,
1301
+ agentLabel: record.label,
1302
+ repoCwd: ctx.cwd,
1303
+ worktree,
1304
+ });
1305
+ let cleanup: WorktreeCleanup;
1306
+ if (!prepared.ok) {
1307
+ cleanup = {
1308
+ removed: false,
1309
+ branchDeleted: false,
1310
+ reason: `handoff capture failed; preserved checkout: ${prepared.reason}`,
1311
+ branch: worktree.branch,
1312
+ ...(worktree.baseSha ? { baseSha: worktree.baseSha } : {}),
1313
+ detached: false,
1314
+ };
1315
+ } else {
1316
+ cleanup = await reclaimWorktree(ctx.cwd, worktree).catch(
1317
+ (error): WorktreeCleanup => ({
1318
+ removed: false,
1319
+ branchDeleted: false,
1320
+ reason: `worktree cleanup failed: ${errorText(error)}`,
1321
+ branch: worktree.branch,
1322
+ ...(worktree.baseSha
1323
+ ? { baseSha: worktree.baseSha }
1324
+ : {}),
1325
+ detached: false,
1326
+ }),
1327
+ );
1328
+ try {
1329
+ finalizeWorktreeHandoff(prepared, cleanup);
1330
+ record.worktreeHandoffArtifact = prepared.artifact;
1331
+ } catch (error) {
1332
+ cleanup = {
1333
+ ...cleanup,
1334
+ reason: `handoff finalization failed: ${errorText(error)}${cleanup.reason ? `; ${cleanup.reason}` : ""}`,
1335
+ };
1336
+ }
1337
+ }
1338
+ if (!runSettled) {
1339
+ record.worktreeCleanup = cleanup;
1340
+ if (cleanup.branchDeleted) delete record.worktreeBranch;
1341
+ else record.worktreeBranch = cleanup.branch;
1342
+ if (!cleanup.removed) record.worktreePath = worktree.path;
1343
+ emit();
1344
+ }
1345
+ }
1346
+ }
1347
+ }, invocationSignal)
1348
+ .catch((error) => fail(errorText(error)))
1349
+ .finally(() => replayLease.end());
1350
+ };
1351
+
1352
+ const runScript = async () => {
1353
+ let status: WorkflowDetails["status"] = "completed";
1354
+ try {
1355
+ const result = await runWorkflowSandbox({
1356
+ source: prepared.source,
1357
+ args,
1358
+ cwd: ctx.cwd,
1359
+ signal: controller.signal,
1360
+ onAgent: agentFn,
1361
+ onPhase: phaseFn,
1362
+ onLog: logFn,
1363
+ usageSnapshot: readUsage,
1364
+ maxConcurrency: workflowConfig.concurrency,
1365
+ maxAgentCalls: workflowConfig.maxAgentCalls,
1366
+ // Replays send an IPC message but spend no controller budget, so
1367
+ // the sandbox's backstop has to know how many to expect.
1368
+ extraAgentRequests: replay?.available ?? 0,
1369
+ });
1370
+ if (!runSettled) details.result = result;
1371
+ } catch (error) {
1372
+ if (!runSettled) {
1373
+ details.error = errorText(error);
1374
+ status = controller.signal.aborted ? "aborted" : "failed";
1375
+ controller.abort("Workflow script failed");
1376
+ }
1377
+ }
1378
+
1379
+ const settled = await controller.settle({
1380
+ abort: status !== "completed",
1381
+ });
1382
+ if (runSettled) return;
1383
+ if (!settled) {
1384
+ status = "failed";
1385
+ details.error = details.error
1386
+ ? `${details.error}; agent shutdown deadline exceeded`
1387
+ : "Agent shutdown deadline exceeded";
1388
+ }
1389
+ if (runSettled) return;
1390
+ terminalize(status, details.error);
1391
+ try {
1392
+ persistence.flush();
1393
+ } catch (error) {
1394
+ details.status = "failed";
1395
+ details.error = `Artifact persistence failed: ${errorText(error)}`;
1396
+ throw new Error(details.error);
1397
+ } finally {
1398
+ flushNow(true);
1399
+ }
1400
+ };
1401
+
1402
+ // Registered for /workflows visibility and session_shutdown abort;
1403
+ // blocking runs are watchable live from the dashboard too.
1404
+ const activeRun: ActiveWorkflowRunLifecycle = {
1405
+ details,
1406
+ controller,
1407
+ forceSettle,
1408
+ };
1409
+ activeRuns.set(runId, activeRun);
1410
+ const completion = runScript();
1411
+ activeRun.completion = completion;
1412
+ if (ctx.hasUI) lastContext = ctx;
1413
+ updateIndicator();
1414
+
1415
+ if (background) {
1416
+ void completion
1417
+ .catch((error) => {
1418
+ details.status = "failed";
1419
+ details.finishedAt = Date.now();
1420
+ details.error = details.error ?? errorText(error);
1421
+ })
1422
+ .finally(() => {
1423
+ activeRuns.delete(runId);
1424
+ recordSettledRun(details);
1425
+ updateIndicator();
1426
+ try {
1427
+ // Deliver like the subagent/terminal families: a custom-typed
1428
+ // session message with a dedicated renderer, not a plain
1429
+ // user-provenance turn.
1430
+ //
1431
+ // Wake the model only if it is idle and therefore plausibly
1432
+ // waiting on this run. If it is busy with something else, the
1433
+ // result still enters context with the user's next message
1434
+ // (nextTurn) instead of forcing a turn it can only acknowledge.
1435
+ const wake = ctx.isIdle();
1436
+ pi.sendMessage(
1437
+ {
1438
+ customType: "workflow-result",
1439
+ content: buildBackgroundWorkflowFollowUp({
1440
+ runId,
1441
+ name: details.name,
1442
+ status: details.status,
1443
+ result: buildWorkflowResultMessage(details, runDir),
1444
+ }),
1445
+ display: true,
1446
+ details: compactToolDetails(details),
1447
+ },
1448
+ wake
1449
+ ? { deliverAs: "followUp", triggerTurn: true }
1450
+ : { deliverAs: "nextTurn" },
1451
+ );
1452
+ } catch {
1453
+ // Session may be shutting down.
1454
+ }
1455
+ });
1456
+ return {
1457
+ content: [
1458
+ {
1459
+ type: "text",
1460
+ text: buildBackgroundWorkflowLaunchResult({
1461
+ runId,
1462
+ name: details.name,
1463
+ runDir,
1464
+ }),
1465
+ },
1466
+ ],
1467
+ details: compactToolDetails(details),
1468
+ };
1469
+ }
1470
+
1471
+ try {
1472
+ await completion;
1473
+ } finally {
1474
+ activeRuns.delete(runId);
1475
+ recordSettledRun(details);
1476
+ updateIndicator();
1477
+ }
1478
+ if (details.status !== "completed") {
1479
+ // Pi marks tool failures only when execute throws; returning isError is
1480
+ // ignored by the extension API.
1481
+ throw new Error(buildWorkflowResultMessage(details, runDir));
1482
+ }
1483
+ return {
1484
+ content: [
1485
+ {
1486
+ type: "text",
1487
+ text: buildWorkflowResultMessage(details, runDir),
1488
+ },
1489
+ ],
1490
+ details: compactToolDetails(details),
1491
+ };
1492
+ },
1493
+
1494
+ renderCall(args: Partial<WorkflowInput>, theme) {
1495
+ const meta =
1496
+ typeof args.script === "string"
1497
+ ? extractMeta(args.script)
1498
+ : { phases: [] };
1499
+ let text =
1500
+ theme.fg("toolTitle", theme.bold("workflow ")) +
1501
+ theme.fg("accent", (meta as WorkflowMeta).name ?? "(script)");
1502
+ if (args.background) text += theme.fg("dim", " (background)");
1503
+ const description = (meta as WorkflowMeta).description;
1504
+ if (description) text += `\n ${theme.fg("dim", description)}`;
1505
+ for (const phase of meta.phases.slice(0, 8)) {
1506
+ text += `\n ${theme.fg("dim", SQUARE)} ${theme.fg("accent", phase.title)}${
1507
+ phase.detail ? theme.fg("dim", ` — ${phase.detail}`) : ""
1508
+ }`;
1509
+ }
1510
+ return new Text(text, 0, 0);
1511
+ },
1512
+
1513
+ renderResult(result, { expanded }, theme) {
1514
+ const details = result.details as WorkflowDetails | undefined;
1515
+ if (!details) {
1516
+ const first = result.content[0];
1517
+ return new Text(
1518
+ first?.type === "text" ? first.text : "(no output)",
1519
+ 0,
1520
+ 0,
1521
+ );
1522
+ }
1523
+
1524
+ const { done, failed } = countStates(details);
1525
+ const settled = done + failed;
1526
+ const elapsed = formatElapsed(details.startedAt, details.finishedAt);
1527
+ let header =
1528
+ `${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` +
1529
+ `${theme.fg(
1530
+ "accent",
1531
+ sanitizeWorkflowDisplayLine(details.name ?? details.runId),
1532
+ )} ` +
1533
+ theme.fg(
1534
+ "dim",
1535
+ `${settled}/${details.agents.length} agents · ${elapsed} · `,
1536
+ ) +
1537
+ theme.fg(statusColor(details.status), statusWord(details.status));
1538
+ if (failed) header += theme.fg("error", ` · ${failed} failed`);
1539
+ if (details.background) header += theme.fg("dim", " (background)");
1540
+ if (details.status === "running" && details.currentPhase) {
1541
+ header += theme.fg(
1542
+ "muted",
1543
+ ` · ${sanitizeWorkflowDisplayLine(details.currentPhase)}`,
1544
+ );
1545
+ }
1546
+ const totals = formatUsage(aggregateUsage(details.agents));
1547
+
1548
+ if (!expanded) {
1549
+ let text = header;
1550
+ for (const agent of details.agents) {
1551
+ const context = agentContext(agent);
1552
+ text += `\n ${stateSquare(agent.state, theme)} ${theme.fg(
1553
+ "accent",
1554
+ sanitizeWorkflowDisplayLine(agent.label),
1555
+ )}${
1556
+ agent.phase
1557
+ ? theme.fg(
1558
+ "dim",
1559
+ ` (${sanitizeWorkflowDisplayLine(agent.phase)})`,
1560
+ )
1561
+ : ""
1562
+ }${theme.fg(
1563
+ "dim",
1564
+ `${context ? ` · ${context}` : ""} · ${formatElapsed(agent.startedAt, agent.finishedAt)}`,
1565
+ )}`;
1566
+ }
1567
+ // Only the tail collapsed: the newest lines are the ones that say
1568
+ // where the run is now.
1569
+ for (const entry of (details.logs ?? []).slice(-3)) {
1570
+ text += `\n ${theme.fg("muted", "›")} ${theme.fg(
1571
+ "dim",
1572
+ sanitizeWorkflowDisplayLine(entry.text),
1573
+ )}`;
1574
+ }
1575
+ if (totals) text += `\n ${theme.fg("dim", `Total: ${totals}`)}`;
1576
+ if (details.error)
1577
+ text += `\n ${theme.fg(
1578
+ "error",
1579
+ `Error: ${sanitizeWorkflowDisplayLine(details.error)}`,
1580
+ )}`;
1581
+ text += `\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`;
1582
+ return new Text(text, 0, 0);
1583
+ }
1584
+
1585
+ const container = new Container();
1586
+ container.addChild(new Text(header, 0, 0));
1587
+ if (details.description) {
1588
+ container.addChild(
1589
+ new Text(
1590
+ theme.fg("dim", sanitizeWorkflowDisplayLine(details.description)),
1591
+ 0,
1592
+ 0,
1593
+ ),
1594
+ );
1595
+ }
1596
+
1597
+ for (const group of phaseGroups(details)) {
1598
+ container.addChild(new Spacer(1));
1599
+ container.addChild(
1600
+ new Text(
1601
+ theme.fg(
1602
+ "muted",
1603
+ `─── ${sanitizeWorkflowDisplayLine(group.title)} ───`,
1604
+ ),
1605
+ 0,
1606
+ 0,
1607
+ ),
1608
+ );
1609
+ for (const agent of group.agents) {
1610
+ const usage = formatUsage(agent.usage, agent.model);
1611
+ const context = agentContext(agent);
1612
+ let line = `${stateSquare(agent.state, theme)} ${theme.fg(
1613
+ "accent",
1614
+ sanitizeWorkflowDisplayLine(agent.label),
1615
+ )} ${theme.fg(
1616
+ "dim",
1617
+ [context, formatElapsed(agent.startedAt, agent.finishedAt)]
1618
+ .filter(Boolean)
1619
+ .join(" · "),
1620
+ )}`;
1621
+ if (usage)
1622
+ line += ` ${theme.fg("dim", sanitizeWorkflowDisplayLine(usage))}`;
1623
+ container.addChild(new Text(line, 0, 0));
1624
+ if (agent.error) {
1625
+ container.addChild(
1626
+ new Text(
1627
+ ` ${theme.fg("error", sanitizeWorkflowDisplayLine(agent.error))}`,
1628
+ 0,
1629
+ 0,
1630
+ ),
1631
+ );
1632
+ } else if (agent.preview) {
1633
+ const preview = sanitizeWorkflowDisplayText(
1634
+ agent.preview,
1635
+ PREVIEW_LENGTH,
1636
+ )
1637
+ .split("\n")
1638
+ .slice(0, 2)
1639
+ .join(" ");
1640
+ container.addChild(new Text(` ${theme.fg("dim", preview)}`, 0, 0));
1641
+ }
1642
+ }
1643
+ }
1644
+
1645
+ if (details.logs && details.logs.length > 0) {
1646
+ container.addChild(new Spacer(1));
1647
+ container.addChild(new Text(theme.fg("muted", "─── log ───"), 0, 0));
1648
+ if (details.logsDropped) {
1649
+ container.addChild(
1650
+ new Text(
1651
+ theme.fg(
1652
+ "dim",
1653
+ `(${details.logsDropped} earlier line(s) dropped)`,
1654
+ ),
1655
+ 0,
1656
+ 0,
1657
+ ),
1658
+ );
1659
+ }
1660
+ for (const entry of details.logs) {
1661
+ container.addChild(
1662
+ new Text(
1663
+ `${theme.fg("muted", "›")} ${theme.fg(
1664
+ "dim",
1665
+ sanitizeWorkflowDisplayLine(entry.text),
1666
+ )}`,
1667
+ 0,
1668
+ 0,
1669
+ ),
1670
+ );
1671
+ }
1672
+ }
1673
+
1674
+ if (details.error) {
1675
+ container.addChild(new Spacer(1));
1676
+ container.addChild(
1677
+ new Text(
1678
+ theme.fg(
1679
+ "error",
1680
+ `Error: ${sanitizeWorkflowDisplayLine(details.error)}`,
1681
+ ),
1682
+ 0,
1683
+ 0,
1684
+ ),
1685
+ );
1686
+ }
1687
+
1688
+ if (details.result !== undefined) {
1689
+ container.addChild(new Spacer(1));
1690
+ container.addChild(new Text(theme.fg("muted", "─── result ───"), 0, 0));
1691
+ container.addChild(
1692
+ new Markdown(
1693
+ `\`\`\`json\n${resultJson(details.result)}\n\`\`\``,
1694
+ 0,
1695
+ 0,
1696
+ getMarkdownTheme(),
1697
+ ),
1698
+ );
1699
+ }
1700
+
1701
+ if (totals) {
1702
+ container.addChild(new Spacer(1));
1703
+ container.addChild(new Text(theme.fg("dim", `Total: ${totals}`), 0, 0));
1704
+ }
1705
+ return container;
1706
+ },
1707
+ });
1708
+
1709
+ /** Resolve one run from live, settled, or persisted state. */
1710
+ const resolveRunDetails = (target: string) => {
1711
+ const base = path.join(getAgentDir(), "workflows");
1712
+ let persistedIds: string[] = [];
1713
+ try {
1714
+ persistedIds = fs.readdirSync(base).filter(isWorkflowRunId);
1715
+ } catch {
1716
+ // In-memory runs remain inspectable without the artifact directory.
1717
+ }
1718
+ const resolution = resolveWorkflowRunTarget(target, [
1719
+ ...activeRuns.keys(),
1720
+ ...settledRuns.keys(),
1721
+ ...persistedIds,
1722
+ ]);
1723
+ if (!resolution.ok) return resolution;
1724
+
1725
+ const active = activeRuns.get(resolution.runId);
1726
+ if (active) return { ok: true, details: active.details } as const;
1727
+ const settled = settledRuns.get(resolution.runId);
1728
+ if (settled) return { ok: true, details: settled } as const;
1729
+
1730
+ try {
1731
+ const parsed: unknown = JSON.parse(
1732
+ fs.readFileSync(
1733
+ path.join(base, resolution.runId, "workflow.json"),
1734
+ "utf8",
1735
+ ),
1736
+ );
1737
+ const details = normalizePersistedWorkflowDetails(
1738
+ resolution.runId,
1739
+ parsed,
1740
+ );
1741
+ if (!details) throw new Error("invalid workflow details");
1742
+ // A run absent from activeRuns cannot still be running this session; a
1743
+ // persisted "running" is a run that was hard-killed or missed the
1744
+ // shutdown settle deadline.
1745
+ return {
1746
+ ok: true,
1747
+ details:
1748
+ details.status === "running"
1749
+ ? { ...details, status: "aborted" as const }
1750
+ : details,
1751
+ } as const;
1752
+ } catch {
1753
+ return {
1754
+ ok: false,
1755
+ error: `Workflow run ${resolution.runId} could not be read.`,
1756
+ } as const;
1757
+ }
1758
+ };
1759
+
1760
+ pi.registerTool({
1761
+ name: "workflow_stop",
1762
+ label: "Stop Workflow",
1763
+ description: WORKFLOW_STOP_TOOL_DESCRIPTION,
1764
+ promptSnippet: WORKFLOW_LIFECYCLE_PROMPT_SNIPPET,
1765
+ parameters: Type.Object({
1766
+ runId: Type.String({
1767
+ description: WORKFLOW_STOP_PARAMETER_DESCRIPTIONS.runId,
1768
+ }),
1769
+ }),
1770
+ execute(_toolCallId, params) {
1771
+ const running = [...activeRuns].filter(
1772
+ ([, run]) => run.details.status === "running",
1773
+ );
1774
+ const resolution = resolveWorkflowRunTarget(
1775
+ params.runId,
1776
+ running.map(([runId]) => runId),
1777
+ );
1778
+ if (!resolution.ok) throw new Error(resolution.error);
1779
+ stopRun(resolution.runId);
1780
+ return Promise.resolve({
1781
+ content: [
1782
+ {
1783
+ type: "text",
1784
+ text: `Stopping workflow ${resolution.runId}.`,
1785
+ },
1786
+ ],
1787
+ details: { runId: resolution.runId, status: "aborting" },
1788
+ });
1789
+ },
1790
+ });
1791
+
1792
+ pi.registerTool({
1793
+ name: "workflow_status",
1794
+ label: "Workflow Status",
1795
+ description: WORKFLOW_STATUS_TOOL_DESCRIPTION,
1796
+ parameters: Type.Object({
1797
+ runId: Type.Optional(
1798
+ Type.String({
1799
+ description: WORKFLOW_STATUS_PARAMETER_DESCRIPTIONS.runId,
1800
+ }),
1801
+ ),
1802
+ }),
1803
+ execute(_toolCallId, params) {
1804
+ // Details are a uniform run-summary array (one entry for a single-id peek)
1805
+ // so the tool has a single result shape; the text carries the detail.
1806
+ const summarize = (d: WorkflowDetails) => {
1807
+ const { done, failed } = countStates(d);
1808
+ return {
1809
+ runId: d.runId,
1810
+ name: d.name,
1811
+ status: d.status,
1812
+ done,
1813
+ failed,
1814
+ total: d.agents.length,
1815
+ };
1816
+ };
1817
+ if (params.runId) {
1818
+ const resolution = resolveRunDetails(params.runId);
1819
+ if (!resolution.ok) throw new Error(resolution.error);
1820
+ const details = resolution.details;
1821
+ const runDir = path.join(getAgentDir(), "workflows", details.runId);
1822
+ return Promise.resolve({
1823
+ content: [
1824
+ { type: "text", text: buildWorkflowResultMessage(details, runDir) },
1825
+ ],
1826
+ details: { runs: [summarize(details)] },
1827
+ });
1828
+ }
1829
+ const runs = [
1830
+ ...[...activeRuns.values()].map((run) => run.details),
1831
+ ...settledRuns.values(),
1832
+ ];
1833
+ if (runs.length === 0) {
1834
+ return Promise.resolve({
1835
+ content: [
1836
+ { type: "text", text: "No active or recently finished workflows." },
1837
+ ],
1838
+ details: { runs: [] },
1839
+ });
1840
+ }
1841
+ const lines = runs.map((d) => {
1842
+ const { done, failed } = countStates(d);
1843
+ return `${d.runId}${d.name ? ` "${d.name}"` : ""} — ${statusWord(d.status)} · ${done + failed}/${d.agents.length} agents${failed ? `, ${failed} failed` : ""}`;
1844
+ });
1845
+ return Promise.resolve({
1846
+ content: [{ type: "text", text: lines.join("\n") }],
1847
+ details: { runs: runs.map(summarize) },
1848
+ });
1849
+ },
1850
+ });
1851
+
1852
+ pi.registerMessageRenderer(
1853
+ "workflow-result",
1854
+ (message, { expanded }, theme) => {
1855
+ const details = message.details as WorkflowDetails | undefined;
1856
+ const body =
1857
+ typeof message.content === "string"
1858
+ ? message.content
1859
+ : (message.content
1860
+ ?.map((part) => (part.type === "text" ? part.text : ""))
1861
+ .join("") ?? "");
1862
+ const safeBody = sanitizeWorkflowDisplayText(body);
1863
+ if (!details) return new Text(safeBody, 0, 0);
1864
+ const { done, failed } = countStates(details);
1865
+ const settled = done + failed;
1866
+ let header =
1867
+ `${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` +
1868
+ `${theme.fg(
1869
+ "accent",
1870
+ sanitizeWorkflowDisplayLine(details.name ?? details.runId),
1871
+ )} ` +
1872
+ theme.fg("dim", `${settled}/${details.agents.length} agents · `) +
1873
+ theme.fg(statusColor(details.status), statusWord(details.status));
1874
+ if (failed) header += theme.fg("error", ` · ${failed} failed`);
1875
+ if (expanded) return new Text(`${header}\n\n${safeBody}`, 0, 0);
1876
+ const preview = safeBody.split("\n").slice(0, 8).join("\n");
1877
+ return new Text(
1878
+ `${header}\n${preview}\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`,
1879
+ 0,
1880
+ 0,
1881
+ );
1882
+ },
1883
+ );
1884
+ }