pi-extensible-workflows 3.1.0 → 3.3.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 (47) hide show
  1. package/README.md +11 -47
  2. package/dist/src/agent-execution.d.ts +4 -0
  3. package/dist/src/agent-execution.js +146 -60
  4. package/dist/src/bundles.d.ts +53 -0
  5. package/dist/src/bundles.js +457 -0
  6. package/dist/src/cli.d.ts +3 -1
  7. package/dist/src/cli.js +146 -21
  8. package/dist/src/doctor-cleanup.js +4 -2
  9. package/dist/src/eval-capture-extension.js +16 -1
  10. package/dist/src/execution.js +13 -4
  11. package/dist/src/host.d.ts +51 -8
  12. package/dist/src/host.js +1181 -487
  13. package/dist/src/index.d.ts +4 -2
  14. package/dist/src/index.js +3 -1
  15. package/dist/src/persistence.d.ts +40 -1
  16. package/dist/src/persistence.js +51 -0
  17. package/dist/src/session-inspector.d.ts +12 -2
  18. package/dist/src/session-inspector.js +36 -2
  19. package/dist/src/types.d.ts +19 -0
  20. package/dist/src/types.js +1 -0
  21. package/dist/src/validation.js +42 -2
  22. package/dist/src/workflow-artifacts.d.ts +13 -0
  23. package/dist/src/workflow-artifacts.js +39 -0
  24. package/dist/src/workflow-evals.d.ts +7 -1
  25. package/dist/src/workflow-evals.js +23 -3
  26. package/evals/cases/recovery-completed-worktree.yaml +17 -0
  27. package/evals/cases/recovery-failed-run.yaml +14 -0
  28. package/examples/workflow-extension-template/README.md +37 -0
  29. package/examples/workflow-extension-template/extension.test.mjs +59 -0
  30. package/examples/workflow-extension-template/index.js +51 -0
  31. package/examples/workflow-extension-template/roles/reviewer.md +4 -0
  32. package/package.json +3 -2
  33. package/skills/pi-extensible-workflows/SKILL.md +21 -28
  34. package/src/agent-execution.ts +102 -30
  35. package/src/bundles.ts +471 -0
  36. package/src/cli.ts +118 -21
  37. package/src/doctor-cleanup.ts +3 -2
  38. package/src/eval-capture-extension.ts +15 -1
  39. package/src/execution.ts +13 -4
  40. package/src/host.ts +992 -447
  41. package/src/index.ts +4 -2
  42. package/src/persistence.ts +53 -1
  43. package/src/session-inspector.ts +36 -4
  44. package/src/types.ts +12 -5
  45. package/src/validation.ts +33 -2
  46. package/src/workflow-artifacts.ts +34 -0
  47. package/src/workflow-evals.ts +24 -3
package/dist/src/host.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { existsSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
4
5
  import { dirname, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { Type } from "@earendil-works/pi-ai";
7
8
  import { Value } from "typebox/value";
8
- import { copyToClipboard, getAgentDir, highlightCode, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
9
+ import { copyToClipboard, getAgentDir, SettingsManager, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
9
10
  import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
10
11
  import { herdrPaneId, openHerdrPane } from "./herdr.js";
11
12
  import { acquireSessionLease, listRunIds, RunStore, structuralPath as operationPath } from "./persistence.js";
@@ -14,17 +15,21 @@ import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCod
14
15
  import { launchScriptForSnapshot, loadAgentDefinitions, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, saveModelAliases, validateAgentOptions, validateCheckpoint, validateModelAliasAvailability, validateShellOptions, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowPrompt, workflowSettingsPath } from "./validation.js";
15
16
  import { beginWorkflowExtensionLoading, loadingRegistry, resetWorkflowRegistry } from "./registry.js";
16
17
  import { agentIdentityPath, agentWorktree, encoded, executeShellCommand, persistActiveAgentAttempt, persistAgentAttempts, readShellResult, runWorkflow, shellIdentityPath } from "./execution.js";
17
- import { ERROR_CODES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WORKFLOW_AGENT_STATE_CHANGED_EVENT, WORKFLOW_BUDGET_EVENT, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, WORKFLOW_PHASE_CHANGED_EVENT, WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT, WORKFLOW_RUN_RESUMED_EVENT, WORKFLOW_RUN_STARTED_EVENT, WORKFLOW_RUN_STATE_CHANGED_EVENT, WORKFLOW_WORKTREE_CREATED_EVENT, WorkflowError } from "./types.js";
18
+ import { openWorkflowArtifact, workflowResultArtifact, workflowScriptArtifact } from "./workflow-artifacts.js";
19
+ import { ERROR_CODES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WORKFLOW_AGENT_STALL_THRESHOLD_MS, WORKFLOW_AGENT_STATE_CHANGED_EVENT, WORKFLOW_BUDGET_EVENT, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, WORKFLOW_PHASE_CHANGED_EVENT, WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT, WORKFLOW_RUN_RESUMED_EVENT, WORKFLOW_RUN_STARTED_EVENT, WORKFLOW_RUN_STATE_CHANGED_EVENT, WORKFLOW_WORKTREE_CREATED_EVENT, WorkflowError } from "./types.js";
18
20
  const SETTLED_AGENT_STATES = new Set(["completed", "failed", "cancelled"]);
21
+ const INTERNAL_WORKFLOW_TOOLS = ["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"];
22
+ const HARD_TERMINAL_RUN_STATES = new Set(["completed", "failed", "stopped"]);
23
+ const SHUTDOWN_TERMINAL_RUN_STATES = new Set(["completed", "failed", "stopped", "budget_exhausted"]);
19
24
  function snapshotResourcePolicy(snapshot, cwd, projectTrusted, globalSettingsPath) {
20
25
  const empty = { skills: [], extensions: [] };
21
- return { globalSettingsPath, projectSettingsPath: join(cwd, ".pi", "pi-extensible-workflows", "settings.json"), projectTrusted, global: empty, project: empty, effective: snapshot.settings.disabledAgentResources ?? empty, unmatchedSkills: [], unmatchedExtensions: [] };
26
+ return { globalSettingsPath, projectSettingsPath: workflowProjectSettingsPath(cwd), projectTrusted, global: empty, project: empty, effective: snapshot.settings.disabledAgentResources ?? empty, unmatchedSkills: [], unmatchedExtensions: [] };
22
27
  }
23
28
  const PLAIN_WORKFLOW_PROGRESS_STYLES = { accent: (text) => text, success: (text) => text, error: (text) => text, warning: (text) => text, muted: (text) => text, dim: (text) => text, bold: (text) => text };
24
29
  function workflowLaunchSettings(cwd, projectTrusted, globalSettingsPath, concurrency) {
25
30
  const resolution = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
26
31
  const settings = Object.freeze({ ...resolution.effective, ...(concurrency === undefined ? {} : { concurrency }) });
27
- return { settings, resolution, resourcePolicy: resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath), modelSettingsPath: resolution.sources.modelAliases };
32
+ return { settings, resolution, resourcePolicy: resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath) };
28
33
  }
29
34
  function frozenResourcePolicy(policy) { return () => structuredClone(policy); }
30
35
  function resumedSnapshotSettings(snapshot, resolution, modelAliases) {
@@ -81,9 +86,10 @@ function mainAgentError(error) {
81
86
  const typed = asWorkflowError(error);
82
87
  const presented = new WorkflowError(typed.code, formatWorkflowFailure(typed));
83
88
  Object.assign(presented, typed);
84
- presented.message = formatWorkflowFailure(typed);
85
89
  return presented;
86
90
  }
91
+ function workflowFailedAt(error) { return object(error) && typeof error.failedAt === "string" && error.failedAt ? error.failedAt : undefined; }
92
+ function persistedFailure(run, error) { const failedAt = workflowFailedAt(error); return { ...run, error: { code: error.code, message: error.message, ...(failedAt ? { failedAt } : {}) }, ...(failedAt ? { failedAt } : {}) }; }
87
93
  export class RunLifecycle {
88
94
  changed;
89
95
  #state;
@@ -144,7 +150,7 @@ export class RunLifecycle {
144
150
  await this.enter();
145
151
  }
146
152
  async terminal(state, reason) {
147
- if (["completed", "failed", "stopped"].includes(this.#state))
153
+ if (HARD_TERMINAL_RUN_STATES.has(this.#state))
148
154
  throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
149
155
  await this.#set(state, reason ?? state);
150
156
  for (const resolve of this.#waiters.splice(0))
@@ -165,20 +171,42 @@ export function formatWorkflowPreview(args) {
165
171
  return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
166
172
  }
167
173
  export const WORKFLOW_TOOL_LABEL = "Workflow";
168
- export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow";
169
- export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow that orchestrates subagents. Inline launches require an explicit non-empty name; registered function launches reject name and use workflow as the run name. Runs in the background by default; completion arrives as a follow-up message. Foreground results include the completed run ID. Use workflow_retry with an explicit failed run ID to replay completed structural operations; parentRunId only reuses named worktrees.";
174
+ export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline parallel-to-summary path by default";
175
+ export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow. Prefer a named inline script that fans out independent work with parallel(...), awaits the keyed results before interpolating them into one summarizing agent(...), and returns. Inline launches require an explicit non-empty name; registered function launches reject name and use workflow as the run name. Advanced controls include registered functions, outputSchema, budgets, checkpoints, worktrees, retry/resume, CLI export, and pipelines. Use workflow_retry with an explicit failed run ID; parentRunId only reuses named worktrees. Runs are in the background by default; completion arrives as a follow-up message. Set foreground: true when the caller must wait for the final value. If a foreground call detaches before its result is accepted, its terminal success or failure is promoted to one follow-up message. Foreground results include the completed run ID. Recovery inherits the source launch mode; legacy snapshots without launchMode recover in the background. Set foreground: true or false on workflow_resume/workflow_retry to override it; foreground recovery waits for terminal value and run details, while background recovery returns immediately with a follow-up. Recovery map: agent(..., { retries }) reruns one agent call in the same run for transient failures; workflow_retry({ runId, foreground? }) replays a failed run into a child; workflow_resume({ runId, budget?, foreground? }) continues a budget_exhausted run; parentRunId on a new launch only borrows named worktrees and never replays or resumes.";
176
+ function workflowRecoveryGuidance(action, state) {
177
+ if (action === "resume") {
178
+ if (state === "failed")
179
+ return "Failed workflow runs must use workflow_retry({ runId })";
180
+ if (state === "completed")
181
+ return "Completed workflow runs have no recovery action";
182
+ if (state === "stopped")
183
+ return "Stopped workflow runs have no recovery action; launch a new workflow";
184
+ if (state === "interrupted")
185
+ return "Interrupted workflow runs use /workflow resume, not workflow_resume";
186
+ return `Only budget-exhausted runs can be resumed with workflow_resume; source is ${state}`;
187
+ }
188
+ if (state === "budget_exhausted")
189
+ return "Budget-exhausted workflow runs must use workflow_resume({ runId, budget? })";
190
+ if (state === "completed")
191
+ return "Completed workflow runs have no recovery action";
192
+ if (state === "stopped")
193
+ return "Stopped workflow runs cannot be retried; launch a new workflow";
194
+ if (state === "interrupted")
195
+ return "Interrupted workflow runs use /workflow resume, not workflow_retry";
196
+ return `Only failed workflow runs can be retried; source is ${state}`;
197
+ }
170
198
  export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
171
- name: Type.Optional(Type.String({ description: "Required non-empty name for inline workflow runs; invalid for registered function launches" })),
199
+ name: Type.Optional(Type.String({ description: "Required non-empty name for the default inline workflow path; invalid for registered function launches" })),
172
200
  description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
173
- script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
174
- workflow: Type.Optional(Type.String({ description: "Registered reusable function as an unqualified name" })),
201
+ script: Type.Optional(Type.String({ description: "Immutable inline workflow source; default to a named script that fans out with parallel(...) and awaits results before passing them to a summarizing agent(...)" })),
202
+ workflow: Type.Optional(Type.String({ description: "Advanced: registered reusable function as an unqualified name" })),
175
203
  args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
176
- foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
177
- concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
178
- budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
179
- parentRunId: Type.Optional(Type.String({ description: "Terminal run whose named worktrees may be reused" })),
204
+ foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of the default background launch" })),
205
+ concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16, description: "Advanced: optional per-run active-agent limit" })),
206
+ budget: Type.Optional(Type.Unknown({ description: "Advanced: optional aggregate soft and hard run budgets" })),
207
+ parentRunId: Type.Optional(Type.String({ description: "Advanced: terminal run whose named worktrees may be reused" })),
180
208
  });
181
- export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
209
+ export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }), foreground: Type.Optional(Type.Boolean({ description: "Override the source launch mode for this recovery" })) });
182
210
  function phaseNames(source) {
183
211
  const phases = source === undefined ? [] : Array.isArray(source) ? source : source.phases ?? [];
184
212
  return phases.filter((phase) => typeof phase === "string" && phase.trim() !== "").map((phase) => phase.trim());
@@ -258,7 +286,7 @@ export function buildWorkflowPhaseModel(run, source) {
258
286
  const agents = observation?.agents ?? [];
259
287
  const counts = phaseAgentCounts(agents);
260
288
  const state = observation ? phaseState(run.state, counts, entry.observedIndex === observedEntries.length - 1) : "not started";
261
- return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, status: state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
289
+ return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
262
290
  });
263
291
  let currentPhaseIndex;
264
292
  for (let index = phases.length - 1; index >= 0; index -= 1) {
@@ -282,12 +310,197 @@ export function buildWorkflowPhaseModel(run, source) {
282
310
  result.unassignedAgents = unassignedAgents;
283
311
  return result;
284
312
  }
313
+ function workflowPhaseTreePath(kind, phaseId, operationPath, agentId) {
314
+ const root = `phase/${encodeURIComponent(phaseId)}`;
315
+ if (kind === "phase")
316
+ return root;
317
+ const operation = operationPath.map((part) => encodeURIComponent(part)).join("/");
318
+ if (kind === "operation")
319
+ return `${root}/operation/${operation}`;
320
+ return operation ? `${root}/operation/${operation}/agent/${encodeURIComponent(agentId ?? "")}` : `${root}/agent/${encodeURIComponent(agentId ?? "")}`;
321
+ }
322
+ function workflowPhaseTreeAggregateState(states) {
323
+ if (!states.length || states.every((state) => state === "completed"))
324
+ return "completed";
325
+ if (states.some((state) => state === "failed"))
326
+ return "failed";
327
+ if (states.some((state) => state === "cancelled"))
328
+ return "cancelled";
329
+ if (states.some((state) => state === "running"))
330
+ return "running";
331
+ return "queued";
332
+ }
333
+ export function buildWorkflowPhaseTree(model) {
334
+ const drafts = new Map();
335
+ const roots = [];
336
+ const add = (node, parentId) => {
337
+ const existing = drafts.get(node.id);
338
+ if (existing)
339
+ return existing;
340
+ const draft = { ...node, ...(parentId === undefined ? {} : { parentId }), children: [] };
341
+ drafts.set(draft.id, draft);
342
+ if (parentId === undefined)
343
+ roots.push(draft.id);
344
+ else
345
+ drafts.get(parentId)?.children.push(draft.id);
346
+ return draft;
347
+ };
348
+ const samePath = (left, right) => left.length === right.length && left.every((part, index) => part === right[index]);
349
+ const addPhase = (phaseId, label, agents, phase) => {
350
+ const phaseNode = add({ id: workflowPhaseTreePath("phase", phaseId, []), kind: "phase", label, depth: 0, phaseId, operationPath: [], state: phase?.state ?? workflowPhaseTreeAggregateState(agents.map((agent) => agent.state)), ...(phase ? { phase } : {}) });
351
+ const operationNodes = new Map();
352
+ const entries = agents.map((agent) => ({ agent, path: [...(agent.structuralPath ?? [])], node: undefined, defaultParentId: phaseNode.id }));
353
+ const agentEntries = new Map(entries.map((entry) => [entry.agent.id, entry]));
354
+ const acceptedParents = new Map();
355
+ const wouldCycle = (childId, parentId) => {
356
+ const seen = new Set([childId]);
357
+ let current = parentId;
358
+ while (current) {
359
+ if (seen.has(current))
360
+ return true;
361
+ seen.add(current);
362
+ current = acceptedParents.get(current);
363
+ }
364
+ return false;
365
+ };
366
+ for (const entry of entries) {
367
+ const parent = entry.agent.parentId ? agentEntries.get(entry.agent.parentId) : undefined;
368
+ if (parent && !wouldCycle(entry.agent.id, parent.agent.id))
369
+ acceptedParents.set(entry.agent.id, parent.agent.id);
370
+ }
371
+ const operationChain = (path, owner, startIndex = 0) => {
372
+ let parent = owner;
373
+ for (let index = startIndex; index < path.length; index += 1) {
374
+ const prefix = path.slice(0, index + 1);
375
+ const key = `${owner.id}:${JSON.stringify(prefix)}`;
376
+ const existing = operationNodes.get(key);
377
+ if (existing) {
378
+ parent = existing;
379
+ continue;
380
+ }
381
+ const suffix = path.slice(startIndex, index + 1).map((part) => encodeURIComponent(part)).join("/");
382
+ const id = owner.id === phaseNode.id ? workflowPhaseTreePath("operation", phaseId, prefix) : `${owner.id}/operation/${suffix}`;
383
+ const operation = add({ id, kind: "operation", label: prefix.at(-1) ?? "", depth: 0, phaseId, operationPath: prefix, state: "queued", ...(phase ? { phase } : {}) }, parent.id);
384
+ operationNodes.set(key, operation);
385
+ parent = operation;
386
+ }
387
+ return parent;
388
+ };
389
+ for (const entry of entries) {
390
+ if (!acceptedParents.has(entry.agent.id))
391
+ entry.defaultParentId = operationChain(entry.path, phaseNode).id;
392
+ }
393
+ for (const entry of entries) {
394
+ entry.node = add({ id: workflowPhaseTreePath("agent", phaseId, entry.path, entry.agent.id), kind: "agent", label: entry.agent.label ?? entry.agent.name, depth: 0, phaseId, operationPath: entry.path, state: entry.agent.state, agentId: entry.agent.id, agent: entry.agent }, phaseNode.id);
395
+ }
396
+ const attach = (entry, parentId) => {
397
+ const previous = entry.node.parentId ? drafts.get(entry.node.parentId) : undefined;
398
+ if (previous)
399
+ previous.children = previous.children.filter((childId) => childId !== entry.node.id);
400
+ entry.node.parentId = parentId;
401
+ const parent = drafts.get(parentId);
402
+ if (parent && !parent.children.includes(entry.node.id))
403
+ parent.children.push(entry.node.id);
404
+ };
405
+ for (const entry of entries) {
406
+ const parentId = acceptedParents.get(entry.agent.id);
407
+ const parent = parentId ? agentEntries.get(parentId) : undefined;
408
+ if (parent) {
409
+ if (samePath(entry.path, parent.path))
410
+ entry.defaultParentId = parent.node.id;
411
+ else {
412
+ const commonLength = entry.path.findIndex((part, index) => parent.path[index] !== part);
413
+ const startIndex = commonLength < 0 ? Math.min(entry.path.length, parent.path.length) : commonLength;
414
+ entry.defaultParentId = operationChain(entry.path, parent.node, startIndex).id;
415
+ }
416
+ }
417
+ attach(entry, entry.defaultParentId);
418
+ }
419
+ const setDepth = (node, depth, seen = new Set()) => {
420
+ if (seen.has(node.id))
421
+ return;
422
+ seen.add(node.id);
423
+ node.depth = depth;
424
+ for (const childId of node.children) {
425
+ const child = drafts.get(childId);
426
+ if (child)
427
+ setDepth(child, depth + 1, seen);
428
+ }
429
+ };
430
+ setDepth(phaseNode, 0);
431
+ const statesFor = (node, seen = new Set()) => {
432
+ if (seen.has(node.id))
433
+ return [];
434
+ const nextSeen = new Set(seen).add(node.id);
435
+ return node.children.flatMap((childId) => {
436
+ const child = drafts.get(childId);
437
+ return child?.kind === "agent" ? [child.agent?.state ?? "queued", ...statesFor(child, nextSeen)] : child ? statesFor(child, nextSeen) : [];
438
+ });
439
+ };
440
+ for (const operation of operationNodes.values())
441
+ operation.state = workflowPhaseTreeAggregateState(statesFor(operation));
442
+ };
443
+ for (const phase of model.phases)
444
+ addPhase(phase.id, `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`, phase.agents, phase);
445
+ if (model.unassignedAgents?.length)
446
+ addPhase("unassigned", "Unassigned", model.unassignedAgents);
447
+ const nodes = [...drafts.values()].map((node) => ({ ...node, children: [...node.children] }));
448
+ return { roots, nodes, byId: new Map(nodes.map((node) => [node.id, node])) };
449
+ }
450
+ export function workflowPhaseTreeVisibleNodes(tree, expanded = new Set()) {
451
+ const visible = [];
452
+ const visit = (id) => {
453
+ const node = tree.byId.get(id);
454
+ if (!node)
455
+ return;
456
+ visible.push(node);
457
+ if (expanded.has(node.id))
458
+ for (const childId of node.children)
459
+ visit(childId);
460
+ };
461
+ for (const root of tree.roots)
462
+ visit(root);
463
+ return visible;
464
+ }
465
+ export function workflowPhaseTreeInitialExpanded(tree) {
466
+ return new Set(tree.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
467
+ }
468
+ export function preserveWorkflowPhaseTreeSelection(tree, selection) {
469
+ const node = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? tree.nodes[0];
470
+ return node ? { nodeId: node.id } : {};
471
+ }
472
+ export function navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, direction) {
473
+ const expanded = new Set(expandedNodeIds);
474
+ const current = (selectedNodeId ? tree.byId.get(selectedNodeId) : undefined) ?? tree.nodes[0];
475
+ if (!current)
476
+ return { expandedNodeIds: expanded };
477
+ if (direction === "left") {
478
+ if (current.children.length && expanded.delete(current.id))
479
+ return { nodeId: current.id, expandedNodeIds: expanded };
480
+ return { nodeId: current.parentId ?? current.id, expandedNodeIds: expanded };
481
+ }
482
+ if (direction === "right") {
483
+ if (current.children.length && !expanded.has(current.id)) {
484
+ expanded.add(current.id);
485
+ return { nodeId: current.id, expandedNodeIds: expanded };
486
+ }
487
+ return { nodeId: current.children[0] ?? current.id, expandedNodeIds: expanded };
488
+ }
489
+ const visible = workflowPhaseTreeVisibleNodes(tree, expanded);
490
+ const index = Math.max(0, visible.findIndex((node) => node.id === current.id));
491
+ const next = visible[(index + (direction === "up" ? visible.length - 1 : 1)) % visible.length];
492
+ return { nodeId: next?.id ?? current.id, expandedNodeIds: expanded };
493
+ }
285
494
  export function preserveWorkflowPhaseSelection(model, selection) {
286
495
  const phase = model.phases.find((candidate) => candidate.id === selection.phaseId) ?? (model.currentPhaseIndex === undefined ? undefined : model.phases[model.currentPhaseIndex]) ?? model.phases[0];
287
496
  if (!phase)
288
- return {};
289
- const agentId = phase.agents.some((agent) => agent.id === selection.agentId) ? selection.agentId : phase.agents[0]?.id;
290
- return { phaseId: phase.id, ...(agentId === undefined ? {} : { agentId }) };
497
+ return model.unassignedAgents?.length ? { nodeId: workflowPhaseTreePath("phase", "unassigned", []) } : {};
498
+ const tree = buildWorkflowPhaseTree(model);
499
+ const selectedAgent = selection.agentId ? phase.agents.find((candidate) => candidate.id === selection.agentId) : undefined;
500
+ const selectedCandidate = selection.nodeId ? tree.byId.get(selection.nodeId) : undefined;
501
+ const selected = selectedCandidate?.phaseId === phase.id ? selectedCandidate : selectedAgent ? tree.byId.get(workflowPhaseTreePath("agent", phase.id, selectedAgent.structuralPath ?? [], selectedAgent.id)) : undefined;
502
+ const nodeId = selected?.id ?? tree.byId.get(workflowPhaseTreePath("phase", phase.id, []))?.id;
503
+ return { phaseId: phase.id, ...(selection.agentId && phase.agents.some((agent) => agent.id === selection.agentId) ? { agentId: selection.agentId } : phase.agents[0] ? { agentId: phase.agents[0].id } : {}), ...(nodeId ? { nodeId } : {}), ...(selection.expandedNodeIds ? { expandedNodeIds: selection.expandedNodeIds } : {}) };
291
504
  }
292
505
  function agentGroupKey(agent) { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
293
506
  function agentGroupLabel(agents) {
@@ -322,28 +535,36 @@ function renderGroupedAgents(agents, render, allAgents = agents, groupLabel = (l
322
535
  ...group.entries.map((entry) => render(entry, grouped)),
323
536
  ]);
324
537
  }
325
- function progressStyleForState(state, styles) {
326
- if (state === "completed")
327
- return (text) => styles.success(text);
328
- if (state === "failed" || state === "cancelled")
329
- return (text) => styles.error(text);
330
- if (state === "running")
331
- return (text) => styles.accent(text);
332
- return (text) => styles.muted(text);
333
- }
334
- export function formatWorkflowProgress(run, spinner = "◇", styles = PLAIN_WORKFLOW_PROGRESS_STYLES) {
538
+ const RUN_STATE_GLYPH = { completed: "✓", failed: "✗", stopped: "✗", budget_exhausted: "!", awaiting_input: "●" };
539
+ const AGENT_STATE_GLYPH = { completed: "✓", failed: "✗", cancelled: "✗" };
540
+ function runStateGlyph(state, running) { return state === "running" ? running : RUN_STATE_GLYPH[state] ?? "◆"; }
541
+ function agentStateGlyph(state, running) { return state === "running" ? running : AGENT_STATE_GLYPH[state] ?? ""; }
542
+ const PROGRESS_STATE_STYLE = { completed: "success", failed: "error", cancelled: "error", running: "accent" };
543
+ const WORKFLOW_ICON_STYLE = { completed: "success", failed: "error", stopped: "error", budget_exhausted: "warning", running: "accent" };
544
+ const PHASE_STATE_STYLE = { completed: "success", failed: "error", cancelled: "error", running: "accent", interrupted: "warning", budget_exhausted: "warning" };
545
+ function styleForState(map, state, styles) {
546
+ const key = map[state] ?? "muted";
547
+ return (text) => styles[key](text);
548
+ }
549
+ function progressStyleForState(state, styles) { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
550
+ function workflowIconStyle(state, styles) { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
551
+ function phaseStyleForState(state, styles) { return styleForState(PHASE_STATE_STYLE, state, styles); }
552
+ export function formatWorkflowProgress(run, spinner = "◇", styles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()) {
335
553
  const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
336
- const workflowIcon = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? spinner : "◆";
337
- const workflowIconStyle = run.state === "completed" ? (text) => styles.success(text) : run.state === "failed" || run.state === "stopped" ? (text) => styles.error(text) : run.state === "budget_exhausted" ? (text) => styles.warning(text) : run.state === "running" ? (text) => styles.accent(text) : (text) => styles.muted(text);
554
+ const workflowIcon = runStateGlyph(run.state, spinner);
555
+ const iconStyle = workflowIconStyle(run.state, styles);
338
556
  const header = styles.bold(styles.accent(`Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`));
339
- const lines = [`${workflowIconStyle(workflowIcon)} ${header}`];
557
+ const lines = [`${iconStyle(workflowIcon)} ${header}`];
340
558
  const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
341
559
  lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${budgetWarning ? styles.warning(line) : line}`));
560
+ const activeShells = run.activeShells ?? 0;
561
+ if (activeShells > 0)
562
+ lines.push(` ${styles.accent(spinner)} shell ${styles.accent("[running]")} ${styles.dim(`(${String(activeShells)} active)`)}`);
342
563
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
343
564
  const renderAgents = (agents, offset, nested) => renderGroupedAgents(agents, ({ agent, index, depth }, grouped) => {
344
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
565
+ const icon = agentStateGlyph(agent.state, spinner);
345
566
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
346
- const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles);
567
+ const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles, now);
347
568
  const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
348
569
  const state = progressStyleForState(agent.state, styles);
349
570
  return `${indent}#${String(offset + index + 1)} ${state(icon)} ${name} ${state(`[${agent.state}]`)}${activity ? ` ${activity}` : ""}`;
@@ -392,6 +613,104 @@ function workflowCatalogBlock(text, expanded) {
392
613
  invalidate() { },
393
614
  };
394
615
  }
616
+ function controlString(value) { return typeof value === "string" && value.trim() ? value : undefined; }
617
+ function controlValue(value) {
618
+ if (value === null)
619
+ return "removed";
620
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
621
+ return String(value);
622
+ const json = JSON.stringify(value);
623
+ return typeof json === "string" ? json : "unknown";
624
+ }
625
+ function controlTitle(name, theme) { return theme.fg("toolTitle", theme.bold(name)); }
626
+ function controlState(state, theme) {
627
+ const color = state === "completed" || state === "running" || state === "stopped" ? "success" : state === "failed" || state === "unknown" ? "error" : state === "budget_exhausted" || state === "awaiting_approval" ? "warning" : "accent";
628
+ return theme.fg(color, state);
629
+ }
630
+ function controlAction(action, theme) {
631
+ const color = /approved|completed|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
632
+ return theme.fg(color, action);
633
+ }
634
+ function budgetPatchEntries(value) {
635
+ if (!object(value))
636
+ return value === undefined ? [] : [controlValue(value)];
637
+ return Object.entries(value).map(([dimension, limits]) => {
638
+ if (limits === null)
639
+ return `${dimension}=removed`;
640
+ if (!object(limits))
641
+ return `${dimension}=${controlValue(limits)}`;
642
+ const parts = ["soft", "hard"].filter((key) => Object.prototype.hasOwnProperty.call(limits, key)).map((key) => `${key}=${controlValue(limits[key])}`);
643
+ return `${dimension} ${parts.join(" ")}`;
644
+ });
645
+ }
646
+ function budgetPatchSummary(value) {
647
+ const entries = budgetPatchEntries(value);
648
+ return entries.length ? entries.join(", ") : "unchanged";
649
+ }
650
+ function budgetPatchDetails(value, theme) {
651
+ const entries = budgetPatchEntries(value);
652
+ return entries.length ? [theme.fg("accent", theme.bold("Budget patch")), ...entries.map((entry) => ` ${theme.fg("toolOutput", entry)}`)] : [];
653
+ }
654
+ function workflowControlValue(result) { return catalogResultValue(result); }
655
+ function workflowControlCall(name, args, theme) {
656
+ const runId = controlString(args.runId) ?? "(missing run ID)";
657
+ if (name === "workflow_respond") {
658
+ const proposalId = controlString(args.proposalId);
659
+ const target = proposalId ? `budget proposal ${proposalId}` : `checkpoint ${controlString(args.name) ?? "(missing name)"}`;
660
+ const decision = args.approved === true ? "approve" : "reject";
661
+ return [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", target)} · ${controlAction(decision, theme)}`].join("\n");
662
+ }
663
+ if (name === "workflow_resume")
664
+ return args.budget === undefined ? `${controlTitle(name, theme)} ${theme.fg("accent", runId)}` : [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", "Budget")} ${theme.fg("toolOutput", budgetPatchSummary(args.budget))}`].join("\n");
665
+ if (name === "workflow_retry")
666
+ return `${controlTitle(name, theme)} ${theme.fg("accent", runId)} ${theme.fg("muted", "failed run")}`;
667
+ return `${controlTitle(name, theme)} ${theme.fg("accent", runId)}`;
668
+ }
669
+ function workflowControlResult(name, args, result, expanded, theme, isError) {
670
+ if (isError) {
671
+ const text = result.content?.filter(({ type }) => type === "text").map(({ text }) => text ?? "").join("\n").trim();
672
+ return theme.fg("error", text || `The ${name} tool failed.`);
673
+ }
674
+ const value = workflowControlValue(result);
675
+ if (!object(value))
676
+ return theme.fg("error", `The ${name} tool returned an invalid result.`);
677
+ const runId = controlString(args.runId) ?? controlString(value.runId) ?? "(unknown)";
678
+ const title = controlTitle(name, theme);
679
+ if (name === "workflow_stop") {
680
+ const state = controlString(value.state) ?? "unknown";
681
+ const action = value.stopped === true ? "stopped" : value.reason === "already_terminal" ? "already terminal" : value.reason === "unknown_run" ? "run not found" : "no change";
682
+ if (!expanded)
683
+ return `${title}\nRun ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`;
684
+ return [title, `Run: ${theme.fg("accent", runId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action, theme)}`, ...(controlString(value.reason) ? [`Reason: ${theme.fg("toolOutput", controlValue(value.reason))}`] : [])].join("\n");
685
+ }
686
+ if (name === "workflow_retry") {
687
+ const childRunId = controlString(value.runId) ?? "(unknown)";
688
+ const state = controlString(value.state) ?? "unknown";
689
+ const action = state === "completed" ? "completed" : "started";
690
+ if (!expanded)
691
+ return [title, `Source ${theme.fg("accent", runId)}`, `Child ${theme.fg("accent", childRunId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`].join("\n");
692
+ return [title, `Source run: ${theme.fg("accent", runId)}`, `Retry run: ${theme.fg("accent", childRunId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action === "completed" ? "completed" : "started; completed work will be replayed", theme)}`].join("\n");
693
+ }
694
+ if (name === "workflow_resume") {
695
+ const state = controlString(value.state) ?? "unknown";
696
+ const proposalId = controlString(value.proposalId);
697
+ const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : state === "completed" ? "completed" : "no change";
698
+ if (!expanded)
699
+ return [title, `Run ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal ${theme.fg("accent", proposalId)}`] : [])].join("\n");
700
+ return [title, `Run: ${theme.fg("accent", runId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal: ${theme.fg("accent", proposalId)}`] : []), ...budgetPatchDetails(args.budget, theme)].join("\n");
701
+ }
702
+ const proposalId = controlString(args.proposalId);
703
+ const checkpointName = controlString(args.name);
704
+ const target = proposalId ? `Budget proposal ${theme.fg("accent", proposalId)}` : `Checkpoint ${theme.fg("accent", checkpointName ?? "(missing)")}`;
705
+ const accepted = value.accepted === true;
706
+ const approved = value.approved === true;
707
+ const reason = controlString(value.reason);
708
+ const action = reason === "proposal_not_pending" ? "not pending" : reason === "checkpoint" && !accepted ? "not pending" : approved ? "approved" : "rejected";
709
+ const state = controlString(value.state);
710
+ if (!expanded)
711
+ return [title, target, `Run ${theme.fg("accent", runId)} · ${controlAction(action, theme)}${state ? ` · ${controlState(state, theme)}` : ""}`].join("\n");
712
+ return [title, `Run: ${theme.fg("accent", runId)}`, `Target: ${target}`, `Action: ${controlAction(action, theme)}`, ...(state ? [`State: ${controlState(state, theme)}`] : []), ...(reason ? [`Reason: ${theme.fg("toolOutput", reason)}`] : [])].join("\n");
713
+ }
395
714
  function catalogText(value) { return value.replace(/\s+/g, " ").trim(); }
396
715
  function catalogResultValue(result) {
397
716
  if (result.details !== undefined)
@@ -468,7 +787,8 @@ function formatWorkflowCatalog(value, expanded, theme) {
468
787
  return theme.fg("error", value.error.message);
469
788
  return theme.fg("error", "The workflow catalog returned an invalid result.");
470
789
  }
471
- const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`);
790
+ const ANSI_SGR_SOURCE = `${String.fromCharCode(27)}\\[[0-9;]*m`;
791
+ const ANSI_SGR = new RegExp(ANSI_SGR_SOURCE);
472
792
  export function truncateWorkflowProgress(text, width) {
473
793
  const safeWidth = Math.max(1, width);
474
794
  return text.split("\n").flatMap((line) => {
@@ -539,7 +859,7 @@ function navigatorRunLabels(entries) {
539
859
  nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
540
860
  return entries.map(({ store, loaded: { run } }) => {
541
861
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
542
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
862
+ const glyph = runStateGlyph(run.state, "⠦");
543
863
  const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
544
864
  const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
545
865
  const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
@@ -574,20 +894,46 @@ function styledAgentBreadcrumb(agent, byId, styles) {
574
894
  return parts[0] ?? "";
575
895
  return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${styles.bold(parts[parts.length - 1] ?? "")}`;
576
896
  }
577
- function formatAgentActivity(agent, spinner, styles = PLAIN_WORKFLOW_PROGRESS_STYLES) {
897
+ export function formatStalledDuration(durationMs) {
898
+ const minutes = Math.max(0, Math.floor(durationMs / 60_000));
899
+ if (minutes < 60)
900
+ return `${String(minutes)}m`;
901
+ const hours = Math.floor(minutes / 60);
902
+ const remainingMinutes = minutes % 60;
903
+ return `${String(hours)}h${remainingMinutes ? ` ${String(remainingMinutes)}m` : ""}`;
904
+ }
905
+ function stalledDuration(agent, now) {
906
+ if (agent.state !== "running" || agent.lastEventAt === undefined || !Number.isFinite(agent.lastEventAt))
907
+ return undefined;
908
+ const duration = now - agent.lastEventAt;
909
+ return duration >= WORKFLOW_AGENT_STALL_THRESHOLD_MS ? duration : undefined;
910
+ }
911
+ function formatAgentActivity(agent, spinner, styles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()) {
578
912
  const label = agent.activity?.kind === "reasoning" ? "reasoning" : agent.activity?.kind === "text" ? "responding" : agent.activity?.kind === "tool" ? agent.activity.text : [...(agent.toolCalls ?? [])].reverse().find(({ state }) => state === "running")?.name ?? "";
579
- return label ? `${styles.accent(spinner)} ${styles.dim(label)}` : "";
913
+ const activity = label ? `${styles.accent(spinner)} ${styles.dim(label)}` : "";
914
+ const stalled = stalledDuration(agent, now);
915
+ if (stalled === undefined)
916
+ return activity;
917
+ const warning = `stalled? ${formatStalledDuration(stalled)}`;
918
+ return activity ? `${activity} ${styles.warning(`- ${warning}`)}` : styles.warning(warning);
919
+ }
920
+ function formatAccountingValue(value) {
921
+ return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value).toLowerCase();
580
922
  }
581
923
  function formatAccounting(accounting) {
582
924
  const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
583
- return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
925
+ return `${formatAccountingValue(total)} tok`;
584
926
  }
585
- export function formatNavigatorDashboard(run, checkpoints, worktrees) {
927
+ function formatAgentAccounting(accounting) {
928
+ const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
929
+ return [`Tokens: ${formatAccountingValue(total)} (in=${formatAccountingValue(accounting.input)} out=${formatAccountingValue(accounting.output)} cache-read=${formatAccountingValue(accounting.cacheRead)} cache-write=${formatAccountingValue(accounting.cacheWrite)})`, `Cost: $${accounting.cost.toFixed(2)}`];
930
+ }
931
+ export function formatNavigatorDashboard(run, checkpoints, worktrees, now = Date.now()) {
586
932
  void worktrees;
587
933
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
588
934
  const totalAccounting = run.agents.reduce((sum, a) => ({ input: sum.input + (a.accounting?.input ?? 0), output: sum.output + (a.accounting?.output ?? 0), cacheRead: sum.cacheRead + (a.accounting?.cacheRead ?? 0), cacheWrite: sum.cacheWrite + (a.accounting?.cacheWrite ?? 0), cost: sum.cost + (a.accounting?.cost ?? 0) }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
589
935
  const hasAccounting = run.agents.some((a) => a.accounting);
590
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
936
+ const glyph = runStateGlyph(run.state, "⠦");
591
937
  const header = `${glyph} ${run.workflowName}`;
592
938
  const meta = [run.state, run.phase ? `phase: ${run.phase}` : "", `${String(done)}/${String(run.agents.length)} agents`, hasAccounting ? formatAccounting(totalAccounting) : "", totalAccounting.cost > 0 ? `$${totalAccounting.cost.toFixed(2)}` : ""].filter(Boolean).join(" · ");
593
939
  const lines = [header, meta, ...formatCompactBudgetStatus(run)];
@@ -598,7 +944,7 @@ export function formatNavigatorDashboard(run, checkpoints, worktrees) {
598
944
  lines.push("");
599
945
  const byId = new Map(run.agents.map((a) => [a.id, a]));
600
946
  const render = ({ agent, depth }, grouped) => {
601
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
947
+ const icon = agentStateGlyph(agent.state, "⠦");
602
948
  const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
603
949
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
604
950
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
@@ -608,7 +954,7 @@ export function formatNavigatorDashboard(run, checkpoints, worktrees) {
608
954
  if (last?.error)
609
955
  result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
610
956
  }
611
- const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
957
+ const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now) : "";
612
958
  if (activity)
613
959
  result.push(`${indent} ${activity}`);
614
960
  return result.join("\n");
@@ -621,7 +967,7 @@ export function formatNavigatorDashboard(run, checkpoints, worktrees) {
621
967
  }
622
968
  return lines.join("\n");
623
969
  }
624
- export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
970
+ export function formatNavigatorRun(loaded, checkpoints, worktrees, now = Date.now()) {
625
971
  const { run, snapshot } = loaded;
626
972
  const lines = [
627
973
  `Workflow: ${run.workflowName}`,
@@ -657,6 +1003,9 @@ export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
657
1003
  result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
658
1004
  for (const call of agent.toolCalls ?? [])
659
1005
  result.push(`${indent} tool ${call.name} state=${call.state}`);
1006
+ const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now) : "";
1007
+ if (activity)
1008
+ result.push(`${indent} ${activity}`);
660
1009
  return result.join("\n");
661
1010
  }));
662
1011
  lines.push("Checkpoints:");
@@ -664,23 +1013,83 @@ export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
664
1013
  lines.push(" (none)");
665
1014
  for (const checkpoint of checkpoints)
666
1015
  lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
667
- lines.push(`Worktrees: ${String(_worktrees.length)}`);
1016
+ lines.push(`Worktrees: ${String(worktrees.length)}`);
668
1017
  lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
669
1018
  return lines.join("\n");
670
1019
  }
671
- export function formatWorkflowPhaseDashboard(run, snapshot, width, selection = {}, styles = PLAIN_WORKFLOW_PROGRESS_STYLES) {
1020
+ export function formatWorkflowPhaseDashboard(run, snapshot, width, selection = {}, styles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()) {
672
1021
  const safeWidth = Math.max(1, width);
673
1022
  const model = buildWorkflowPhaseModel(run, snapshot);
1023
+ const tree = buildWorkflowPhaseTree(model);
1024
+ const expanded = selection.expandedNodeIds === undefined ? workflowPhaseTreeInitialExpanded(tree) : new Set(selection.expandedNodeIds);
674
1025
  const wrap = (text, limit = safeWidth) => truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, Math.max(1, limit), 0).visualLines.map((line) => line.trimEnd());
675
- const phaseStyle = (state) => state === "completed" ? (text) => styles.success(text) : state === "failed" || state === "cancelled" ? (text) => styles.error(text) : state === "running" ? (text) => styles.accent(text) : state === "interrupted" || state === "budget_exhausted" ? (text) => styles.warning(text) : (text) => styles.muted(text);
676
- const phaseName = (phase) => `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`;
677
- const phaseCounts = (phase) => `agents completed=${String(phase.counts.completed)} running=${String(phase.counts.running)} failed=${String(phase.counts.failed)} cancelled=${String(phase.counts.cancelled)} pending=${String(phase.counts.pending)}`;
678
- const phaseLine = (phase, selected) => `${selected ? "→" : " "} ${phaseName(phase)} · ${phaseStyle(phase.state)(phase.state)} · ${phaseCounts(phase)}`;
1026
+ // ponytail: ANSI-only width, good enough for the ASCII labels the tree renders
1027
+ const ansiPattern = new RegExp(ANSI_SGR_SOURCE, "g");
1028
+ const visibleLength = (text) => text.replace(ansiPattern, "").length;
1029
+ const padTo = (text, limit) => `${text}${" ".repeat(Math.max(0, limit - visibleLength(text)))}`;
1030
+ const phaseStyle = (state) => phaseStyleForState(state, styles);
1031
+ const phase = selection.phaseId ? model.phases.find((candidate) => candidate.id === selection.phaseId) : undefined;
1032
+ const selectedByAgent = selection.agentId ? tree.nodes.find((node) => node.kind === "agent" && node.agentId === selection.agentId && (!selection.phaseId || node.phaseId === selection.phaseId)) : undefined;
1033
+ const selectedNode = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? selectedByAgent ?? (phase ? tree.byId.get(workflowPhaseTreePath("phase", phase.id, [])) : undefined) ?? (model.currentPhaseId ? tree.byId.get(workflowPhaseTreePath("phase", model.currentPhaseId, [])) : undefined) ?? tree.nodes[0];
1034
+ const selectedPhase = selectedNode?.phase ?? (selectedNode ? model.phases.find((candidate) => candidate.id === selectedNode.phaseId) : undefined);
1035
+ const visibleNodes = workflowPhaseTreeVisibleNodes(tree, expanded);
1036
+ const nodeAgents = (node) => {
1037
+ const agents = [];
1038
+ const visit = (id) => {
1039
+ const child = tree.byId.get(id);
1040
+ if (!child)
1041
+ return;
1042
+ if (child.agent)
1043
+ agents.push(child.agent);
1044
+ else
1045
+ for (const childId of child.children)
1046
+ visit(childId);
1047
+ };
1048
+ if (node.agent)
1049
+ agents.push(node.agent);
1050
+ else
1051
+ for (const childId of node.children)
1052
+ visit(childId);
1053
+ return agents;
1054
+ };
1055
+ const nodeStatus = (node) => phaseStyle(node.state)(node.state);
1056
+ const nodeIcon = (node) => node.children.length ? expanded.has(node.id) ? "▾" : "▸" : node.kind === "agent" ? "•" : " ";
1057
+ const treeLine = (node) => {
1058
+ const selected = node.id === selectedNode?.id;
1059
+ const state = progressStyleForState(node.state, styles);
1060
+ const activity = node.agent && !SETTLED_AGENT_STATES.has(node.agent.state) ? formatAgentActivity(node.agent, "⠦", styles, now) : "";
1061
+ return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}${activity ? ` ${activity}` : ""}`;
1062
+ };
1063
+ const details = (node) => {
1064
+ if (!node)
1065
+ return [styles.muted("No workflow node is selected")];
1066
+ const agents = nodeAgents(node);
1067
+ if (node.kind === "phase") {
1068
+ const selected = node.phase;
1069
+ const counts = selected?.counts ?? phaseAgentCounts(agents);
1070
+ return [styles.bold(`Selected phase: ${node.label}`), `Status: ${nodeStatus(node)}`, `agents completed=${String(counts.completed)} running=${String(counts.running)} failed=${String(counts.failed)} cancelled=${String(counts.cancelled)} pending=${String(counts.pending)}`, `Agents: ${String(agents.length)}`];
1071
+ }
1072
+ if (node.kind === "operation") {
1073
+ const states = phaseAgentCounts(agents);
1074
+ return [styles.bold(`Selected operation: ${node.operationPath.join(" > ")}`), `Phase: ${node.phase?.name ?? node.phaseId}`, `Status: ${nodeStatus(node)}`, `agents completed=${String(states.completed)} running=${String(states.running)} failed=${String(states.failed)} cancelled=${String(states.cancelled)} pending=${String(states.pending)}`, `Agents: ${String(agents.length)}`];
1075
+ }
1076
+ const agent = node.agent;
1077
+ if (!agent)
1078
+ return [styles.muted("Agent details are unavailable")];
1079
+ const byId = new Map(run.agents.map((candidate) => [candidate.id, candidate]));
1080
+ const result = [styles.bold(`Selected agent: ${agentBreadcrumb(agent, byId, true)}`), `State: ${phaseStyle(agent.state)(agent.state)}`, `Structural path: ${agent.structuralPath?.join(" > ") || "(root)"}`, `Model: ${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`, `Role: ${agent.role ?? "(none)"}`, `Tools: ${agent.tools.join(", ") || "(none)"}`, `Attempts: ${String(agent.attempts)}`, ...(agent.accounting ? formatAgentAccounting(agent.accounting) : []), ...(selection.actions ? [] : [styles.muted("enter for agent actions")])];
1081
+ const error = agent.attemptDetails?.at(-1)?.error;
1082
+ if (error)
1083
+ result.push(styles.error(`Error: ${error.code}: ${error.message}`));
1084
+ if (agent.activity)
1085
+ result.push(`Activity: ${agent.activity.text}`);
1086
+ const stalled = stalledDuration(agent, now);
1087
+ if (stalled !== undefined)
1088
+ result.push(styles.warning(`stalled? ${formatStalledDuration(stalled)}`));
1089
+ return result;
1090
+ };
679
1091
  const stateNames = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
680
1092
  const statusSummary = stateNames.filter((state) => (model.counts[state] ?? 0) > 0).map((state) => `${String(model.counts[state])} ${state}`).join(" · ") || "0 phases";
681
- const selectedIndex = selection.phaseId ? model.phases.findIndex((phase) => phase.id === selection.phaseId) : -1;
682
- const activeIndex = selectedIndex >= 0 ? selectedIndex : model.currentPhaseIndex ?? (model.phases.length ? 0 : -1);
683
- const selectedPhase = activeIndex >= 0 ? model.phases[activeIndex] : undefined;
684
1093
  const lines = [styles.bold(styles.accent(`Workflow: ${run.workflowName}`))];
685
1094
  if (run.error)
686
1095
  lines.push(styles.error(`ERROR ${run.error.code}: ${run.error.message}`));
@@ -688,46 +1097,32 @@ export function formatWorkflowPhaseDashboard(run, snapshot, width, selection = {
688
1097
  for (const event of run.events ?? [])
689
1098
  lines.push(styles.warning(`Warning: ${event.message}`));
690
1099
  lines.push(...formatCompactBudgetStatus(run));
691
- const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
692
- const renderAgentLines = (agents, selectedAgentId) => {
693
- if (!agents.length)
694
- return [styles.muted("Agents: none")];
695
- const rendered = [];
696
- for (const agent of agents) {
697
- const selected = agent.id === selectedAgentId;
698
- const stateStyle = progressStyleForState(agent.state, styles);
699
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
700
- rendered.push(`${selected ? "→" : " "} ${stateStyle(icon)} ${styledAgentBreadcrumb(agent, byId, styles)} · ${stateStyle(agent.state)}`);
701
- if (agent.state === "failed") {
702
- const error = agent.attemptDetails?.at(-1)?.error;
703
- if (error)
704
- rendered.push(styles.error(` error: ${error.code}: ${error.message}`));
705
- }
706
- }
707
- return rendered;
1100
+ const renderTree = (limit) => [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.flatMap((node) => wrap(treeLine(node), limit)) : [styles.muted("(empty)")])];
1101
+ const actionRows = () => {
1102
+ const actions = selection.actions;
1103
+ if (!actions)
1104
+ return [];
1105
+ return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
708
1106
  };
709
- if (!model.phases.length) {
710
- lines.push(styles.muted("Phases: no declared or observed phases (legacy snapshot)"), styles.bold("Agents"), ...renderAgentLines(run.agents, selection.agentId));
711
- return lines.flatMap((line) => wrap(line));
712
- }
713
- const selectedAgent = selectedPhase?.agents.find((agent) => agent.id === selection.agentId) ?? selectedPhase?.agents[0];
1107
+ const detailRows = () => [...details(selectedNode), ...actionRows()];
714
1108
  if (safeWidth >= 80) {
715
- const sidebarWidth = Math.min(38, Math.max(24, Math.floor(safeWidth * 0.36)));
1109
+ const sidebarWidth = Math.min(42, Math.max(24, Math.floor((safeWidth - 3) * 0.38)));
716
1110
  const detailWidth = Math.max(1, safeWidth - sidebarWidth - 3);
717
- const sidebar = [styles.bold("Phases"), ...model.phases.flatMap((phase, index) => wrap(phaseLine(phase, index === activeIndex), sidebarWidth))];
718
- const detail = selectedPhase ? [styles.bold(`Phase: ${phaseName(selectedPhase)}`), ...wrap(`Status: ${phaseStyle(selectedPhase.state)(selectedPhase.state)}`, detailWidth), ...wrap(phaseCounts(selectedPhase), detailWidth), "Agents", ...renderAgentLines(selectedPhase.agents, selectedAgent?.id).flatMap((line) => wrap(line, detailWidth))] : [styles.muted("No phase is selected")];
1111
+ const sidebar = renderTree(sidebarWidth).flatMap((line) => wrap(line, sidebarWidth));
1112
+ const detail = detailRows().flatMap((line) => wrap(line, detailWidth));
719
1113
  const rows = Math.max(sidebar.length, detail.length);
720
1114
  for (let index = 0; index < rows; index += 1)
721
- lines.push(`${sidebar[index] ?? ""} | ${detail[index] ?? ""}`);
1115
+ lines.push(`${padTo(sidebar[index] ?? "", sidebarWidth)} | ${detail[index] ?? ""}`);
1116
+ }
1117
+ else if (selection.detailsOnly) {
1118
+ lines.push(...detailRows().flatMap((line) => wrap(line)));
722
1119
  }
723
1120
  else {
724
- lines.push(styles.bold("Phases"));
725
- for (const [index, phase] of model.phases.entries())
726
- lines.push(...wrap(phaseLine(phase, index === activeIndex)));
727
- if (selectedPhase)
728
- lines.push("", styles.bold(`Selected phase: ${phaseName(selectedPhase)}`), ...wrap(`Status: ${phaseStyle(selectedPhase.state)(selectedPhase.state)}`), ...wrap(phaseCounts(selectedPhase)), styles.bold("Agents"), ...renderAgentLines(selectedPhase.agents, selectedAgent?.id).flatMap((line) => wrap(line)));
1121
+ lines.push(...renderTree(safeWidth));
1122
+ if (!selection.treeOnly)
1123
+ lines.push("", ...detailRows().flatMap((line) => wrap(line)));
729
1124
  }
730
- if (model.unassignedAgents?.length)
1125
+ if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned"))
731
1126
  lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
732
1127
  return lines.flatMap((line) => wrap(line));
733
1128
  }
@@ -932,15 +1327,35 @@ export function formatWorkflowFailureDiagnostics(diagnostic) {
932
1327
  const retry = diagnostic.retry ? [` Retry: ${diagnostic.retry.action}`, ` Replayable completed paths: ${diagnostic.retry.completedPaths.join(", ") || "(none)"}`, ` Incomplete paths: ${diagnostic.retry.incompletePaths.join(", ") || "(unknown)"}`, ` Named worktrees: ${diagnostic.retry.namedWorktrees.join(", ") || "(none)"}`, ` Warning: ${diagnostic.retry.warning}`] : [];
933
1328
  return [`✗ Workflow: ${diagnostic.workflowName}`, ` Run: ${diagnostic.runId}`, ` State: ${diagnostic.state}`, ` Error: ${diagnostic.error.code}: ${diagnostic.error.message}`, ` Failed at: ${diagnostic.failedAt ?? "(unknown)"}`, ` Failed agent: ${failedAgent}`, ` Completed sibling ${siblingAgents ? "agents" : "paths"}: ${siblings}`, ...retry, ` Artifacts: state=${diagnostic.artifacts.statePath} journal=${diagnostic.artifacts.journalPath}`].join("\n");
934
1329
  }
1330
+ function deliveryPart(value, maxBytes) { return utf8Prefix(value.replace(/\s+/g, " ").trim(), maxBytes) || "(unknown)"; }
1331
+ export function formatWorkflowFailureDelivery(diagnostic) {
1332
+ const name = deliveryPart(diagnostic.workflowName, 128);
1333
+ const runId = deliveryPart(diagnostic.runId, 128);
1334
+ const error = `${diagnostic.error.code}: ${deliveryPart(diagnostic.error.message, 768)}`;
1335
+ const failedPath = diagnostic.failedAt ? `; failed path=${deliveryPart(diagnostic.failedAt, 512)}` : "";
1336
+ const nextAction = diagnostic.retry ? `; next action: ${deliveryPart(diagnostic.retry.action, 256)}` : "";
1337
+ const artifacts = `; artifacts: runDirectory=${deliveryPart(diagnostic.artifacts.runDirectory, 512)} statePath=${deliveryPart(diagnostic.artifacts.statePath, 512)} journalPath=${deliveryPart(diagnostic.artifacts.journalPath, 512)}`;
1338
+ const line = `Workflow ${name} failed (runId=${runId}): error=${error}${failedPath}${nextAction}${artifacts}`;
1339
+ return Buffer.byteLength(line) <= DELIVERY_LIMIT_BYTES ? line : utf8Prefix(line, DELIVERY_LIMIT_BYTES);
1340
+ }
1341
+ function formatWorkflowFailureDeliveryFallback(workflowName, runId, runDirectory, error) {
1342
+ const code = errorCode(error) ?? "INTERNAL_ERROR";
1343
+ const failedPath = workflowFailedAt(error);
1344
+ const nextAction = code === "BUDGET_EXHAUSTED" || code === "CANCELLED" ? "" : `; next action: workflow_retry({ runId: ${JSON.stringify(runId)} })`;
1345
+ const line = `Workflow ${deliveryPart(workflowName, 128)} failed (runId=${deliveryPart(runId, 128)}): error=${code}: ${deliveryPart(formatWorkflowFailure(error), 768)}${failedPath ? `; failed path=${deliveryPart(failedPath, 512)}` : ""}${nextAction}; artifacts: runDirectory=${deliveryPart(runDirectory, 512)} statePath=${deliveryPart(join(runDirectory, "state.json"), 512)} journalPath=${deliveryPart(join(runDirectory, "journal.json"), 512)}`;
1346
+ return Buffer.byteLength(line) <= DELIVERY_LIMIT_BYTES ? line : utf8Prefix(line, DELIVERY_LIMIT_BYTES);
1347
+ }
935
1348
  function serializeWorkflowFailureDiagnostics(diagnostic) { return JSON.stringify(diagnostic); }
936
1349
  function isWorkflowFailureDiagnostics(value) {
937
1350
  return object(value) && typeof value.runId === "string" && typeof value.workflowName === "string" && typeof value.state === "string" && "failedAt" in value && object(value.error) && object(value.artifacts);
938
1351
  }
939
1352
  function deliver(pi, content) {
1353
+ if (typeof pi.sendMessage !== "function")
1354
+ return;
940
1355
  pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
941
1356
  }
942
1357
  function deliverFailure(pi, diagnostic) {
943
- deliver(pi, `Workflow ${utf8Prefix(diagnostic.workflowName, 128)} failure diagnostics: ${serializeWorkflowFailureDiagnostics(diagnostic)}`);
1358
+ deliver(pi, formatWorkflowFailureDelivery(diagnostic));
944
1359
  }
945
1360
  function safeEventError(error) {
946
1361
  const code = errorCode(error) ?? "INTERNAL_ERROR";
@@ -1216,23 +1631,22 @@ function projectTrusted(ctx) {
1216
1631
  const check = object(ctx) ? ctx.isProjectTrusted : undefined;
1217
1632
  return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
1218
1633
  }
1219
- function isEntryRenderer(value) { return typeof value === "function"; }
1634
+ function asFn(value) { return typeof value === "function" ? value : undefined; }
1220
1635
  function isWorkflowEventSink(value) { return object(value) && typeof value.emit === "function"; }
1221
1636
  function piHostCapabilities(pi) {
1222
1637
  if (!object(pi))
1223
1638
  return {};
1224
- const registerEntryRenderer = pi.registerEntryRenderer;
1639
+ const registerEntryRenderer = asFn(pi.registerEntryRenderer);
1225
1640
  const events = pi.events;
1226
- return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
1641
+ return { ...(registerEntryRenderer ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
1227
1642
  }
1228
- function isModelRegistryGetter(value) { return typeof value === "function"; }
1229
1643
  function contextHostCapabilities(ctx) {
1230
1644
  if (!object(ctx) || !object(ctx.modelRegistry))
1231
1645
  return {};
1232
1646
  const registry = ctx.modelRegistry;
1233
- const getAll = registry.getAll;
1234
- const getAvailable = registry.getAvailable;
1235
- return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
1647
+ const getAll = asFn(registry.getAll);
1648
+ const getAvailable = asFn(registry.getAvailable);
1649
+ return { modelRegistry: { ...(getAll ? { getAll: () => getAll.call(registry) } : {}), ...(getAvailable ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
1236
1650
  }
1237
1651
  function modelInventory(root, registry) {
1238
1652
  const all = registry?.getAll?.() ?? registry?.getAvailable?.() ?? [];
@@ -1266,24 +1680,22 @@ async function resolveLaunchAliases(registry, staticAliases, context, availableM
1266
1680
  throw error;
1267
1681
  }
1268
1682
  }
1269
- function isUiSelect(value) { return typeof value === "function"; }
1270
- function isUiInput(value) { return typeof value === "function"; }
1271
- function isUiSetStatus(value) { return typeof value === "function"; }
1272
1683
  function uiHostCapabilities(ui) {
1273
1684
  if (!object(ui))
1274
1685
  return undefined;
1275
- const select = ui.select;
1276
- const input = ui.input;
1277
- const setStatus = ui.setStatus;
1278
- return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
1686
+ const select = asFn(ui.select);
1687
+ const input = asFn(ui.input);
1688
+ const setStatus = asFn(ui.setStatus);
1689
+ return { ...(select ? { select } : {}), ...(input ? { input } : {}), ...(setStatus ? { setStatus } : {}) };
1279
1690
  }
1280
- function tuiHostCapabilities(tui) {
1281
- if (!object(tui) || !object(tui.terminal))
1282
- return {};
1283
- return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
1691
+ function tuiRows(tui) {
1692
+ const rows = object(tui) && object(tui.terminal) ? tui.terminal.rows : undefined;
1693
+ return typeof rows === "number" && Number.isFinite(rows) ? rows : 24;
1284
1694
  }
1285
- function tuiRows(tui) { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
1695
+ const WORKFLOW_PANEL_FOOTER_ROWS = 2;
1286
1696
  const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
1697
+ const WORKFLOW_OVERLAY_TOP_MARGIN = 1;
1698
+ const WORKFLOW_OVERLAY_OPTIONS = { anchor: "top-left", width: "100%", maxHeight: "100%", margin: { top: WORKFLOW_OVERLAY_TOP_MARGIN } };
1287
1699
  function borderWorkflowOverlay(component, theme) {
1288
1700
  return {
1289
1701
  ...component,
@@ -1293,14 +1705,19 @@ function borderWorkflowOverlay(component, theme) {
1293
1705
  },
1294
1706
  };
1295
1707
  }
1296
- function isKeybindingGetter(value) { return typeof value === "function"; }
1297
- function keybindingsHostCapabilities(keybindings) {
1298
- if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys))
1299
- return {};
1300
- return { getKeys: keybindings.getKeys };
1708
+ function keybindingKeys(keybindings, name) {
1709
+ const getKeys = object(keybindings) ? asFn(keybindings.getKeys) : undefined;
1710
+ return getKeys ? getKeys.call(keybindings, name) : undefined;
1711
+ }
1712
+ const WORKFLOW_VIM_KEYS = { "tui.select.up": "k", "tui.select.down": "j", "tui.editor.cursorLeft": "h", "tui.editor.cursorRight": "l" };
1713
+ function workflowKeyMatches(keybindings, data, binding) { return keybindings.matches(data, binding) || WORKFLOW_VIM_KEYS[binding] === data; }
1714
+ function workflowKeyLabel(keybindings, binding, fallback, labels) {
1715
+ const keys = keybindingKeys(keybindings, binding);
1716
+ const configured = keys?.length ? keys.map((key) => labels[key] ?? key) : [fallback];
1717
+ const vim = WORKFLOW_VIM_KEYS[binding];
1718
+ return [...new Set(vim ? [...configured, vim] : configured)].join("/");
1301
1719
  }
1302
- function keybindingKeys(keybindings, name) { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
1303
- export default function workflowExtension(pi, home, clipboard = copyToClipboard, createSession = createNativeAgentSession, agentDir) {
1720
+ export default function workflowExtension(pi, home, clipboard = copyToClipboard, createSession = createNativeAgentSession, agentDir, additionalSkillPaths = []) {
1304
1721
  beginWorkflowExtensionLoading();
1305
1722
  const registry = loadingRegistry();
1306
1723
  const extensionAgentDir = agentDir ?? getAgentDir();
@@ -1353,16 +1770,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1353
1770
  });
1354
1771
  };
1355
1772
  const pendingFailureDiagnostics = new Map();
1356
- pi.on("tool_result", (event) => {
1357
- if (event.toolName !== "workflow" || !event.isError)
1358
- return;
1359
- const diagnostic = pendingFailureDiagnostics.get(event.toolCallId);
1360
- if (!diagnostic)
1361
- return;
1362
- pendingFailureDiagnostics.delete(event.toolCallId);
1363
- return { content: [{ type: "text", text: serializeWorkflowFailureDiagnostics(diagnostic) }], details: diagnostic, isError: true };
1364
- });
1773
+ const foregroundDeliveries = new Map();
1365
1774
  const liveActivities = new Map();
1775
+ const liveEventTimes = new Map();
1366
1776
  const setLiveActivity = (runId, agentId, activity) => {
1367
1777
  const activities = liveActivities.get(runId);
1368
1778
  if (activity) {
@@ -1377,12 +1787,27 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1377
1787
  liveActivities.delete(runId);
1378
1788
  }
1379
1789
  };
1790
+ const setLiveEventTime = (runId, agentId, timestamp) => {
1791
+ if (timestamp === undefined)
1792
+ return;
1793
+ const timestamps = liveEventTimes.get(runId);
1794
+ if (timestamps)
1795
+ timestamps.set(agentId, timestamp);
1796
+ else
1797
+ liveEventTimes.set(runId, new Map([[agentId, timestamp]]));
1798
+ };
1380
1799
  const withLiveActivities = (run) => {
1381
1800
  const activities = liveActivities.get(run.id);
1382
- return activities?.size ? { ...run, agents: run.agents.map((agent) => {
1383
- const activity = activities.get(agent.id);
1384
- return activity ? { ...agent, activity } : agent;
1385
- }) } : run;
1801
+ const timestamps = liveEventTimes.get(run.id);
1802
+ if (!activities?.size && !timestamps?.size)
1803
+ return run;
1804
+ return { ...run, agents: run.agents.map((agent) => {
1805
+ const activity = activities?.get(agent.id);
1806
+ const lastEventAt = timestamps?.get(agent.id);
1807
+ if (activity === undefined && lastEventAt === undefined)
1808
+ return agent;
1809
+ return { ...agent, ...(activity === undefined ? {} : { activity }), ...(lastEventAt === undefined ? {} : { lastEventAt }) };
1810
+ }) };
1386
1811
  };
1387
1812
  const terminalRunStates = new Map();
1388
1813
  let sessionLease;
@@ -1410,6 +1835,52 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1410
1835
  await eventPublisher.budget(store, metadata, persisted);
1411
1836
  return persisted;
1412
1837
  };
1838
+ pi.on("tool_result", async (event) => {
1839
+ const delivery = event.toolName === "workflow" ? foregroundDeliveries.get(event.toolCallId) : undefined;
1840
+ if (delivery) {
1841
+ if (delivery.timer)
1842
+ clearTimeout(delivery.timer);
1843
+ delivery.inline = true;
1844
+ await delivery.store.updateState((current) => {
1845
+ if (current.delivery?.toolCallId !== event.toolCallId || current.delivery.state === "delivered")
1846
+ return current;
1847
+ return { ...current, delivery: { ...current.delivery, state: "delivered" } };
1848
+ });
1849
+ foregroundDeliveries.delete(event.toolCallId);
1850
+ }
1851
+ if (event.toolName !== "workflow" || !event.isError)
1852
+ return;
1853
+ const diagnostic = pendingFailureDiagnostics.get(event.toolCallId);
1854
+ if (!diagnostic)
1855
+ return;
1856
+ pendingFailureDiagnostics.delete(event.toolCallId);
1857
+ return { content: [{ type: "text", text: serializeWorkflowFailureDiagnostics(diagnostic) }], details: diagnostic, isError: true };
1858
+ });
1859
+ const deliverTerminal = async (store, content) => {
1860
+ let claimed;
1861
+ await store.updateState((current) => {
1862
+ if (current.delivery?.state === "delivered")
1863
+ return current;
1864
+ if (!current.delivery) {
1865
+ claimed = true;
1866
+ return current;
1867
+ }
1868
+ claimed = true;
1869
+ return { ...current, delivery: { ...current.delivery, mode: "background", state: "delivered" } };
1870
+ });
1871
+ if (claimed === true)
1872
+ deliver(pi, content);
1873
+ };
1874
+ const scheduleForegroundDelivery = (toolCallId, send) => {
1875
+ const delivery = foregroundDeliveries.get(toolCallId);
1876
+ if (!delivery || delivery.inline || typeof pi.sendMessage !== "function")
1877
+ return;
1878
+ //NOTE: Give Pi one event-loop turn to deliver an uninterrupted tool result before promoting.
1879
+ delivery.timer = setTimeout(() => {
1880
+ delete delivery.timer;
1881
+ void send().finally(() => foregroundDeliveries.delete(toolCallId));
1882
+ }, 0);
1883
+ };
1413
1884
  const phaseBridge = (store, metadata, lifecycle) => {
1414
1885
  let cursor = 0;
1415
1886
  return async (phase) => {
@@ -1462,10 +1933,25 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1462
1933
  const replayed = await store.replay(path);
1463
1934
  if (replayed)
1464
1935
  return readShellResult(replayed.value);
1465
- const cwd = identity.worktreeOwner ? (await persistWorktree(store, metadata, identity.worktreeOwner)).cwd : store.cwd;
1466
- const result = await executeShellCommand(command, options, signal, cwd);
1467
- await store.complete(path, result);
1468
- return result;
1936
+ const started = await persistRunState(store, metadata, (current) => ({ ...current, activeShells: (current.activeShells ?? 0) + 1 }));
1937
+ runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(started)));
1938
+ try {
1939
+ const cwd = identity.worktreeOwner ? (await persistWorktree(store, metadata, identity.worktreeOwner)).cwd : store.cwd;
1940
+ const result = await executeShellCommand(command, options, signal, cwd);
1941
+ await store.complete(path, result);
1942
+ return result;
1943
+ }
1944
+ finally {
1945
+ const stopped = await persistRunState(store, metadata, (current) => {
1946
+ const activeShells = Math.max(0, (current.activeShells ?? 0) - 1);
1947
+ if (activeShells > 0)
1948
+ return { ...current, activeShells };
1949
+ const next = { ...current };
1950
+ delete next.activeShells;
1951
+ return next;
1952
+ });
1953
+ runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(stopped)));
1954
+ }
1469
1955
  }
1470
1956
  finally {
1471
1957
  await lifecycle.leave();
@@ -1476,8 +1962,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1476
1962
  budget.transition(next);
1477
1963
  const persisted = await persistRunState(store, metadata, (current) => {
1478
1964
  const nextRun = { ...current, state: next, ...budget.snapshot() };
1479
- if (next === "running" || next === "completed")
1965
+ if (next === "running" || next === "completed") {
1480
1966
  delete nextRun.error;
1967
+ delete nextRun.failedAt;
1968
+ }
1481
1969
  return nextRun;
1482
1970
  });
1483
1971
  await eventPublisher.runState(store, metadata, previous, next, reason);
@@ -1492,28 +1980,31 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1492
1980
  const onProgress = async (progress) => {
1493
1981
  let runState;
1494
1982
  if (progress.persist) {
1495
- runState = await persistRunState(run.store, run.metadata, (current) => current.agents.some((agent) => agent.id === id) ? { ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) } : current);
1983
+ runState = await persistRunState(run.store, run.metadata, (current) => current.agents.some((agent) => agent.id === id) ? { ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity, ...(progress.lastEventAt === undefined ? {} : { lastEventAt: progress.lastEventAt }) } : agent) } : current);
1496
1984
  }
1497
1985
  else {
1498
1986
  const loaded = await run.store.load();
1499
1987
  if (!loaded.run.agents.some((agent) => agent.id === id))
1500
1988
  return;
1501
- runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
1989
+ runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity, ...(progress.lastEventAt === undefined ? {} : { lastEventAt: progress.lastEventAt }) } : agent) };
1502
1990
  }
1503
1991
  if (!runState.agents.some((agent) => agent.id === id))
1504
1992
  return;
1505
1993
  setLiveActivity(runId, id, progress.activity);
1994
+ setLiveEventTime(runId, id, progress.lastEventAt);
1506
1995
  run.update?.(workflowToolUpdate(withLiveActivities(runState)));
1507
1996
  };
1508
1997
  const onAttempt = async (attempt) => {
1509
1998
  await scheduler.flush();
1510
1999
  scheduler.attemptStarted(id);
2000
+ const lastEventAt = Date.now();
2001
+ setLiveEventTime(runId, id, lastEventAt);
1511
2002
  await scheduler.flush();
1512
2003
  const before = (await run.store.load()).run;
1513
2004
  await persistActiveAgentAttempt(run.store, id, attempt);
1514
2005
  const active = (await run.store.load()).run;
1515
2006
  await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
1516
- const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2007
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, lastEventAt } : agent) }));
1517
2008
  run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1518
2009
  };
1519
2010
  const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, budget, ...(run.providerErrorRecovery ? { providerErrorRecovery: run.providerErrorRecovery } : {}), ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.agentOptions ? { agentOptions: options.agentOptions } : {}), ...(options.agentIdentity ? { agentIdentity: options.agentIdentity } : {}) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); scheduler.retry(id); });
@@ -1558,7 +2049,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1558
2049
  catch {
1559
2050
  effective = previous ? { model: previous.model, ...(previous.requestedModel ? { requestedModel: previous.requestedModel } : {}), tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, ...(node.options.model ? { requestedModel: node.options.model } : {}), tools: node.options.tools };
1560
2051
  }
1561
- return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), structuralPath: [...(node.options.agentIdentity?.structuralPath ?? [])], ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.worktreeOwner ? { worktreeOwner: node.options.worktreeOwner } : {}), ...(node.options.role ? { role: node.options.role } : {}), ...(effective.requestedModel ? { requestedModel: effective.requestedModel } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}) };
2052
+ const resultPath = !node.parentId && node.options.agentIdentity ? agentIdentityPath(node.options.agentIdentity) : undefined;
2053
+ const lastEventAt = node.state === "running" ? previous?.state === "running" && previous.lastEventAt !== undefined ? previous.lastEventAt : Date.now() : previous?.lastEventAt;
2054
+ return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), structuralPath: [...(node.options.agentIdentity?.structuralPath ?? [])], ...(resultPath ? { resultPath } : {}), ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.worktreeOwner ? { worktreeOwner: node.options.worktreeOwner } : {}), ...(node.options.role ? { role: node.options.role } : {}), ...(effective.requestedModel ? { requestedModel: effective.requestedModel } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }) };
1562
2055
  });
1563
2056
  return { ...current, agents };
1564
2057
  });
@@ -1567,7 +2060,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1567
2060
  });
1568
2061
  const cleanupTerminalRun = async (runId) => {
1569
2062
  const run = runs.get(runId);
1570
- if (!run || !["completed", "failed", "stopped"].includes(run.lifecycle.state))
2063
+ if (!run || !HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state))
1571
2064
  return;
1572
2065
  await scheduler.cancelRun(runId);
1573
2066
  await scheduler.flush();
@@ -1577,6 +2070,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1577
2070
  terminalRunStates.set(runId, run.lifecycle.state);
1578
2071
  run.checkpointResolvers.clear();
1579
2072
  liveActivities.delete(runId);
2073
+ liveEventTimes.delete(runId);
1580
2074
  eventPublisher.removeRun(runId);
1581
2075
  runs.delete(runId);
1582
2076
  };
@@ -1617,7 +2111,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1617
2111
  run.budget.recordEvent({ type, budgetVersion: request.budgetVersion, dimensions: [], usage: structuredClone(request.consumed), limits: structuredClone(request.proposed), at: Date.now(), proposalId: request.proposalId, previous: structuredClone(request.previous), proposed: structuredClone(request.proposed) });
1618
2112
  await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
1619
2113
  };
1620
- const answerBudgetDecision = async (runId, proposalId, approved, silent = false, context, signal) => {
2114
+ const answerBudgetDecision = async (runId, proposalId, approved, silent = false, context, signal, waitForCompletion = true) => {
1621
2115
  const run = runs.get(runId);
1622
2116
  if (!run)
1623
2117
  return undefined;
@@ -1625,7 +2119,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1625
2119
  if (!request)
1626
2120
  return undefined;
1627
2121
  await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
1628
- const result = await applyBudgetDecision(request, approved, context, signal);
2122
+ const result = await applyBudgetDecision(request, approved, context, signal, waitForCompletion);
1629
2123
  if (!silent)
1630
2124
  deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
1631
2125
  return result;
@@ -1705,6 +2199,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1705
2199
  throw mainAgentError(error);
1706
2200
  }
1707
2201
  },
2202
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_respond", args, theme)); },
2203
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_respond", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1708
2204
  });
1709
2205
  pi.registerTool({
1710
2206
  name: "workflow_stop",
@@ -1720,6 +2216,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1720
2216
  throw mainAgentError(error);
1721
2217
  }
1722
2218
  },
2219
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_stop", args, theme)); },
2220
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_stop", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1723
2221
  });
1724
2222
  let catalogRegistered = false;
1725
2223
  let sessionStarted = false;
@@ -1751,37 +2249,100 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1751
2249
  });
1752
2250
  catalogRegistered = true;
1753
2251
  };
1754
- const refreshPausedRunAliases = async (run, context) => {
1755
- const loaded = await run.store.load();
1756
- const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1757
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2252
+ const createAgentExecutor = (root) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, ...(additionalSkillPaths.length ? { additionalSkillPaths } : {}), agentSetupHooks: registry.agentSetupHooks() }, createSession);
2253
+ const activeSnapshotTools = (tools, active) => active === "session"
2254
+ ? new Set(tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog"))
2255
+ : new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
2256
+ const resumeLaunchPrologue = async (input) => {
2257
+ const active = new Set(pi.getActiveTools().filter((tool) => !INTERNAL_WORKFLOW_TOOLS.includes(tool)));
2258
+ const missing = input.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1758
2259
  if (missing)
1759
2260
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1760
2261
  const settingsPath = workflowSettingsPath(extensionAgentDir);
1761
- const trustedProject = context?.projectTrusted ?? run.projectTrusted();
1762
- const resolution = resolveWorkflowSettings(run.store.cwd, trustedProject, settingsPath);
1763
- const currentPolicy = resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
2262
+ const resolution = resolveWorkflowSettings(input.cwd, input.trustedProject, settingsPath);
2263
+ const currentPolicy = resolveAgentResourcePolicy(input.cwd, input.trustedProject, settingsPath);
1764
2264
  const staticAliases = resolution.effective.modelAliases ?? {};
1765
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2265
+ const previousAliases = input.snapshot.modelAliases ?? input.snapshot.settings.modelAliases ?? {};
2266
+ const inventory = modelInventory(input.rootModel, input.modelRegistry);
2267
+ const knownModels = input.modelRegistry ? inventory.knownModels : new Set([...input.snapshot.models, ...inventory.knownModels]);
2268
+ const availableModels = input.modelRegistry ? inventory.availableModels : new Set([...input.snapshot.models, ...inventory.availableModels]);
2269
+ const currentAliases = input.resolvedAliases ?? (await resolveLaunchAliases(registry, staticAliases, { cwd: input.cwd, projectTrusted: input.trustedProject, rootModel: input.rootModel, knownModels, availableModels, signal: input.signal }, availableModels, knownModels, settingsPath)).aliases;
2270
+ const blockedAliases = input.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2271
+ const blockedAliasTargets = input.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2272
+ let script;
2273
+ if (input.withPreflight) {
2274
+ const resumeAliases = { ...previousAliases, ...currentAliases };
2275
+ script = launchScriptForSnapshot(input.snapshot, registry);
2276
+ preflight(script, { models: availableModels, tools: active, agentTypes: new Set(input.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, input.snapshot.schemas, input.snapshot.metadata, true);
2277
+ }
2278
+ const refreshed = resumedSnapshotSettings(input.snapshot, resolution, currentAliases);
2279
+ const snapshot = createLaunchSnapshot({ ...input.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
2280
+ return { active, settingsPath, resolution, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script };
2281
+ };
2282
+ const workflowAgentHandler = (store, metadata, lifecycle, executor, cwd, runId, captureRole) => async (prompt, options, agentSignal, identity) => {
2283
+ await lifecycle.enter();
2284
+ try {
2285
+ const path = agentIdentityPath(identity);
2286
+ const replayed = await store.replay(path);
2287
+ if (replayed) {
2288
+ return replayed.value;
2289
+ }
2290
+ const worktree = agentWorktree(identity);
2291
+ const agentCwd = worktree.worktreeOwner ? (await persistWorktree(store, metadata, worktree.worktreeOwner)).cwd : cwd;
2292
+ const role = typeof options.role === "string" ? options.role : undefined;
2293
+ const model = typeof options.model === "string" ? options.model : undefined;
2294
+ const thinking = parseThinking(options.thinking);
2295
+ const requestedLabel = typeof options.label === "string" ? options.label : undefined;
2296
+ const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools } : {}) });
2297
+ if (role)
2298
+ await captureRole?.(role, resolved.model);
2299
+ const label = displayAgentName(requestedLabel, role, resolved.model);
2300
+ const tools = resolved.tools;
2301
+ const schema = object(options.outputSchema) ? options.outputSchema : undefined;
2302
+ const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd: agentCwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), agentOptions: options, agentIdentity: identity });
2303
+ const cancel = () => { scheduler.cancel(spawned.id); };
2304
+ if (agentSignal.aborted)
2305
+ cancel();
2306
+ else
2307
+ agentSignal.addEventListener("abort", cancel, { once: true });
2308
+ const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
2309
+ if (!outcome.ok)
2310
+ throw new WorkflowError(outcome.error.code, outcome.error.message);
2311
+ await store.complete(path, outcome.value);
2312
+ return outcome.value;
2313
+ }
2314
+ finally {
2315
+ await lifecycle.leave();
2316
+ }
2317
+ };
2318
+ const refreshPausedRunAliases = async (run, context) => {
2319
+ const loaded = await run.store.load();
2320
+ const trustedProject = context?.projectTrusted ?? run.projectTrusted();
1766
2321
  const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
1767
- const inventory = modelInventory(rootModel, context?.modelRegistry);
1768
- const knownModels = context?.modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
1769
- const availableModels = context?.modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
1770
- const currentAliases = (await resolveLaunchAliases(registry, staticAliases, { cwd: run.store.cwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: run.abortController.signal }, availableModels, knownModels, settingsPath)).aliases;
1771
- const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1772
- const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1773
- const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
1774
- const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
2322
+ const { settingsPath, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd: run.store.cwd, trustedProject, rootModel, ...(context?.modelRegistry ? { modelRegistry: context.modelRegistry } : {}), signal: run.abortController.signal, withPreflight: false });
1775
2323
  await run.store.saveSnapshot(snapshot);
1776
2324
  scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
1777
- run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, agentDir: extensionAgentDir, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(currentPolicy) }, createSession);
2325
+ run.executor = createAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: activeSnapshotTools(snapshot.tools, "session"), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentResourcePolicy: frozenResourcePolicy(currentPolicy) });
1778
2326
  run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
1779
2327
  const drift = aliasDrift(previousAliases, currentAliases);
1780
2328
  if (drift.length)
1781
2329
  await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
1782
2330
  };
1783
- const coldResumeRun = async (run, hasUI, ui, trustedProject, context) => {
2331
+ const recoveryUi = (context) => {
2332
+ const host = object(context) ? context : undefined;
2333
+ const ui = host && object(host.ui) ? host.ui : {};
2334
+ return { hasUI: host?.hasUI === true, ui };
2335
+ };
2336
+ const coldResumeRun = async (run, hasUI, ui, trustedProject, context, modeOverride, waitForCompletion = true) => {
1784
2337
  const loaded = await run.store.load();
2338
+ const foreground = modeOverride ?? loaded.snapshot.launchMode === "foreground";
2339
+ if (loaded.run.activeShells !== undefined) {
2340
+ await persistRunState(run.store, run.metadata, (current) => {
2341
+ const next = { ...current };
2342
+ delete next.activeShells;
2343
+ return next;
2344
+ });
2345
+ }
1785
2346
  await run.store.validateRetrySource();
1786
2347
  await run.store.validateBorrowedWorktrees();
1787
2348
  if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION)
@@ -1793,19 +2354,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1793
2354
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
1794
2355
  if (missingRole)
1795
2356
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
1796
- const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1797
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1798
- if (missing)
1799
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1800
- const settingsPath = workflowSettingsPath(extensionAgentDir);
1801
- const resolution = resolveWorkflowSettings(run.store.cwd, trustedProject, settingsPath);
1802
- const currentPolicy = resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
1803
- const staticAliases = resolution.effective.modelAliases ?? {};
1804
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
1805
2357
  const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
1806
- const inventory = modelInventory(rootModel, context?.modelRegistry);
1807
- const knownModels = context?.modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
1808
- const availableModels = context?.modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
1809
2358
  const controller = new AbortController();
1810
2359
  if (context?.signal?.aborted)
1811
2360
  controller.abort();
@@ -1813,17 +2362,13 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1813
2362
  context?.signal?.addEventListener("abort", () => { controller.abort(); }, { once: true });
1814
2363
  }
1815
2364
  run.abortController = controller;
1816
- const currentAliases = context?.resolvedAliases ?? (await resolveLaunchAliases(registry, staticAliases, { cwd: run.store.cwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: controller.signal }, availableModels, knownModels, settingsPath)).aliases;
1817
- const resumeAliases = { ...previousAliases, ...currentAliases };
1818
- const blockedAliases = context?.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1819
- const blockedAliasTargets = context?.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1820
- const script = launchScriptForSnapshot(loaded.snapshot, registry);
1821
- preflight(script, { models: availableModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
1822
- const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
1823
- const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
1824
- await run.store.saveSnapshot(snapshot);
2365
+ const { settingsPath, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd: run.store.cwd, trustedProject, rootModel, ...(context?.modelRegistry ? { modelRegistry: context.modelRegistry } : {}), signal: controller.signal, ...(context?.resolvedAliases ? { resolvedAliases: context.resolvedAliases } : {}), ...(context?.blockedAliases ? { blockedAliases: context.blockedAliases } : {}), ...(context?.blockedAliasTargets ? { blockedAliasTargets: context.blockedAliasTargets } : {}), withPreflight: true });
2366
+ if (!script)
2367
+ throw new WorkflowError("INTERNAL_ERROR", "Resume preflight did not produce a launch script");
2368
+ const persistedSnapshot = modeOverride === undefined ? snapshot : createLaunchSnapshot({ ...snapshot, launchMode: foreground ? "foreground" : "background" });
2369
+ await run.store.saveSnapshot(persistedSnapshot);
1825
2370
  scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
1826
- run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, agentDir: extensionAgentDir, model: rootModel, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(currentPolicy) }, createSession);
2371
+ run.executor = createAgentExecutor({ cwd: run.store.cwd, model: rootModel, tools: activeSnapshotTools(snapshot.tools, "session"), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentResourcePolicy: frozenResourcePolicy(currentPolicy) });
1827
2372
  const drift = aliasDrift(previousAliases, currentAliases);
1828
2373
  if (drift.length)
1829
2374
  await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
@@ -1835,9 +2380,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1835
2380
  }
1836
2381
  catch (error) {
1837
2382
  const typed = asWorkflowError(error);
1838
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
2383
+ if (!HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) {
1839
2384
  await run.lifecycle.terminal("failed", typed.code).catch(() => undefined);
1840
- const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } }));
2385
+ const persisted = await persistRunState(run.store, run.metadata, (current) => persistedFailure({ ...current }, typed));
1841
2386
  await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed");
1842
2387
  run.update?.(workflowToolUpdate(persisted));
1843
2388
  if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
@@ -1848,37 +2393,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1848
2393
  }
1849
2394
  await scheduler.cancelRun(run.store.runId);
1850
2395
  await run.lifecycle.resume();
1851
- const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: async (prompt, options, signal, identity) => {
1852
- await run.lifecycle.enter();
1853
- try {
1854
- const path = agentIdentityPath(identity);
1855
- const replayed = await run.store.replay(path);
1856
- if (replayed) {
1857
- return replayed.value;
1858
- }
1859
- const worktree = agentWorktree(identity);
1860
- const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
1861
- const role = typeof options.role === "string" ? options.role : undefined;
1862
- const model = typeof options.model === "string" ? options.model : undefined;
1863
- const thinking = parseThinking(options.thinking);
1864
- const requestedLabel = typeof options.label === "string" ? options.label : undefined;
1865
- const resolved = run.executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools } : {}) });
1866
- const label = displayAgentName(requestedLabel, role, resolved.model);
1867
- const tools = resolved.tools;
1868
- const schema = object(options.outputSchema) ? options.outputSchema : undefined;
1869
- const spawned = scheduler.spawn(run.store.runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), agentOptions: options, agentIdentity: identity });
1870
- const cancel = () => { scheduler.cancel(spawned.id); };
1871
- signal.addEventListener("abort", cancel, { once: true });
1872
- const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
1873
- if (!outcome.ok)
1874
- throw new WorkflowError(outcome.error.code, outcome.error.message);
1875
- await run.store.complete(path, outcome.value);
1876
- return outcome.value;
1877
- }
1878
- finally {
1879
- await run.lifecycle.leave();
1880
- }
1881
- }, worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
2396
+ const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: workflowAgentHandler(run.store, run.metadata, run.lifecycle, run.executor, run.store.cwd, run.store.runId), worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, foreground, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
1882
2397
  run.execution = execution;
1883
2398
  const completion = execution.result.then(async (value) => {
1884
2399
  await scheduler.flush();
@@ -1893,19 +2408,34 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1893
2408
  const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
1894
2409
  if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
1895
2410
  await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
1896
- const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
2411
+ const persisted = await persistRunState(run.store, run.metadata, (current) => persistedFailure({ ...current, ...run.budget.snapshot() }, typed));
1897
2412
  const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
1898
2413
  if (state === "failed")
1899
2414
  retryReservations.delete(persisted.retry?.lineageRootRunId ?? run.store.runId);
1900
2415
  await eventPublisher.runFailed(run.store, run.metadata, typed, state);
1901
2416
  run.update?.(workflowToolUpdate(persisted));
1902
- if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
1903
- await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).then((diagnostic) => { deliverFailure(pi, diagnostic); }).catch(() => undefined);
2417
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) {
2418
+ const diagnostic = await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted);
2419
+ Object.defineProperty(typed, WORKFLOW_FAILURE_DIAGNOSTICS, { value: diagnostic });
2420
+ }
2421
+ throw typed;
1904
2422
  }).finally(() => cleanupTerminalRun(run.store.runId));
1905
2423
  run.completion = completion;
1906
- void completion;
2424
+ if (!foreground || !waitForCompletion) {
2425
+ void completion.then(async ({ value, resultPath }) => {
2426
+ deliver(pi, completionDelivery(run.metadata.name, value, resultPath, await run.store.changedWorktrees()));
2427
+ }, (error) => {
2428
+ const diagnostic = failureDiagnosticsFrom(error);
2429
+ if (diagnostic)
2430
+ deliverFailure(pi, diagnostic);
2431
+ else
2432
+ deliver(pi, formatWorkflowFailureDeliveryFallback(run.metadata.name, run.store.runId, run.store.directory, error));
2433
+ });
2434
+ return undefined;
2435
+ }
2436
+ return completion;
1907
2437
  };
1908
- const applyBudgetDecision = async (request, approved, context, signal) => {
2438
+ const applyBudgetDecision = async (request, approved, context, signal, waitForCompletion = true) => {
1909
2439
  const run = runs.get(request.runId);
1910
2440
  if (!run)
1911
2441
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
@@ -1919,16 +2449,34 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1919
2449
  next.budget = nextBudget;
1920
2450
  else
1921
2451
  delete next.budget; return next; });
1922
- await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
2452
+ const { hasUI, ui } = recoveryUi(context);
2453
+ const completed = await coldResumeRun(run, hasUI, ui, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) }, undefined, waitForCompletion);
2454
+ if (completed)
2455
+ return { state: "completed", approved: true, value: completed.value, run: (await run.store.load()).run };
1923
2456
  return { state: "running", approved: true };
1924
2457
  };
1925
- const resumeWorkflowRun = async (runId, rawPatch, context, signal) => {
2458
+ const resumeWorkflowRun = async (runId, rawPatch, context, signal, modeOverride, waitForCompletion = true) => {
1926
2459
  const run = runs.get(runId);
1927
- if (!run)
1928
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
2460
+ if (!run) {
2461
+ const host = object(context) ? context : {};
2462
+ const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
2463
+ const sessionManager = object(host.sessionManager) ? host.sessionManager : undefined;
2464
+ const sessionId = typeof sessionManager?.getSessionId === "function" ? String(Reflect.apply(sessionManager.getSessionId, sessionManager, [])) : undefined;
2465
+ if (cwd && sessionId) {
2466
+ try {
2467
+ const state = (await new RunStore(cwd, sessionId, runId, home).load()).run.state;
2468
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", state));
2469
+ }
2470
+ catch (error) {
2471
+ if (error instanceof WorkflowError)
2472
+ throw error;
2473
+ }
2474
+ }
2475
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session`);
2476
+ }
1929
2477
  const loaded = await run.store.load();
1930
2478
  if (loaded.run.state !== "budget_exhausted")
1931
- throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
2479
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", loaded.run.state));
1932
2480
  const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
1933
2481
  const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
1934
2482
  const nextBudget = mergeBudget(currentBudget, patch);
@@ -1953,11 +2501,14 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1953
2501
  else
1954
2502
  delete next.budget; return next; });
1955
2503
  }
1956
- await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
2504
+ const { hasUI, ui } = recoveryUi(context);
2505
+ const completed = await coldResumeRun(run, hasUI, ui, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) }, modeOverride, waitForCompletion);
2506
+ if (completed)
2507
+ return { state: "completed", runId, value: completed.value, run: (await run.store.load()).run };
1957
2508
  return { state: "running" };
1958
2509
  };
1959
2510
  const retryReservations = new Set();
1960
- const retryWorkflowRun = async (runId, context, signal) => {
2511
+ const retryWorkflowRun = async (runId, context, signal, modeOverride) => {
1961
2512
  if (typeof runId !== "string" || !runId.trim())
1962
2513
  throw new WorkflowError("RESUME_INCOMPATIBLE", "workflow_retry requires an explicit run ID");
1963
2514
  const host = object(context) ? context : {};
@@ -1973,10 +2524,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1973
2524
  loaded = await sourceStore.load();
1974
2525
  }
1975
2526
  catch (error) {
1976
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load failed source run ${runId}: ${errorText(error)}`);
2527
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session: ${errorText(error)}`);
1977
2528
  }
1978
2529
  if (loaded.run.state !== "failed")
1979
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Only failed workflow runs can be retried; source is ${loaded.run.state}`);
2530
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("retry", loaded.run.state));
1980
2531
  if (loaded.run.retry && (typeof loaded.run.retry.sourceRunId !== "string" || !loaded.run.retry.sourceRunId || typeof loaded.run.retry.lineageRootRunId !== "string" || !loaded.run.retry.lineageRootRunId || !Array.isArray(loaded.run.retry.completedPaths) || loaded.run.retry.completedPaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.incompletePaths) || loaded.run.retry.incompletePaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.namedWorktrees) || loaded.run.retry.namedWorktrees.some((name) => typeof name !== "string")))
1981
2532
  throw new WorkflowError("RESUME_INCOMPATIBLE", "The source retry provenance is incomplete");
1982
2533
  const lineageRootRunId = loaded.run.retry?.lineageRootRunId ?? loaded.run.id;
@@ -2012,28 +2563,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2012
2563
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
2013
2564
  if (missingRole)
2014
2565
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
2015
- const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
2016
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2017
- if (missing)
2018
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2019
- const settingsPath = workflowSettingsPath(extensionAgentDir);
2020
- const resolution = resolveWorkflowSettings(cwd, trustedProject, settingsPath);
2021
- const currentPolicy = resolveAgentResourcePolicy(cwd, trustedProject, settingsPath);
2022
- const staticAliases = resolution.effective.modelAliases ?? {};
2023
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2024
2566
  const modelRegistry = contextHostCapabilities(context).modelRegistry;
2025
2567
  const hostModel = object(host.model) && typeof host.model.provider === "string" && typeof host.model.id === "string" ? { provider: host.model.provider, id: host.model.id } : { provider: "", id: "" };
2026
2568
  const rootModel = { provider: hostModel.provider, model: hostModel.id, thinking: pi.getThinkingLevel() };
2027
- const inventory = modelInventory(rootModel, modelRegistry);
2028
- const knownModels = modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
2029
- const availableModels = modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
2030
- const resolvedAliases = await resolveLaunchAliases(registry, staticAliases, { cwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: signal ?? new AbortController().signal }, availableModels, knownModels, settingsPath);
2031
- const currentAliases = resolvedAliases.aliases;
2032
- const resumeAliases = { ...previousAliases, ...currentAliases };
2033
- const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2034
- const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2035
- const script = launchScriptForSnapshot(loaded.snapshot, registry);
2036
- preflight(script, { models: availableModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
2569
+ const { active, settingsPath, currentPolicy, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot: childBaseSnapshot } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd, trustedProject, rootModel, ...(modelRegistry ? { modelRegistry } : {}), signal: signal ?? new AbortController().signal, withPreflight: true });
2037
2570
  await sourceStore.validateNamedWorktrees();
2038
2571
  for (const name of loaded.run.retry?.namedWorktrees ?? [])
2039
2572
  await sourceStore.resolveNamedWorktree(name);
@@ -2043,8 +2576,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2043
2576
  const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
2044
2577
  const childRunId = randomUUID();
2045
2578
  const childStore = new RunStore(cwd, sessionId, childRunId, home);
2046
- const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
2047
- const childSnapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
2579
+ const childSnapshot = childBaseSnapshot;
2048
2580
  const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
2049
2581
  const childInitialBudget = childBudget.snapshot();
2050
2582
  const retry = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
@@ -2055,16 +2587,23 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2055
2587
  const abortController = new AbortController();
2056
2588
  const providerErrorRecovery = createProviderErrorRecovery(context, availableModels, () => { abortController.abort(); });
2057
2589
  const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
2058
- const childRun = { executor: new WorkflowAgentExecutor({ cwd, agentDir: extensionAgentDir, model, tools: new Set(loaded.snapshot.tools.filter((tool) => active.has(tool) || tool === "workflow_catalog")), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: loaded.snapshot.roles ?? {}, runStore: childStore, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(currentPolicy) }, createSession), store: childStore, metadata: loaded.snapshot.metadata, model, lifecycle, budget: childBudget, abortController, projectTrusted: () => projectTrusted(context), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) };
2590
+ const childRun = { executor: createAgentExecutor({ cwd, model, tools: activeSnapshotTools(loaded.snapshot.tools, active), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: loaded.snapshot.roles ?? {}, runStore: childStore, providerPause, agentResourcePolicy: frozenResourcePolicy(currentPolicy) }), store: childStore, metadata: loaded.snapshot.metadata, model, lifecycle, budget: childBudget, abortController, projectTrusted: () => projectTrusted(context), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) };
2059
2591
  runs.set(childRunId, childRun);
2060
2592
  scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
2061
2593
  await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
2062
- await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) });
2594
+ const { hasUI, ui } = recoveryUi(context);
2595
+ const completed = await coldResumeRun(childRun, hasUI, ui, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) }, modeOverride);
2063
2596
  const completion = runs.get(childRunId)?.completion;
2064
2597
  if (completion) {
2065
2598
  childStarted = true;
2066
2599
  void completion.then(() => { retryReservations.delete(lineageRootRunId); }, () => { retryReservations.delete(lineageRootRunId); });
2067
2600
  }
2601
+ else if (completed) {
2602
+ childStarted = true;
2603
+ retryReservations.delete(lineageRootRunId);
2604
+ }
2605
+ if (completed)
2606
+ return { runId: childRunId, parentRunId: loaded.run.id, state: "completed", value: completed.value, run: (await childStore.load()).run };
2068
2607
  return { runId: childRunId, parentRunId: loaded.run.id, state: "running" };
2069
2608
  }
2070
2609
  finally {
@@ -2079,28 +2618,32 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2079
2618
  parameters: WORKFLOW_RETRY_PARAMETERS,
2080
2619
  async execute(_id, params, signal, _onUpdate, ctx) {
2081
2620
  try {
2082
- const result = await retryWorkflowRun(params.runId, ctx, signal);
2621
+ const result = await retryWorkflowRun(params.runId, ctx, signal, params.foreground);
2083
2622
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
2084
2623
  }
2085
2624
  catch (error) {
2086
2625
  throw mainAgentError(error);
2087
2626
  }
2088
2627
  },
2628
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_retry", args, theme)); },
2629
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_retry", context.args, result, options.expanded, theme, context.isError), options.expanded); },
2089
2630
  });
2090
2631
  pi.registerTool({
2091
2632
  name: "workflow_resume",
2092
2633
  label: "Workflow Resume",
2093
2634
  description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
2094
- parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
2635
+ parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()), foreground: Type.Optional(Type.Boolean({ description: "Override the source launch mode for this recovery" })) }, { additionalProperties: false }),
2095
2636
  async execute(_id, params, signal, _onUpdate, ctx) {
2096
2637
  try {
2097
- const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal);
2638
+ const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal, params.foreground);
2098
2639
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
2099
2640
  }
2100
2641
  catch (error) {
2101
2642
  throw mainAgentError(error);
2102
2643
  }
2103
2644
  },
2645
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_resume", args, theme)); },
2646
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_resume", context.args, result, options.expanded, theme, context.isError), options.expanded); },
2104
2647
  });
2105
2648
  pi.on("session_start", async (_event, ctx) => {
2106
2649
  if (sessionStarted)
@@ -2129,11 +2672,27 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2129
2672
  }
2130
2673
  if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
2131
2674
  const previousState = loaded.run.state;
2132
- await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
2675
+ await store.updateState((current) => {
2676
+ if (["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state))
2677
+ return current;
2678
+ const next = { ...current, state: "interrupted" };
2679
+ delete next.activeShells;
2680
+ return next;
2681
+ });
2133
2682
  loaded = { ...loaded, run: (await store.load()).run };
2134
2683
  await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
2135
2684
  loaded = { ...loaded, run: (await store.load()).run };
2136
2685
  }
2686
+ else if (loaded.run.activeShells !== undefined) {
2687
+ await store.updateState((current) => {
2688
+ if (["completed", "failed", "stopped"].includes(current.state))
2689
+ return current;
2690
+ const next = { ...current };
2691
+ delete next.activeShells;
2692
+ return next;
2693
+ });
2694
+ loaded = { ...loaded, run: (await store.load()).run };
2695
+ }
2137
2696
  const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
2138
2697
  const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
2139
2698
  eventPublisher.seedBudget(runId, loaded.run.budgetEvents);
@@ -2143,7 +2702,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2143
2702
  const roleDefinitions = loaded.snapshot.roles ?? {};
2144
2703
  const abortController = new AbortController();
2145
2704
  const providerErrorRecovery = createProviderErrorRecovery(ctx, new Set(loaded.snapshot.models), () => { abortController.abort(); });
2146
- runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, agentDir: extensionAgentDir, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), knownModels: new Set(loaded.snapshot.models), ...(loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ? { modelAliases: loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases } : {}), ...(loaded.snapshot.settingsSources?.modelAliases ? { settingsPath: loaded.snapshot.settingsSources.modelAliases } : loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(snapshotResourcePolicy(loaded.snapshot, store.cwd, projectTrusted(ctx), workflowSettingsPath(extensionAgentDir))) }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) });
2705
+ runs.set(runId, { executor: createAgentExecutor({ cwd: ctx.cwd, model, tools: activeSnapshotTools(loaded.snapshot.tools, "session"), availableModels: new Set(loaded.snapshot.models), knownModels: new Set(loaded.snapshot.models), ...(loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ? { modelAliases: loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases } : {}), ...(loaded.snapshot.settingsSources?.modelAliases ? { settingsPath: loaded.snapshot.settingsSources.modelAliases } : loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentResourcePolicy: frozenResourcePolicy(snapshotResourcePolicy(loaded.snapshot, store.cwd, projectTrusted(ctx), workflowSettingsPath(extensionAgentDir))) }), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) });
2147
2706
  for (const checkpoint of await store.awaitingCheckpoints())
2148
2707
  deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
2149
2708
  for (const decision of await store.pendingWorkflowDecisions())
@@ -2161,7 +2720,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2161
2720
  const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
2162
2721
  for (const run of toResume) {
2163
2722
  try {
2164
- await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx);
2723
+ await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx, undefined, false);
2165
2724
  ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info");
2166
2725
  }
2167
2726
  catch (err) {
@@ -2205,7 +2764,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2205
2764
  const inventory = modelInventory(rootModel, modelRegistry);
2206
2765
  const knownModels = inventory.knownModels;
2207
2766
  const availableModels = inventory.availableModels;
2208
- const rootTools = pi.getActiveTools().filter((name) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(name));
2767
+ const rootTools = pi.getActiveTools().filter((name) => !INTERNAL_WORKFLOW_TOOLS.includes(name));
2209
2768
  const trustedProject = projectTrusted(ctx);
2210
2769
  const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
2211
2770
  const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
@@ -2233,54 +2792,38 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2233
2792
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
2234
2793
  const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, modelAliases, knownModels, settingsPath)] : []; });
2235
2794
  const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
2236
- const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, settingsSources: { ...launch.resolution.sources, concurrency: params.concurrency === undefined ? launch.resolution.sources.concurrency : "per-run options" }, ...(functionName ? { launchKind: "function", functionName } : {}), ...(Object.keys(modelAliases).length ? { modelAliases } : {}), ...(budget ? { budget } : {}), ...(checked.referenced.phases.length ? { phases: checked.referenced.phases } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
2795
+ const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, launchMode: params.foreground ? "foreground" : "background", settings, settingsPath, settingsSources: { ...launch.resolution.sources, concurrency: params.concurrency === undefined ? launch.resolution.sources.concurrency : "per-run options" }, ...(functionName ? { launchKind: "function", functionName } : {}), ...(Object.keys(modelAliases).length ? { modelAliases } : {}), ...(budget ? { budget } : {}), ...(checked.referenced.phases.length ? { phases: checked.referenced.phases } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
2796
+ let persistedSnapshot = snapshot;
2797
+ const captureFunctionRole = functionName ? async (role, model) => {
2798
+ const definition = agentDefinitions[role];
2799
+ if (!definition)
2800
+ return;
2801
+ const modelName = `${model.provider}/${model.model}`;
2802
+ const hasProjectRole = projectAgentDefinitions[role] !== undefined;
2803
+ if (persistedSnapshot.roles?.[role] !== undefined && (!hasProjectRole || persistedSnapshot.projectRoles?.includes(role)) && persistedSnapshot.models.includes(modelName))
2804
+ return;
2805
+ const roles = { ...(persistedSnapshot.roles ?? {}), [role]: definition };
2806
+ const projectRoles = hasProjectRole ? [...new Set([...(persistedSnapshot.projectRoles ?? []), role])] : persistedSnapshot.projectRoles ?? [];
2807
+ const models = [...new Set([...persistedSnapshot.models, modelName])];
2808
+ persistedSnapshot = createLaunchSnapshot({ ...persistedSnapshot, models, roles, projectRoles });
2809
+ await store.saveSnapshot(persistedSnapshot);
2810
+ } : undefined;
2237
2811
  const budgetRuntime = new WorkflowBudgetRuntime(budget);
2238
2812
  const initialBudget = budgetRuntime.snapshot();
2239
- await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", ...(parentRunId !== undefined ? { parentRunId } : {}), agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
2813
+ await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", ...(parentRunId !== undefined ? { parentRunId } : {}), agents: [], nativeSessions: [], delivery: params.foreground ? { mode: "foreground", state: "attached", toolCallId } : { mode: "background", state: "pending" }, ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
2814
+ if (params.foreground)
2815
+ foregroundDeliveries.set(toolCallId, { store, inline: false });
2240
2816
  const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
2241
2817
  const background = !params.foreground;
2242
2818
  const providerPause = async () => { if (background)
2243
2819
  deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
2244
2820
  const providerErrorRecovery = createProviderErrorRecovery(ctx, availableModels, () => { runController.abort(); });
2245
- const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, agentDir: extensionAgentDir, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases, settingsPath, agentDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(launch.resourcePolicy), runContext }, createSession);
2821
+ const executor = createAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases, settingsPath, agentDefinitions, runStore: store, providerPause, agentResourcePolicy: frozenResourcePolicy(launch.resourcePolicy), runContext });
2246
2822
  runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, budget: budgetRuntime, abortController: runController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
2247
2823
  if (params.foreground && onUpdate)
2248
2824
  onUpdate(workflowToolUpdate((await store.load()).run));
2249
2825
  scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
2250
- const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: async (prompt, options, agentSignal, identity) => {
2251
- await lifecycle.enter();
2252
- try {
2253
- const path = agentIdentityPath(identity);
2254
- const replayed = await store.replay(path);
2255
- if (replayed) {
2256
- return replayed.value;
2257
- }
2258
- const worktree = agentWorktree(identity);
2259
- const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
2260
- const role = typeof options.role === "string" ? options.role : undefined;
2261
- const model = typeof options.model === "string" ? options.model : undefined;
2262
- const thinking = parseThinking(options.thinking);
2263
- const requestedLabel = typeof options.label === "string" ? options.label : undefined;
2264
- const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: checked.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools } : {}) });
2265
- const label = displayAgentName(requestedLabel, role, resolved.model);
2266
- const tools = resolved.tools;
2267
- const schema = object(options.outputSchema) ? options.outputSchema : undefined;
2268
- const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), agentOptions: options, agentIdentity: identity });
2269
- const cancel = () => { scheduler.cancel(spawned.id); };
2270
- if (agentSignal.aborted)
2271
- cancel();
2272
- else
2273
- agentSignal.addEventListener("abort", cancel, { once: true });
2274
- const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
2275
- if (!outcome.ok)
2276
- throw new WorkflowError(outcome.error.code, outcome.error.message);
2277
- await store.complete(path, outcome.value);
2278
- return outcome.value;
2279
- }
2280
- finally {
2281
- await lifecycle.leave();
2282
- }
2283
- }, worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
2826
+ const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: workflowAgentHandler(store, checked.metadata, lifecycle, executor, ctx.cwd, runId, captureFunctionRole), worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
2284
2827
  runs.get(runId).execution = execution;
2285
2828
  await eventPublisher.runStarted(store, checked.metadata);
2286
2829
  const finish = execution.result.then(async (value) => {
@@ -2296,7 +2839,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2296
2839
  const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
2297
2840
  if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state))
2298
2841
  await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
2299
- const persisted = await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
2842
+ const persisted = await persistRunState(store, checked.metadata, (current) => persistedFailure({ ...current, ...budgetRuntime.snapshot() }, typed));
2300
2843
  const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
2301
2844
  await eventPublisher.runFailed(store, checked.metadata, typed, state);
2302
2845
  const diagnostic = await createWorkflowFailureDiagnostics(store, checked.metadata, typed, persisted);
@@ -2307,18 +2850,41 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2307
2850
  });
2308
2851
  const completion = finish.finally(() => cleanupTerminalRun(runId));
2309
2852
  runs.get(runId).completion = completion;
2853
+ const deliverFailureContent = (error) => {
2854
+ const diagnostic = failureDiagnosticsFrom(error);
2855
+ return diagnostic ? formatWorkflowFailureDelivery(diagnostic) : formatWorkflowFailureDeliveryFallback(checked.metadata.name, runId, store.directory, error);
2856
+ };
2857
+ const queueForegroundDelivery = async (content) => {
2858
+ const delivery = foregroundDeliveries.get(toolCallId);
2859
+ if (!delivery)
2860
+ return;
2861
+ await store.updateState((current) => {
2862
+ if (!current.delivery || current.delivery.state === "delivered")
2863
+ return current;
2864
+ return { ...current, delivery: { ...current.delivery, mode: "background", state: "pending" } };
2865
+ });
2866
+ if (delivery.inline)
2867
+ return;
2868
+ scheduleForegroundDelivery(toolCallId, async () => {
2869
+ if (delivery.inline)
2870
+ return;
2871
+ pendingFailureDiagnostics.delete(toolCallId);
2872
+ await deliverTerminal(store, content);
2873
+ });
2874
+ };
2310
2875
  if (background) {
2311
2876
  void completion.then(async ({ value, resultPath }) => {
2312
- deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2313
- }, (error) => {
2314
- const diagnostic = failureDiagnosticsFrom(error);
2315
- if (diagnostic)
2316
- deliverFailure(pi, diagnostic);
2317
- else
2318
- deliver(pi, `Workflow ${checked.metadata.name} failed: ${formatWorkflowFailure(error)}`);
2877
+ await deliverTerminal(store, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2878
+ }, async (error) => {
2879
+ await deliverTerminal(store, deliverFailureContent(error));
2319
2880
  });
2320
2881
  return { content: [{ type: "text", text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
2321
2882
  }
2883
+ void completion.then(async ({ value, resultPath }) => {
2884
+ await queueForegroundDelivery(completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2885
+ }, async (error) => {
2886
+ await queueForegroundDelivery(deliverFailureContent(error));
2887
+ });
2322
2888
  const { value } = await completion;
2323
2889
  const run = (await store.load()).run;
2324
2890
  return { content: [{ type: "text", text: JSON.stringify(value) }, { type: "text", text: `Workflow run ID: ${runId}` }], details: { runId, value, run } };
@@ -2365,7 +2931,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2365
2931
  const entries = await Promise.all((await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)).map(async (runId) => {
2366
2932
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
2367
2933
  try {
2368
- return { store, loaded: await store.load() };
2934
+ const loaded = await store.load();
2935
+ return { store, loaded: { ...loaded, run: withLiveActivities(loaded.run) } };
2369
2936
  }
2370
2937
  catch {
2371
2938
  if (!await store.isComplete())
@@ -2393,12 +2960,12 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2393
2960
  return keepContext ? "dashboard" : "done";
2394
2961
  }
2395
2962
  if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
2396
- const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx);
2963
+ const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx, undefined, false);
2397
2964
  ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
2398
2965
  return keepContext ? "dashboard" : "done";
2399
2966
  }
2400
2967
  if (action === "delete" && stored) {
2401
- if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) {
2968
+ if (!HARD_TERMINAL_RUN_STATES.has(stored.loaded.run.state)) {
2402
2969
  ctx.ui.notify("Stop the workflow before deleting it.", "warning");
2403
2970
  return keepContext ? "dashboard" : "done";
2404
2971
  }
@@ -2418,12 +2985,12 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2418
2985
  if (action === "resume" && run) {
2419
2986
  if (run.lifecycle.state === "budget_exhausted") {
2420
2987
  const patch = rest.length ? JSON.parse(rest.join(" ")) : undefined;
2421
- const result = await resumeWorkflowRun(run.store.runId, patch, ctx);
2422
- ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
2988
+ const result = await resumeWorkflowRun(run.store.runId, patch, ctx, undefined, undefined, false);
2989
+ ctx.ui.notify(result.state === "completed" ? `Workflow ${run.store.runId} completed.` : result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "awaiting_approval" ? "warning" : "info");
2423
2990
  }
2424
2991
  else {
2425
2992
  if (run.lifecycle.state === "interrupted")
2426
- await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
2993
+ await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx, undefined, false);
2427
2994
  else {
2428
2995
  if (run.lifecycle.state === "paused")
2429
2996
  await refreshPausedRunAliases(run, { ...resumeHostContext(ctx), projectTrusted: projectTrusted(ctx) });
@@ -2437,8 +3004,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2437
3004
  const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}");
2438
3005
  if (input === undefined)
2439
3006
  return keepContext ? "dashboard" : "done";
2440
- const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx);
2441
- ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
3007
+ const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx, undefined, undefined, false);
3008
+ ctx.ui.notify(result.state === "completed" ? `Workflow ${run.store.runId} completed.` : result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "awaiting_approval" ? "warning" : "info");
2442
3009
  return keepContext ? "dashboard" : "done";
2443
3010
  }
2444
3011
  if (action === "stop" && run) {
@@ -2591,7 +3158,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2591
3158
  }
2592
3159
  const sorted = navigatorAttentionSort(stores);
2593
3160
  const labels = navigatorRunLabels(sorted);
2594
- const terminalStates = new Set(["completed", "failed", "stopped"]);
3161
+ const terminalStates = HARD_TERMINAL_RUN_STATES;
2595
3162
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
2596
3163
  const hasFailed = sorted.some(({ loaded: { run } }) => run.state === "failed");
2597
3164
  const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : []), ...(hasFailed ? ["Delete all failed"] : [])];
@@ -2659,18 +3226,28 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2659
3226
  };
2660
3227
  const loadDashboard = async () => {
2661
3228
  const loaded = await store.load();
3229
+ const liveRun = withLiveActivities(loaded.run);
2662
3230
  const checkpoints = await store.awaitingCheckpoints();
2663
3231
  const worktrees = await store.worktrees();
3232
+ const completedOperations = ctx.mode === "tui" ? await store.replayableOperations().catch(() => []) : [];
3233
+ const agentResults = new Map();
3234
+ for (const agent of liveRun.agents) {
3235
+ if (agent.state !== "completed" || agent.parentId || !agent.resultPath)
3236
+ continue;
3237
+ const operation = completedOperations.find((candidate) => candidate.path === agent.resultPath);
3238
+ if (operation)
3239
+ agentResults.set(agent.id, operation.value);
3240
+ }
2664
3241
  const actions = new Map();
2665
3242
  const copies = new Map();
2666
3243
  const reviews = new Map();
2667
3244
  const add = (label, value) => { actions.set(label, `${value} ${store.runId}`); };
2668
3245
  const addCopy = (label, value, artifact) => { actions.set(label, "copy"); copies.set(label, { value, artifact }); };
2669
- if (loaded.run.state === "running")
3246
+ if (liveRun.state === "running")
2670
3247
  add("Pause", "pause");
2671
- if (["paused", "interrupted"].includes(loaded.run.state))
3248
+ if (["paused", "interrupted"].includes(liveRun.state))
2672
3249
  add("Resume", "resume");
2673
- if (loaded.run.state === "budget_exhausted") {
3250
+ if (liveRun.state === "budget_exhausted") {
2674
3251
  actions.set("Resume unchanged", `resume ${store.runId}`);
2675
3252
  actions.set("Adjust budget", `adjust ${store.runId}`);
2676
3253
  }
@@ -2679,7 +3256,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2679
3256
  actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
2680
3257
  actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
2681
3258
  }
2682
- if (!terminalStates.has(loaded.run.state))
3259
+ if (!terminalStates.has(liveRun.state))
2683
3260
  add("Stop", "stop");
2684
3261
  for (const cp of checkpoints) {
2685
3262
  if (ctx.mode === "tui") {
@@ -2695,40 +3272,64 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2695
3272
  if (ctx.mode !== "tui")
2696
3273
  actions.set("Refresh", "refresh");
2697
3274
  else
2698
- actions.set("View script", "view-script");
2699
- if (loaded.run.agents.length)
3275
+ actions.set("Open script in editor", "open-script");
3276
+ if (ctx.mode !== "tui" && liveRun.agents.length)
2700
3277
  actions.set("Agents...", "agents");
2701
- if (terminalStates.has(loaded.run.state))
3278
+ if (terminalStates.has(liveRun.state))
2702
3279
  add("Delete", "delete");
2703
3280
  if (ctx.mode === "tui") {
2704
3281
  addCopy("Copy run path", store.directory, "run path");
2705
3282
  addCopy("Copy run ID", store.runId, "run ID");
2706
3283
  }
2707
- return { dashboard: formatWorkflowPhaseDashboard(loaded.run, loaded.snapshot, process.stdout.columns || 80).join("\n"), phaseModel: buildWorkflowPhaseModel(loaded.run, loaded.snapshot), run: loaded.run, snapshot: loaded.snapshot, actions, copies, reviews, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
3284
+ return { dashboard: formatWorkflowPhaseDashboard(liveRun, loaded.snapshot, process.stdout.columns || 80).join("\n"), phaseModel: buildWorkflowPhaseModel(liveRun, loaded.snapshot), run: liveRun, snapshot: loaded.snapshot, actions, copies, reviews, agentResults, agents: liveRun.agents, worktrees, cwd: liveRun.cwd };
3285
+ };
3286
+ const agentWorktreeFor = (dashboard, agent) => agent.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === agent.worktreeOwner) : undefined;
3287
+ const agentActionLabels = (dashboard, agent) => {
3288
+ const worktree = agentWorktreeFor(dashboard, agent);
3289
+ return [
3290
+ ...(agent.attemptDetails?.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
3291
+ ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
3292
+ ...(ctx.mode === "tui" && dashboard.agentResults.has(agent.id) ? ["Open result in editor"] : []),
3293
+ "Copy agent ID",
3294
+ "Back",
3295
+ ];
3296
+ };
3297
+ const forkAgentSession = async (dashboard, agent) => {
3298
+ const attempts = [...(agent.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
3299
+ const worktree = agentWorktreeFor(dashboard, agent);
3300
+ const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
3301
+ const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
3302
+ const index = choice ? choices.indexOf(choice) : -1;
3303
+ const attempt = index >= 0 ? attempts[index] : undefined;
3304
+ if (!attempt)
3305
+ return;
3306
+ const running = !SETTLED_AGENT_STATES.has(agent.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
3307
+ if (running && !await ctx.ui.confirm("Fork running attempt?", "This attempt is still running. The snapshot may end mid-turn and will not receive later updates. It opens read-only to avoid concurrent changes to the workflow agent's working directory. Continue?"))
3308
+ return;
3309
+ try {
3310
+ await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
3311
+ ctx.ui.notify("Forked Pi session in pane.", "info");
3312
+ }
3313
+ catch (error) {
3314
+ ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
3315
+ }
2708
3316
  };
2709
- const selectAgent = async (dashboard) => {
3317
+ const selectAgent = async (dashboard, requestedAgentId) => {
2710
3318
  const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
2711
3319
  const title = (agent) => agentBreadcrumb(agent, byId, true);
2712
3320
  const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
2713
- const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
2714
- const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
2715
- const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
3321
+ let selected;
3322
+ if (requestedAgentId)
3323
+ selected = dashboard.agents.find((agent) => agent.id === requestedAgentId);
3324
+ else {
3325
+ const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
3326
+ const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
3327
+ selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
3328
+ }
2716
3329
  if (!selected)
2717
3330
  return;
2718
- const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
2719
- const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
2720
- const actions = [
2721
- ...(attempts.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
2722
- ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
2723
- "Copy agent ID",
2724
- "Back",
2725
- ];
2726
- const chooseAttempt = async () => {
2727
- const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
2728
- const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
2729
- const index = choice ? choices.indexOf(choice) : -1;
2730
- return index >= 0 ? attempts[index] : undefined;
2731
- };
3331
+ const worktree = agentWorktreeFor(dashboard, selected);
3332
+ const actions = agentActionLabels(dashboard, selected);
2732
3333
  for (;;) {
2733
3334
  const action = await ctx.ui.select(title(selected), actions);
2734
3335
  if (!action || action === "Back")
@@ -2745,63 +3346,83 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2745
3346
  await copyArtifact(worktree.path, "worktree path");
2746
3347
  continue;
2747
3348
  }
2748
- if (action === "Fork as Pi session in pane") {
2749
- const attempt = await chooseAttempt();
2750
- if (!attempt)
2751
- continue;
2752
- const running = !SETTLED_AGENT_STATES.has(selected.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
2753
- if (running && !await ctx.ui.confirm("Fork running attempt?", "This attempt is still running. The snapshot may end mid-turn and will not receive later updates. It opens read-only to avoid concurrent changes to the workflow agent's working directory. Continue?"))
2754
- continue;
2755
- try {
2756
- await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
2757
- ctx.ui.notify("Forked Pi session in pane.", "info");
2758
- }
2759
- catch (error) {
2760
- ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
2761
- }
2762
- }
3349
+ if (action === "Fork as Pi session in pane")
3350
+ await forkAgentSession(dashboard, selected);
2763
3351
  }
2764
3352
  };
2765
3353
  for (;;) {
2766
3354
  let view = await loadDashboard();
2767
3355
  const actionChoice = ctx.mode === "tui"
2768
3356
  ? await ctx.ui.custom((tui, theme, keybindings, done) => {
2769
- let options = [...view.actions.keys(), "Close"];
2770
- let selectedIndex = 0;
2771
3357
  let dashboardOffset = 0;
2772
3358
  let refreshing = false;
2773
3359
  let disposed = false;
3360
+ let detailsMode = false;
3361
+ let actionMode = false;
3362
+ let actionIndex = 0;
2774
3363
  let stopRequested = false;
2775
3364
  let stopStatus;
3365
+ let selectionNeedsScroll = true;
3366
+ let renderedWidth = 80;
3367
+ let refreshGeneration = 0;
2776
3368
  const initialSelection = preserveWorkflowPhaseSelection(view.phaseModel, {});
2777
- let selectedPhaseId = initialSelection.phaseId;
2778
- let selectedAgentId = initialSelection.agentId;
2779
- const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
2780
- const keyLabels = { up: "↑", down: "↓", left: "←", right: "→", tab: "tab", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
2781
- const keyLabel = (binding, fallback) => {
2782
- const keys = keybindingKeys(keybindings, binding);
2783
- return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
3369
+ let tree = buildWorkflowPhaseTree(view.phaseModel);
3370
+ let selectedNodeId = initialSelection.nodeId ?? tree.nodes[0]?.id;
3371
+ let expandedNodeIds = new Set(initialSelection.expandedNodeIds ?? workflowPhaseTreeInitialExpanded(tree));
3372
+ const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_PANEL_FOOTER_ROWS);
3373
+ const keyLabels = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
3374
+ const keyLabel = (binding, fallback) => workflowKeyLabel(keybindings, binding, fallback, keyLabels);
3375
+ const selectedAgentRecord = () => {
3376
+ const node = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
3377
+ return node?.kind === "agent" && node.agentId ? view.agents.find((agent) => agent.id === node.agentId) : undefined;
2784
3378
  };
2785
- const dashboardLayout = () => {
2786
- const rows = terminalRows();
2787
- const hintRows = rows >= 4 ? 1 : 0;
2788
- const separatorRows = rows >= 8 ? 1 : 0;
2789
- const available = Math.max(1, rows - hintRows - separatorRows);
2790
- const actionViewport = Math.min(options.length, Math.max(1, Math.ceil(available / 2)));
2791
- return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
3379
+ const actionOptions = () => {
3380
+ const agent = selectedAgentRecord();
3381
+ return agent ? agentActionLabels(view, agent) : [...view.actions.keys(), "Back"];
2792
3382
  };
2793
- const updateDashboard = async (selectedOption) => {
2794
- const phaseId = selectedPhaseId;
2795
- const agentId = selectedAgentId;
3383
+ let editorRunning = false;
3384
+ const openArtifact = async (artifact, label) => {
3385
+ if (editorRunning)
3386
+ return;
3387
+ editorRunning = true;
3388
+ try {
3389
+ const command = SettingsManager.create(view.cwd, extensionAgentDir, { projectTrusted: projectTrusted(ctx) }).getExternalEditorCommand();
3390
+ if (!command) {
3391
+ ctx.ui.notify(`Cannot open ${label}: no external editor is configured.`, "warning");
3392
+ return;
3393
+ }
3394
+ const exitCode = await openWorkflowArtifact(tui, command, await artifact);
3395
+ if (exitCode !== 0) {
3396
+ const detail = exitCode === null ? "could not be started" : `exited with code ${String(exitCode)}`;
3397
+ ctx.ui.notify(`Cannot open ${label}: external editor ${detail}.`, "warning");
3398
+ }
3399
+ }
3400
+ catch (error) {
3401
+ ctx.ui.notify(`Cannot open ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
3402
+ }
3403
+ finally {
3404
+ editorRunning = false;
3405
+ }
3406
+ };
3407
+ const updateDashboard = async () => {
3408
+ const generation = ++refreshGeneration;
3409
+ const hadExpandableNodes = tree.nodes.some((node) => node.children.length > 0);
2796
3410
  const next = await loadDashboard();
2797
- if (disposed)
3411
+ if (disposed || generation !== refreshGeneration)
2798
3412
  return;
3413
+ const previousNodeId = selectedNodeId;
3414
+ const previousExpanded = expandedNodeIds;
3415
+ const selectedAction = actionMode ? actionOptions()[actionIndex] : undefined;
2799
3416
  view = next;
2800
- options = [...view.actions.keys(), "Close"];
2801
- selectedIndex = Math.max(0, options.indexOf(selectedOption ?? ""));
2802
- const preserved = preserveWorkflowPhaseSelection(view.phaseModel, { phaseId, agentId });
2803
- selectedPhaseId = preserved.phaseId;
2804
- selectedAgentId = preserved.agentId;
3417
+ tree = buildWorkflowPhaseTree(view.phaseModel);
3418
+ selectedNodeId = preserveWorkflowPhaseTreeSelection(tree, { nodeId: previousNodeId }).nodeId;
3419
+ expandedNodeIds = new Set([...previousExpanded].filter((id) => tree.byId.has(id)));
3420
+ if (!hadExpandableNodes && !expandedNodeIds.size && tree.nodes.some((node) => node.children.length > 0))
3421
+ expandedNodeIds = new Set(workflowPhaseTreeInitialExpanded(tree));
3422
+ const nextActions = actionOptions();
3423
+ const preservedActionIndex = selectedAction ? nextActions.indexOf(selectedAction) : -1;
3424
+ actionIndex = preservedActionIndex >= 0 ? preservedActionIndex : selectedAction ? nextActions.length - 1 : Math.min(actionIndex, Math.max(0, nextActions.length - 1));
3425
+ selectionNeedsScroll = true;
2805
3426
  tui.requestRender();
2806
3427
  };
2807
3428
  const requestStop = () => {
@@ -2810,16 +3431,16 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2810
3431
  stopRequested = true;
2811
3432
  stopStatus = undefined;
2812
3433
  setWorkflowStatus(undefined);
2813
- const selectedOption = options[selectedIndex];
2814
3434
  void runAction(`stop ${store.runId}`, true, (status) => {
2815
3435
  stopStatus = status;
2816
3436
  setWorkflowStatus(status);
2817
3437
  if (!disposed)
2818
3438
  tui.requestRender();
2819
- }).then(() => updateDashboard(selectedOption)).catch((error) => {
3439
+ }).then(() => updateDashboard()).catch((error) => {
2820
3440
  if (disposed)
2821
3441
  return;
2822
3442
  stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
3443
+ setWorkflowStatus(stopStatus);
2823
3444
  tui.requestRender();
2824
3445
  }).finally(() => {
2825
3446
  stopRequested = false;
@@ -2831,129 +3452,203 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2831
3452
  if (refreshing || stopRequested)
2832
3453
  return;
2833
3454
  refreshing = true;
2834
- const selectedOption = options[selectedIndex];
2835
- void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
3455
+ void updateDashboard().catch(() => undefined).finally(() => { refreshing = false; });
2836
3456
  }, 1000);
2837
3457
  timer.unref();
2838
- return borderWorkflowOverlay({
3458
+ return {
2839
3459
  render(width) {
3460
+ renderedWidth = width;
3461
+ const narrow = width < 80;
2840
3462
  const styles = themeWorkflowProgressStyles(theme);
2841
- const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { phaseId: selectedPhaseId, agentId: selectedAgentId }, styles);
2842
- const dashboardLines = stopStatus ? [styles.error(stopStatus), ...phaseLines] : phaseLines;
2843
- const actionLines = options.map((option, index) => truncateToVisualLines(`${index === selectedIndex ? "→ " : " "}${option}`, Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "");
2844
- const layout = dashboardLayout();
2845
- const hint = truncateToVisualLines(theme.fg("dim", `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} actions · ${keyLabel("tui.editor.cursorLeft", "←")}/${keyLabel("tui.editor.cursorRight", "→")} phases · ${keyLabel("tui.input.tab", "tab")} agents${dashboardLines.length > layout.dashboardViewport ? ` · ${keyLabel("tui.select.pageUp", "pgup")}/${keyLabel("tui.select.pageDown", "pgdn")} scroll` : ""} · ${keyLabel("tui.select.confirm", "enter")} select · ${keyLabel("tui.select.cancel", "esc")} close · auto-refresh 1s`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
2846
- const compact = [...dashboardLines, "", ...actionLines, "", hint];
2847
- if (compact.length <= layout.rows) {
2848
- dashboardOffset = 0;
2849
- return compact;
3463
+ const agent = selectedAgentRecord();
3464
+ const actions = actionMode ? { title: agent ? "Agent actions" : "Run actions", options: actionOptions(), index: actionIndex } : undefined;
3465
+ const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { nodeId: selectedNodeId, expandedNodeIds: [...expandedNodeIds], ...(narrow && !detailsMode ? { treeOnly: true } : {}), ...(narrow && detailsMode ? { detailsOnly: true } : {}), ...(actions ? { actions } : {}) }, styles);
3466
+ const statusLines = stopStatus ? truncateToVisualLines(styles.error(stopStatus), Number.MAX_SAFE_INTEGER, width, 0).visualLines.map((line) => line.trimEnd()) : [];
3467
+ const content = [...statusLines, ...phaseLines];
3468
+ const rows = terminalRows();
3469
+ const hintRows = rows >= 3 ? 1 : 0;
3470
+ const viewport = Math.max(1, rows - hintRows);
3471
+ const maxOffset = Math.max(0, content.length - viewport);
3472
+ dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
3473
+ if (actionMode) {
3474
+ const label = actions?.options[actionIndex];
3475
+ const actionRow = label ? content.findIndex((line) => line.includes(label)) : -1;
3476
+ if (actionRow >= 0) {
3477
+ if (actionRow < dashboardOffset)
3478
+ dashboardOffset = actionRow;
3479
+ else if (actionRow >= dashboardOffset + viewport)
3480
+ dashboardOffset = actionRow - viewport + 1;
3481
+ }
3482
+ }
3483
+ else if (!detailsMode && selectionNeedsScroll) {
3484
+ const selectedRow = content.findIndex((line) => line.startsWith("→"));
3485
+ if (selectedRow >= 0) {
3486
+ if (selectedRow < dashboardOffset)
3487
+ dashboardOffset = selectedRow;
3488
+ else if (selectedRow >= dashboardOffset + viewport)
3489
+ dashboardOffset = selectedRow - viewport + 1;
3490
+ }
3491
+ selectionNeedsScroll = false;
2850
3492
  }
2851
- const maxOffset = Math.max(0, dashboardLines.length - layout.dashboardViewport);
2852
3493
  dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
2853
- const actionStart = Math.min(Math.max(0, selectedIndex - layout.actionViewport + 1), Math.max(0, options.length - layout.actionViewport));
2854
- return [
2855
- ...dashboardLines.slice(dashboardOffset, dashboardOffset + layout.dashboardViewport),
2856
- ...(layout.separatorRows && layout.dashboardViewport ? [""] : []),
2857
- ...actionLines.slice(actionStart, actionStart + layout.actionViewport),
2858
- ...(layout.hintRows ? [hint] : []),
2859
- ];
3494
+ const hint = truncateToVisualLines(theme.fg("dim", actionMode ? `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} actions · ${keyLabel("tui.select.confirm", "enter")} run · ${keyLabel("tui.select.cancel", "esc")} tree` : `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} tree · ${keyLabel("tui.editor.cursorLeft", "←")}/${keyLabel("tui.editor.cursorRight", "→")} collapse/expand · ${keyLabel("tui.select.confirm", "enter")} inspect · a actions · ${keyLabel("tui.select.cancel", "esc")} ${narrow && detailsMode ? "tree" : "back"}${content.length > viewport ? ` · ${keyLabel("tui.select.pageUp", "pgup")}/${keyLabel("tui.select.pageDown", "pgdn")} scroll` : ""} · auto-refresh 1s`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
3495
+ return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
2860
3496
  },
2861
3497
  invalidate() { },
2862
3498
  handleInput(data) {
2863
- if (stopRequested)
3499
+ if (stopRequested || editorRunning)
3500
+ return;
3501
+ const narrow = renderedWidth < 80;
3502
+ if (!actionMode && (data === "a" || data === "A")) {
3503
+ actionMode = true;
3504
+ actionIndex = 0;
3505
+ dashboardOffset = 0;
3506
+ tui.requestRender();
3507
+ return;
3508
+ }
3509
+ if (actionMode) {
3510
+ const options = actionOptions();
3511
+ if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) {
3512
+ actionMode = false;
3513
+ dashboardOffset = 0;
3514
+ tui.requestRender();
3515
+ return;
3516
+ }
3517
+ if (workflowKeyMatches(keybindings, data, "tui.select.up"))
3518
+ actionIndex = (actionIndex + options.length - 1) % options.length;
3519
+ else if (workflowKeyMatches(keybindings, data, "tui.select.down"))
3520
+ actionIndex = (actionIndex + 1) % options.length;
3521
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp"))
3522
+ dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3523
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown"))
3524
+ dashboardOffset += Math.max(1, terminalRows() - 1);
3525
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
3526
+ const action = options[actionIndex];
3527
+ const agent = selectedAgentRecord();
3528
+ if (!action || action === "Back") {
3529
+ actionMode = false;
3530
+ dashboardOffset = 0;
3531
+ }
3532
+ else if (agent) {
3533
+ const worktree = agentWorktreeFor(view, agent);
3534
+ if (action === "Open result in editor") {
3535
+ const result = view.agentResults.get(agent.id);
3536
+ if (result !== undefined)
3537
+ void openArtifact(Promise.resolve(workflowResultArtifact(result)), "agent result");
3538
+ }
3539
+ else if (action === "Copy agent ID")
3540
+ void copyArtifact(agent.id, "agent ID");
3541
+ else if (action === "Copy branch" && worktree)
3542
+ void copyArtifact(worktree.branch, "branch");
3543
+ else if (action === "Copy worktree path" && worktree)
3544
+ void copyArtifact(worktree.path, "worktree path");
3545
+ else
3546
+ done(`__workflow_fork__:${agent.id}`);
3547
+ }
3548
+ else if (action === "Open script in editor")
3549
+ void openArtifact(readFile(join(store.directory, "workflow.js"), "utf8").then(workflowScriptArtifact), "workflow script");
3550
+ else if (action === "Stop")
3551
+ requestStop();
3552
+ else
3553
+ done(action);
3554
+ }
3555
+ tui.requestRender();
2864
3556
  return;
2865
- const currentPhase = () => view.phaseModel.phases.find((phase) => phase.id === selectedPhaseId);
2866
- const left = keybindings.matches(data, "tui.editor.cursorLeft");
2867
- const right = keybindings.matches(data, "tui.editor.cursorRight");
2868
- if (left || right) {
2869
- const phases = view.phaseModel.phases;
2870
- if (phases.length) {
2871
- const currentIndex = Math.max(0, phases.findIndex((phase) => phase.id === selectedPhaseId));
2872
- const delta = left ? -1 : 1;
2873
- const nextPhase = phases[(currentIndex + delta + phases.length) % phases.length];
2874
- selectedPhaseId = nextPhase?.id;
2875
- selectedAgentId = nextPhase?.agents[0]?.id;
3557
+ }
3558
+ const current = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
3559
+ if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) {
3560
+ if (narrow && detailsMode) {
3561
+ detailsMode = false;
3562
+ selectionNeedsScroll = true;
2876
3563
  }
3564
+ else
3565
+ done("Back");
2877
3566
  }
2878
- else if (keybindings.matches(data, "tui.input.tab")) {
2879
- const agents = currentPhase()?.agents ?? [];
2880
- if (agents.length) {
2881
- const currentIndex = Math.max(0, agents.findIndex((agent) => agent.id === selectedAgentId));
2882
- selectedAgentId = agents[(currentIndex + 1) % agents.length]?.id;
3567
+ else if (narrow && detailsMode) {
3568
+ if (workflowKeyMatches(keybindings, data, "tui.select.pageUp"))
3569
+ dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3570
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown"))
3571
+ dashboardOffset += Math.max(1, terminalRows() - 1);
3572
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
3573
+ if (current?.kind === "agent" && current.agentId) {
3574
+ actionMode = true;
3575
+ actionIndex = 0;
3576
+ }
3577
+ else if (current?.children.length) {
3578
+ if (expandedNodeIds.has(current.id))
3579
+ expandedNodeIds.delete(current.id);
3580
+ else
3581
+ expandedNodeIds.add(current.id);
3582
+ }
2883
3583
  }
2884
3584
  }
2885
- else if (keybindings.matches(data, "tui.select.up"))
2886
- selectedIndex = (selectedIndex + options.length - 1) % options.length;
2887
- else if (keybindings.matches(data, "tui.select.down"))
2888
- selectedIndex = (selectedIndex + 1) % options.length;
2889
- else if (keybindings.matches(data, "tui.select.pageUp")) {
2890
- dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, dashboardLayout().dashboardViewport));
3585
+ else if (workflowKeyMatches(keybindings, data, "tui.editor.cursorLeft")) {
3586
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "left");
3587
+ selectedNodeId = next.nodeId;
3588
+ expandedNodeIds = new Set(next.expandedNodeIds);
3589
+ selectionNeedsScroll = true;
2891
3590
  }
2892
- else if (keybindings.matches(data, "tui.select.pageDown")) {
2893
- dashboardOffset += Math.max(1, dashboardLayout().dashboardViewport);
3591
+ else if (workflowKeyMatches(keybindings, data, "tui.editor.cursorRight")) {
3592
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "right");
3593
+ selectedNodeId = next.nodeId;
3594
+ expandedNodeIds = new Set(next.expandedNodeIds);
3595
+ selectionNeedsScroll = true;
2894
3596
  }
2895
- else if (keybindings.matches(data, "tui.select.confirm")) {
2896
- if (options[selectedIndex] === "Stop")
2897
- requestStop();
2898
- else
2899
- done(options[selectedIndex]);
3597
+ else if (workflowKeyMatches(keybindings, data, "tui.select.up")) {
3598
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "up");
3599
+ selectedNodeId = next.nodeId;
3600
+ selectionNeedsScroll = true;
3601
+ }
3602
+ else if (workflowKeyMatches(keybindings, data, "tui.select.down")) {
3603
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "down");
3604
+ selectedNodeId = next.nodeId;
3605
+ selectionNeedsScroll = true;
3606
+ }
3607
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp"))
3608
+ dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3609
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown"))
3610
+ dashboardOffset += Math.max(1, terminalRows() - 1);
3611
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
3612
+ if (narrow)
3613
+ detailsMode = true;
3614
+ else if (current?.kind === "agent" && current.agentId) {
3615
+ actionMode = true;
3616
+ actionIndex = 0;
3617
+ }
3618
+ else if (current?.children.length) {
3619
+ if (expandedNodeIds.has(current.id))
3620
+ expandedNodeIds.delete(current.id);
3621
+ else
3622
+ expandedNodeIds.add(current.id);
3623
+ }
2900
3624
  }
2901
- else if (keybindings.matches(data, "tui.select.cancel"))
2902
- done(undefined);
2903
3625
  tui.requestRender();
2904
3626
  },
2905
3627
  dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
2906
- }, theme);
2907
- }, { overlay: true })
2908
- : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
2909
- if (!actionChoice || actionChoice === "Close")
2910
- return;
3628
+ };
3629
+ })
3630
+ : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Back"]);
3631
+ if (!actionChoice || actionChoice === "Back") {
3632
+ stores = await loadStores();
3633
+ break;
3634
+ }
2911
3635
  if (actionChoice === "Agents...") {
2912
3636
  await selectAgent(view);
2913
3637
  continue;
2914
3638
  }
2915
- if (actionChoice === "Refresh")
3639
+ if (actionChoice.startsWith("__workflow_agent__:")) {
3640
+ await selectAgent(view, actionChoice.slice("__workflow_agent__:".length));
2916
3641
  continue;
2917
- if (actionChoice === "View script") {
2918
- await ctx.ui.custom((tui, theme, keybindings, done) => {
2919
- const highlighted = highlightCode(view.script, "javascript");
2920
- let offset = 0;
2921
- let renderedLines = [];
2922
- const viewport = () => Math.max(1, tuiRows(tui) - 3 - WORKFLOW_OVERLAY_BORDER_ROWS);
2923
- const move = (delta) => {
2924
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2925
- offset = Math.max(0, Math.min(maxOffset, offset + delta));
2926
- };
2927
- return borderWorkflowOverlay({
2928
- render(width) {
2929
- renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
2930
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2931
- offset = Math.min(offset, maxOffset);
2932
- return [
2933
- theme.fg("accent", "Workflow script"),
2934
- ...renderedLines.slice(offset, offset + viewport()),
2935
- "",
2936
- theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close"),
2937
- ];
2938
- },
2939
- invalidate() { },
2940
- handleInput(data) {
2941
- if (keybindings.matches(data, "tui.select.up"))
2942
- move(-1);
2943
- else if (keybindings.matches(data, "tui.select.down"))
2944
- move(1);
2945
- else if (keybindings.matches(data, "tui.select.pageUp"))
2946
- move(-viewport());
2947
- else if (keybindings.matches(data, "tui.select.pageDown"))
2948
- move(viewport());
2949
- else if (keybindings.matches(data, "tui.select.cancel"))
2950
- done(undefined);
2951
- tui.requestRender();
2952
- },
2953
- }, theme);
2954
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3642
+ }
3643
+ if (actionChoice.startsWith("__workflow_fork__:")) {
3644
+ const agentId = actionChoice.slice("__workflow_fork__:".length);
3645
+ const agent = view.agents.find((candidate) => candidate.id === agentId);
3646
+ if (agent)
3647
+ await forkAgentSession(view, agent);
2955
3648
  continue;
2956
3649
  }
3650
+ if (actionChoice === "Refresh")
3651
+ continue;
2957
3652
  const copy = view.copies.get(actionChoice);
2958
3653
  if (copy) {
2959
3654
  await copyArtifact(copy.value, copy.artifact);
@@ -2988,7 +3683,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2988
3683
  const currentLayout = layout();
2989
3684
  const maxOffset = Math.max(0, renderedLines.length - currentLayout.contentViewport);
2990
3685
  offset = Math.min(offset, maxOffset);
2991
- const hint = truncateToVisualLines(theme.fg("dim", "↑↓/pgup/pgdn scroll · enter select · esc cancel"), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
3686
+ const keyLabels = { up: "", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
3687
+ const keyLabel = (binding, fallback) => workflowKeyLabel(keybindings, binding, fallback, keyLabels);
3688
+ const hint = truncateToVisualLines(theme.fg("dim", `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")}/pgup/pgdn scroll · enter select · esc cancel`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
2992
3689
  const controls = currentLayout.compactControls
2993
3690
  ? [options.map((option, index) => `${index === selectedIndex ? "[" : " "}${option}${index === selectedIndex ? "]" : " "}`).join(" ")]
2994
3691
  : options.map((option, index) => `${index === selectedIndex ? "→ " : " "}${option}`);
@@ -3002,22 +3699,22 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3002
3699
  },
3003
3700
  invalidate() { },
3004
3701
  handleInput(data) {
3005
- if (keybindings.matches(data, "tui.select.up"))
3702
+ if (workflowKeyMatches(keybindings, data, "tui.select.up"))
3006
3703
  selectedIndex = (selectedIndex + options.length - 1) % options.length;
3007
- else if (keybindings.matches(data, "tui.select.down"))
3704
+ else if (workflowKeyMatches(keybindings, data, "tui.select.down"))
3008
3705
  selectedIndex = (selectedIndex + 1) % options.length;
3009
- else if (keybindings.matches(data, "tui.select.pageUp"))
3706
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp"))
3010
3707
  move(-layout().contentViewport);
3011
- else if (keybindings.matches(data, "tui.select.pageDown"))
3708
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown"))
3012
3709
  move(layout().contentViewport);
3013
- else if (keybindings.matches(data, "tui.select.confirm"))
3710
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm"))
3014
3711
  done(options[selectedIndex] === "Cancel" ? undefined : options[selectedIndex]);
3015
- else if (keybindings.matches(data, "tui.select.cancel"))
3712
+ else if (workflowKeyMatches(keybindings, data, "tui.select.cancel"))
3016
3713
  done(undefined);
3017
3714
  tui.requestRender();
3018
3715
  },
3019
3716
  }, theme);
3020
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3717
+ }, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS });
3021
3718
  if (decision) {
3022
3719
  const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
3023
3720
  if (!accepted)
@@ -3044,16 +3741,13 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3044
3741
  pi.on("session_shutdown", async () => {
3045
3742
  try {
3046
3743
  await Promise.all([...runs.entries()].map(async ([runId, run]) => {
3047
- if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
3048
- await run.completion?.catch(() => undefined);
3049
- return;
3050
- }
3051
- if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
3744
+ const isTerminal = SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state);
3745
+ if (!isTerminal) {
3052
3746
  try {
3053
3747
  await run.lifecycle.terminal("interrupted");
3054
3748
  }
3055
3749
  catch (error) {
3056
- if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state))
3750
+ if (!SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state))
3057
3751
  throw error;
3058
3752
  }
3059
3753
  run.abortController.abort();