pi-extensible-workflows 3.1.0 → 3.2.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.
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";
18
+ import { openWorkflowArtifact, workflowResultArtifact, workflowScriptArtifact } from "./workflow-artifacts.js";
17
19
  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
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,7 +86,6 @@ 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
  }
87
91
  export class RunLifecycle {
@@ -144,7 +148,7 @@ export class RunLifecycle {
144
148
  await this.enter();
145
149
  }
146
150
  async terminal(state, reason) {
147
- if (["completed", "failed", "stopped"].includes(this.#state))
151
+ if (HARD_TERMINAL_RUN_STATES.has(this.#state))
148
152
  throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
149
153
  await this.#set(state, reason ?? state);
150
154
  for (const resolve of this.#waiters.splice(0))
@@ -165,18 +169,40 @@ export function formatWorkflowPreview(args) {
165
169
  return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
166
170
  }
167
171
  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.";
172
+ export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline parallel-to-summary path by default";
173
+ 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. Foreground results include the completed run ID. Recovery map: agent(..., { retries }) reruns one agent call in the same run for transient failures; workflow_retry({ runId }) replays a failed run into a child; workflow_resume({ runId, budget? }) continues a budget_exhausted run; parentRunId on a new launch only borrows named worktrees and never replays or resumes.";
174
+ function workflowRecoveryGuidance(action, state) {
175
+ if (action === "resume") {
176
+ if (state === "failed")
177
+ return "Failed workflow runs must use workflow_retry({ runId })";
178
+ if (state === "completed")
179
+ return "Completed workflow runs have no recovery action";
180
+ if (state === "stopped")
181
+ return "Stopped workflow runs have no recovery action; launch a new workflow";
182
+ if (state === "interrupted")
183
+ return "Interrupted workflow runs use /workflow resume, not workflow_resume";
184
+ return `Only budget-exhausted runs can be resumed with workflow_resume; source is ${state}`;
185
+ }
186
+ if (state === "budget_exhausted")
187
+ return "Budget-exhausted workflow runs must use workflow_resume({ runId, budget? })";
188
+ if (state === "completed")
189
+ return "Completed workflow runs have no recovery action";
190
+ if (state === "stopped")
191
+ return "Stopped workflow runs cannot be retried; launch a new workflow";
192
+ if (state === "interrupted")
193
+ return "Interrupted workflow runs use /workflow resume, not workflow_retry";
194
+ return `Only failed workflow runs can be retried; source is ${state}`;
195
+ }
170
196
  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" })),
197
+ name: Type.Optional(Type.String({ description: "Required non-empty name for the default inline workflow path; invalid for registered function launches" })),
172
198
  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" })),
199
+ 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(...)" })),
200
+ workflow: Type.Optional(Type.String({ description: "Advanced: registered reusable function as an unqualified name" })),
175
201
  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" })),
202
+ foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of the default background launch" })),
203
+ concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16, description: "Advanced: optional per-run active-agent limit" })),
204
+ budget: Type.Optional(Type.Unknown({ description: "Advanced: optional aggregate soft and hard run budgets" })),
205
+ parentRunId: Type.Optional(Type.String({ description: "Advanced: terminal run whose named worktrees may be reused" })),
180
206
  });
181
207
  export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
182
208
  function phaseNames(source) {
@@ -258,7 +284,7 @@ export function buildWorkflowPhaseModel(run, source) {
258
284
  const agents = observation?.agents ?? [];
259
285
  const counts = phaseAgentCounts(agents);
260
286
  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 };
287
+ return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
262
288
  });
263
289
  let currentPhaseIndex;
264
290
  for (let index = phases.length - 1; index >= 0; index -= 1) {
@@ -282,12 +308,197 @@ export function buildWorkflowPhaseModel(run, source) {
282
308
  result.unassignedAgents = unassignedAgents;
283
309
  return result;
284
310
  }
311
+ function workflowPhaseTreePath(kind, phaseId, operationPath, agentId) {
312
+ const root = `phase/${encodeURIComponent(phaseId)}`;
313
+ if (kind === "phase")
314
+ return root;
315
+ const operation = operationPath.map((part) => encodeURIComponent(part)).join("/");
316
+ if (kind === "operation")
317
+ return `${root}/operation/${operation}`;
318
+ return operation ? `${root}/operation/${operation}/agent/${encodeURIComponent(agentId ?? "")}` : `${root}/agent/${encodeURIComponent(agentId ?? "")}`;
319
+ }
320
+ function workflowPhaseTreeAggregateState(states) {
321
+ if (!states.length || states.every((state) => state === "completed"))
322
+ return "completed";
323
+ if (states.some((state) => state === "failed"))
324
+ return "failed";
325
+ if (states.some((state) => state === "cancelled"))
326
+ return "cancelled";
327
+ if (states.some((state) => state === "running"))
328
+ return "running";
329
+ return "queued";
330
+ }
331
+ export function buildWorkflowPhaseTree(model) {
332
+ const drafts = new Map();
333
+ const roots = [];
334
+ const add = (node, parentId) => {
335
+ const existing = drafts.get(node.id);
336
+ if (existing)
337
+ return existing;
338
+ const draft = { ...node, ...(parentId === undefined ? {} : { parentId }), children: [] };
339
+ drafts.set(draft.id, draft);
340
+ if (parentId === undefined)
341
+ roots.push(draft.id);
342
+ else
343
+ drafts.get(parentId)?.children.push(draft.id);
344
+ return draft;
345
+ };
346
+ const samePath = (left, right) => left.length === right.length && left.every((part, index) => part === right[index]);
347
+ const addPhase = (phaseId, label, agents, phase) => {
348
+ 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 } : {}) });
349
+ const operationNodes = new Map();
350
+ const entries = agents.map((agent) => ({ agent, path: [...(agent.structuralPath ?? [])], node: undefined, defaultParentId: phaseNode.id }));
351
+ const agentEntries = new Map(entries.map((entry) => [entry.agent.id, entry]));
352
+ const acceptedParents = new Map();
353
+ const wouldCycle = (childId, parentId) => {
354
+ const seen = new Set([childId]);
355
+ let current = parentId;
356
+ while (current) {
357
+ if (seen.has(current))
358
+ return true;
359
+ seen.add(current);
360
+ current = acceptedParents.get(current);
361
+ }
362
+ return false;
363
+ };
364
+ for (const entry of entries) {
365
+ const parent = entry.agent.parentId ? agentEntries.get(entry.agent.parentId) : undefined;
366
+ if (parent && !wouldCycle(entry.agent.id, parent.agent.id))
367
+ acceptedParents.set(entry.agent.id, parent.agent.id);
368
+ }
369
+ const operationChain = (path, owner, startIndex = 0) => {
370
+ let parent = owner;
371
+ for (let index = startIndex; index < path.length; index += 1) {
372
+ const prefix = path.slice(0, index + 1);
373
+ const key = `${owner.id}:${JSON.stringify(prefix)}`;
374
+ const existing = operationNodes.get(key);
375
+ if (existing) {
376
+ parent = existing;
377
+ continue;
378
+ }
379
+ const suffix = path.slice(startIndex, index + 1).map((part) => encodeURIComponent(part)).join("/");
380
+ const id = owner.id === phaseNode.id ? workflowPhaseTreePath("operation", phaseId, prefix) : `${owner.id}/operation/${suffix}`;
381
+ const operation = add({ id, kind: "operation", label: prefix.at(-1) ?? "", depth: 0, phaseId, operationPath: prefix, state: "queued", ...(phase ? { phase } : {}) }, parent.id);
382
+ operationNodes.set(key, operation);
383
+ parent = operation;
384
+ }
385
+ return parent;
386
+ };
387
+ for (const entry of entries) {
388
+ if (!acceptedParents.has(entry.agent.id))
389
+ entry.defaultParentId = operationChain(entry.path, phaseNode).id;
390
+ }
391
+ for (const entry of entries) {
392
+ 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);
393
+ }
394
+ const attach = (entry, parentId) => {
395
+ const previous = entry.node.parentId ? drafts.get(entry.node.parentId) : undefined;
396
+ if (previous)
397
+ previous.children = previous.children.filter((childId) => childId !== entry.node.id);
398
+ entry.node.parentId = parentId;
399
+ const parent = drafts.get(parentId);
400
+ if (parent && !parent.children.includes(entry.node.id))
401
+ parent.children.push(entry.node.id);
402
+ };
403
+ for (const entry of entries) {
404
+ const parentId = acceptedParents.get(entry.agent.id);
405
+ const parent = parentId ? agentEntries.get(parentId) : undefined;
406
+ if (parent) {
407
+ if (samePath(entry.path, parent.path))
408
+ entry.defaultParentId = parent.node.id;
409
+ else {
410
+ const commonLength = entry.path.findIndex((part, index) => parent.path[index] !== part);
411
+ const startIndex = commonLength < 0 ? Math.min(entry.path.length, parent.path.length) : commonLength;
412
+ entry.defaultParentId = operationChain(entry.path, parent.node, startIndex).id;
413
+ }
414
+ }
415
+ attach(entry, entry.defaultParentId);
416
+ }
417
+ const setDepth = (node, depth, seen = new Set()) => {
418
+ if (seen.has(node.id))
419
+ return;
420
+ seen.add(node.id);
421
+ node.depth = depth;
422
+ for (const childId of node.children) {
423
+ const child = drafts.get(childId);
424
+ if (child)
425
+ setDepth(child, depth + 1, seen);
426
+ }
427
+ };
428
+ setDepth(phaseNode, 0);
429
+ const statesFor = (node, seen = new Set()) => {
430
+ if (seen.has(node.id))
431
+ return [];
432
+ const nextSeen = new Set(seen).add(node.id);
433
+ return node.children.flatMap((childId) => {
434
+ const child = drafts.get(childId);
435
+ return child?.kind === "agent" ? [child.agent?.state ?? "queued", ...statesFor(child, nextSeen)] : child ? statesFor(child, nextSeen) : [];
436
+ });
437
+ };
438
+ for (const operation of operationNodes.values())
439
+ operation.state = workflowPhaseTreeAggregateState(statesFor(operation));
440
+ };
441
+ for (const phase of model.phases)
442
+ addPhase(phase.id, `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`, phase.agents, phase);
443
+ if (model.unassignedAgents?.length)
444
+ addPhase("unassigned", "Unassigned", model.unassignedAgents);
445
+ const nodes = [...drafts.values()].map((node) => ({ ...node, children: [...node.children] }));
446
+ return { roots, nodes, byId: new Map(nodes.map((node) => [node.id, node])) };
447
+ }
448
+ export function workflowPhaseTreeVisibleNodes(tree, expanded = new Set()) {
449
+ const visible = [];
450
+ const visit = (id) => {
451
+ const node = tree.byId.get(id);
452
+ if (!node)
453
+ return;
454
+ visible.push(node);
455
+ if (expanded.has(node.id))
456
+ for (const childId of node.children)
457
+ visit(childId);
458
+ };
459
+ for (const root of tree.roots)
460
+ visit(root);
461
+ return visible;
462
+ }
463
+ export function workflowPhaseTreeInitialExpanded(tree) {
464
+ return new Set(tree.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
465
+ }
466
+ export function preserveWorkflowPhaseTreeSelection(tree, selection) {
467
+ const node = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? tree.nodes[0];
468
+ return node ? { nodeId: node.id } : {};
469
+ }
470
+ export function navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, direction) {
471
+ const expanded = new Set(expandedNodeIds);
472
+ const current = (selectedNodeId ? tree.byId.get(selectedNodeId) : undefined) ?? tree.nodes[0];
473
+ if (!current)
474
+ return { expandedNodeIds: expanded };
475
+ if (direction === "left") {
476
+ if (current.children.length && expanded.delete(current.id))
477
+ return { nodeId: current.id, expandedNodeIds: expanded };
478
+ return { nodeId: current.parentId ?? current.id, expandedNodeIds: expanded };
479
+ }
480
+ if (direction === "right") {
481
+ if (current.children.length && !expanded.has(current.id)) {
482
+ expanded.add(current.id);
483
+ return { nodeId: current.id, expandedNodeIds: expanded };
484
+ }
485
+ return { nodeId: current.children[0] ?? current.id, expandedNodeIds: expanded };
486
+ }
487
+ const visible = workflowPhaseTreeVisibleNodes(tree, expanded);
488
+ const index = Math.max(0, visible.findIndex((node) => node.id === current.id));
489
+ const next = visible[(index + (direction === "up" ? visible.length - 1 : 1)) % visible.length];
490
+ return { nodeId: next?.id ?? current.id, expandedNodeIds: expanded };
491
+ }
285
492
  export function preserveWorkflowPhaseSelection(model, selection) {
286
493
  const phase = model.phases.find((candidate) => candidate.id === selection.phaseId) ?? (model.currentPhaseIndex === undefined ? undefined : model.phases[model.currentPhaseIndex]) ?? model.phases[0];
287
494
  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 }) };
495
+ return model.unassignedAgents?.length ? { nodeId: workflowPhaseTreePath("phase", "unassigned", []) } : {};
496
+ const tree = buildWorkflowPhaseTree(model);
497
+ const selectedAgent = selection.agentId ? phase.agents.find((candidate) => candidate.id === selection.agentId) : undefined;
498
+ const selectedCandidate = selection.nodeId ? tree.byId.get(selection.nodeId) : undefined;
499
+ const selected = selectedCandidate?.phaseId === phase.id ? selectedCandidate : selectedAgent ? tree.byId.get(workflowPhaseTreePath("agent", phase.id, selectedAgent.structuralPath ?? [], selectedAgent.id)) : undefined;
500
+ const nodeId = selected?.id ?? tree.byId.get(workflowPhaseTreePath("phase", phase.id, []))?.id;
501
+ 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
502
  }
292
503
  function agentGroupKey(agent) { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
293
504
  function agentGroupLabel(agents) {
@@ -322,26 +533,31 @@ function renderGroupedAgents(agents, render, allAgents = agents, groupLabel = (l
322
533
  ...group.entries.map((entry) => render(entry, grouped)),
323
534
  ]);
324
535
  }
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
- }
536
+ const RUN_STATE_GLYPH = { completed: "✓", failed: "✗", stopped: "✗", budget_exhausted: "!", awaiting_input: "●" };
537
+ const AGENT_STATE_GLYPH = { completed: "✓", failed: "✗", cancelled: "✗" };
538
+ function runStateGlyph(state, running) { return state === "running" ? running : RUN_STATE_GLYPH[state] ?? "◆"; }
539
+ function agentStateGlyph(state, running) { return state === "running" ? running : AGENT_STATE_GLYPH[state] ?? ""; }
540
+ const PROGRESS_STATE_STYLE = { completed: "success", failed: "error", cancelled: "error", running: "accent" };
541
+ const WORKFLOW_ICON_STYLE = { completed: "success", failed: "error", stopped: "error", budget_exhausted: "warning", running: "accent" };
542
+ const PHASE_STATE_STYLE = { completed: "success", failed: "error", cancelled: "error", running: "accent", interrupted: "warning", budget_exhausted: "warning" };
543
+ function styleForState(map, state, styles) {
544
+ const key = map[state] ?? "muted";
545
+ return (text) => styles[key](text);
546
+ }
547
+ function progressStyleForState(state, styles) { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
548
+ function workflowIconStyle(state, styles) { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
549
+ function phaseStyleForState(state, styles) { return styleForState(PHASE_STATE_STYLE, state, styles); }
334
550
  export function formatWorkflowProgress(run, spinner = "◇", styles = PLAIN_WORKFLOW_PROGRESS_STYLES) {
335
551
  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);
552
+ const workflowIcon = runStateGlyph(run.state, spinner);
553
+ const iconStyle = workflowIconStyle(run.state, styles);
338
554
  const header = styles.bold(styles.accent(`Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`));
339
- const lines = [`${workflowIconStyle(workflowIcon)} ${header}`];
555
+ const lines = [`${iconStyle(workflowIcon)} ${header}`];
340
556
  const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
341
557
  lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${budgetWarning ? styles.warning(line) : line}`));
342
558
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
343
559
  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 : "○";
560
+ const icon = agentStateGlyph(agent.state, spinner);
345
561
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
346
562
  const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles);
347
563
  const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
@@ -392,6 +608,103 @@ function workflowCatalogBlock(text, expanded) {
392
608
  invalidate() { },
393
609
  };
394
610
  }
611
+ function controlString(value) { return typeof value === "string" && value.trim() ? value : undefined; }
612
+ function controlValue(value) {
613
+ if (value === null)
614
+ return "removed";
615
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
616
+ return String(value);
617
+ const json = JSON.stringify(value);
618
+ return typeof json === "string" ? json : "unknown";
619
+ }
620
+ function controlTitle(name, theme) { return theme.fg("toolTitle", theme.bold(name)); }
621
+ function controlState(state, theme) {
622
+ const color = state === "completed" || state === "running" || state === "stopped" ? "success" : state === "failed" || state === "unknown" ? "error" : state === "budget_exhausted" || state === "awaiting_approval" ? "warning" : "accent";
623
+ return theme.fg(color, state);
624
+ }
625
+ function controlAction(action, theme) {
626
+ const color = /approved|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
627
+ return theme.fg(color, action);
628
+ }
629
+ function budgetPatchEntries(value) {
630
+ if (!object(value))
631
+ return value === undefined ? [] : [controlValue(value)];
632
+ return Object.entries(value).map(([dimension, limits]) => {
633
+ if (limits === null)
634
+ return `${dimension}=removed`;
635
+ if (!object(limits))
636
+ return `${dimension}=${controlValue(limits)}`;
637
+ const parts = ["soft", "hard"].filter((key) => Object.prototype.hasOwnProperty.call(limits, key)).map((key) => `${key}=${controlValue(limits[key])}`);
638
+ return `${dimension} ${parts.join(" ")}`;
639
+ });
640
+ }
641
+ function budgetPatchSummary(value) {
642
+ const entries = budgetPatchEntries(value);
643
+ return entries.length ? entries.join(", ") : "unchanged";
644
+ }
645
+ function budgetPatchDetails(value, theme) {
646
+ const entries = budgetPatchEntries(value);
647
+ return entries.length ? [theme.fg("accent", theme.bold("Budget patch")), ...entries.map((entry) => ` ${theme.fg("toolOutput", entry)}`)] : [];
648
+ }
649
+ function workflowControlValue(result) { return catalogResultValue(result); }
650
+ function workflowControlCall(name, args, theme) {
651
+ const runId = controlString(args.runId) ?? "(missing run ID)";
652
+ if (name === "workflow_respond") {
653
+ const proposalId = controlString(args.proposalId);
654
+ const target = proposalId ? `budget proposal ${proposalId}` : `checkpoint ${controlString(args.name) ?? "(missing name)"}`;
655
+ const decision = args.approved === true ? "approve" : "reject";
656
+ return [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", target)} · ${controlAction(decision, theme)}`].join("\n");
657
+ }
658
+ if (name === "workflow_resume")
659
+ 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");
660
+ if (name === "workflow_retry")
661
+ return `${controlTitle(name, theme)} ${theme.fg("accent", runId)} ${theme.fg("muted", "failed run")}`;
662
+ return `${controlTitle(name, theme)} ${theme.fg("accent", runId)}`;
663
+ }
664
+ function workflowControlResult(name, args, result, expanded, theme, isError) {
665
+ if (isError) {
666
+ const text = result.content?.filter(({ type }) => type === "text").map(({ text }) => text ?? "").join("\n").trim();
667
+ return theme.fg("error", text || `The ${name} tool failed.`);
668
+ }
669
+ const value = workflowControlValue(result);
670
+ if (!object(value))
671
+ return theme.fg("error", `The ${name} tool returned an invalid result.`);
672
+ const runId = controlString(args.runId) ?? controlString(value.runId) ?? "(unknown)";
673
+ const title = controlTitle(name, theme);
674
+ if (name === "workflow_stop") {
675
+ const state = controlString(value.state) ?? "unknown";
676
+ const action = value.stopped === true ? "stopped" : value.reason === "already_terminal" ? "already terminal" : value.reason === "unknown_run" ? "run not found" : "no change";
677
+ if (!expanded)
678
+ return `${title}\nRun ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`;
679
+ 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");
680
+ }
681
+ if (name === "workflow_retry") {
682
+ const childRunId = controlString(value.runId) ?? "(unknown)";
683
+ const state = controlString(value.state) ?? "unknown";
684
+ if (!expanded)
685
+ return [title, `Source ${theme.fg("accent", runId)}`, `Child ${theme.fg("accent", childRunId)} · ${controlState(state, theme)} · ${controlAction("started", theme)}`].join("\n");
686
+ return [title, `Source run: ${theme.fg("accent", runId)}`, `Retry run: ${theme.fg("accent", childRunId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction("started; completed work will be replayed", theme)}`].join("\n");
687
+ }
688
+ if (name === "workflow_resume") {
689
+ const state = controlString(value.state) ?? "unknown";
690
+ const proposalId = controlString(value.proposalId);
691
+ const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : "no change";
692
+ if (!expanded)
693
+ return [title, `Run ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal ${theme.fg("accent", proposalId)}`] : [])].join("\n");
694
+ 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");
695
+ }
696
+ const proposalId = controlString(args.proposalId);
697
+ const checkpointName = controlString(args.name);
698
+ const target = proposalId ? `Budget proposal ${theme.fg("accent", proposalId)}` : `Checkpoint ${theme.fg("accent", checkpointName ?? "(missing)")}`;
699
+ const accepted = value.accepted === true;
700
+ const approved = value.approved === true;
701
+ const reason = controlString(value.reason);
702
+ const action = reason === "proposal_not_pending" ? "not pending" : reason === "checkpoint" && !accepted ? "not pending" : approved ? "approved" : "rejected";
703
+ const state = controlString(value.state);
704
+ if (!expanded)
705
+ return [title, target, `Run ${theme.fg("accent", runId)} · ${controlAction(action, theme)}${state ? ` · ${controlState(state, theme)}` : ""}`].join("\n");
706
+ 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");
707
+ }
395
708
  function catalogText(value) { return value.replace(/\s+/g, " ").trim(); }
396
709
  function catalogResultValue(result) {
397
710
  if (result.details !== undefined)
@@ -468,7 +781,8 @@ function formatWorkflowCatalog(value, expanded, theme) {
468
781
  return theme.fg("error", value.error.message);
469
782
  return theme.fg("error", "The workflow catalog returned an invalid result.");
470
783
  }
471
- const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`);
784
+ const ANSI_SGR_SOURCE = `${String.fromCharCode(27)}\\[[0-9;]*m`;
785
+ const ANSI_SGR = new RegExp(ANSI_SGR_SOURCE);
472
786
  export function truncateWorkflowProgress(text, width) {
473
787
  const safeWidth = Math.max(1, width);
474
788
  return text.split("\n").flatMap((line) => {
@@ -539,7 +853,7 @@ function navigatorRunLabels(entries) {
539
853
  nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
540
854
  return entries.map(({ store, loaded: { run } }) => {
541
855
  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" ? "●" : "◆";
856
+ const glyph = runStateGlyph(run.state, "⠦");
543
857
  const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
544
858
  const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
545
859
  const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
@@ -587,7 +901,7 @@ export function formatNavigatorDashboard(run, checkpoints, worktrees) {
587
901
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
588
902
  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
903
  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" ? "●" : "◆";
904
+ const glyph = runStateGlyph(run.state, "⠦");
591
905
  const header = `${glyph} ${run.workflowName}`;
592
906
  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
907
  const lines = [header, meta, ...formatCompactBudgetStatus(run)];
@@ -598,7 +912,7 @@ export function formatNavigatorDashboard(run, checkpoints, worktrees) {
598
912
  lines.push("");
599
913
  const byId = new Map(run.agents.map((a) => [a.id, a]));
600
914
  const render = ({ agent, depth }, grouped) => {
601
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
915
+ const icon = agentStateGlyph(agent.state, "⠦");
602
916
  const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
603
917
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
604
918
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
@@ -621,7 +935,7 @@ export function formatNavigatorDashboard(run, checkpoints, worktrees) {
621
935
  }
622
936
  return lines.join("\n");
623
937
  }
624
- export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
938
+ export function formatNavigatorRun(loaded, checkpoints, worktrees) {
625
939
  const { run, snapshot } = loaded;
626
940
  const lines = [
627
941
  `Workflow: ${run.workflowName}`,
@@ -664,23 +978,79 @@ export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
664
978
  lines.push(" (none)");
665
979
  for (const checkpoint of checkpoints)
666
980
  lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
667
- lines.push(`Worktrees: ${String(_worktrees.length)}`);
981
+ lines.push(`Worktrees: ${String(worktrees.length)}`);
668
982
  lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
669
983
  return lines.join("\n");
670
984
  }
671
985
  export function formatWorkflowPhaseDashboard(run, snapshot, width, selection = {}, styles = PLAIN_WORKFLOW_PROGRESS_STYLES) {
672
986
  const safeWidth = Math.max(1, width);
673
987
  const model = buildWorkflowPhaseModel(run, snapshot);
988
+ const tree = buildWorkflowPhaseTree(model);
989
+ const expanded = selection.expandedNodeIds === undefined ? workflowPhaseTreeInitialExpanded(tree) : new Set(selection.expandedNodeIds);
674
990
  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)}`;
991
+ // ponytail: ANSI-only width, good enough for the ASCII labels the tree renders
992
+ const ansiPattern = new RegExp(ANSI_SGR_SOURCE, "g");
993
+ const visibleLength = (text) => text.replace(ansiPattern, "").length;
994
+ const padTo = (text, limit) => `${text}${" ".repeat(Math.max(0, limit - visibleLength(text)))}`;
995
+ const phaseStyle = (state) => phaseStyleForState(state, styles);
996
+ const phase = selection.phaseId ? model.phases.find((candidate) => candidate.id === selection.phaseId) : undefined;
997
+ const selectedByAgent = selection.agentId ? tree.nodes.find((node) => node.kind === "agent" && node.agentId === selection.agentId && (!selection.phaseId || node.phaseId === selection.phaseId)) : undefined;
998
+ 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];
999
+ const selectedPhase = selectedNode?.phase ?? (selectedNode ? model.phases.find((candidate) => candidate.id === selectedNode.phaseId) : undefined);
1000
+ const visibleNodes = workflowPhaseTreeVisibleNodes(tree, expanded);
1001
+ const nodeAgents = (node) => {
1002
+ const agents = [];
1003
+ const visit = (id) => {
1004
+ const child = tree.byId.get(id);
1005
+ if (!child)
1006
+ return;
1007
+ if (child.agent)
1008
+ agents.push(child.agent);
1009
+ else
1010
+ for (const childId of child.children)
1011
+ visit(childId);
1012
+ };
1013
+ if (node.agent)
1014
+ agents.push(node.agent);
1015
+ else
1016
+ for (const childId of node.children)
1017
+ visit(childId);
1018
+ return agents;
1019
+ };
1020
+ const nodeStatus = (node) => phaseStyle(node.state)(node.state);
1021
+ const nodeIcon = (node) => node.children.length ? expanded.has(node.id) ? "▾" : "▸" : node.kind === "agent" ? "•" : " ";
1022
+ const treeLine = (node) => {
1023
+ const selected = node.id === selectedNode?.id;
1024
+ const state = progressStyleForState(node.state, styles);
1025
+ return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}`;
1026
+ };
1027
+ const details = (node) => {
1028
+ if (!node)
1029
+ return [styles.muted("No workflow node is selected")];
1030
+ const agents = nodeAgents(node);
1031
+ if (node.kind === "phase") {
1032
+ const selected = node.phase;
1033
+ const counts = selected?.counts ?? phaseAgentCounts(agents);
1034
+ 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)}`];
1035
+ }
1036
+ if (node.kind === "operation") {
1037
+ const states = phaseAgentCounts(agents);
1038
+ 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)}`];
1039
+ }
1040
+ const agent = node.agent;
1041
+ if (!agent)
1042
+ return [styles.muted("Agent details are unavailable")];
1043
+ const byId = new Map(run.agents.map((candidate) => [candidate.id, candidate]));
1044
+ 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)}`, ...(selection.actions ? [] : [styles.muted("enter for agent actions")])];
1045
+ const error = agent.attemptDetails?.at(-1)?.error;
1046
+ if (error)
1047
+ result.push(styles.error(`Error: ${error.code}: ${error.message}`));
1048
+ if (agent.activity)
1049
+ result.push(`Activity: ${agent.activity.text}`);
1050
+ return result;
1051
+ };
679
1052
  const stateNames = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
680
1053
  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
1054
  const lines = [styles.bold(styles.accent(`Workflow: ${run.workflowName}`))];
685
1055
  if (run.error)
686
1056
  lines.push(styles.error(`ERROR ${run.error.code}: ${run.error.message}`));
@@ -688,46 +1058,32 @@ export function formatWorkflowPhaseDashboard(run, snapshot, width, selection = {
688
1058
  for (const event of run.events ?? [])
689
1059
  lines.push(styles.warning(`Warning: ${event.message}`));
690
1060
  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;
1061
+ const renderTree = (limit) => [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.flatMap((node) => wrap(treeLine(node), limit)) : [styles.muted("(empty)")])];
1062
+ const actionRows = () => {
1063
+ const actions = selection.actions;
1064
+ if (!actions)
1065
+ return [];
1066
+ return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
708
1067
  };
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];
1068
+ const detailRows = () => [...details(selectedNode), ...actionRows()];
714
1069
  if (safeWidth >= 80) {
715
- const sidebarWidth = Math.min(38, Math.max(24, Math.floor(safeWidth * 0.36)));
1070
+ const sidebarWidth = Math.min(42, Math.max(24, Math.floor((safeWidth - 3) * 0.38)));
716
1071
  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")];
1072
+ const sidebar = renderTree(sidebarWidth).flatMap((line) => wrap(line, sidebarWidth));
1073
+ const detail = detailRows().flatMap((line) => wrap(line, detailWidth));
719
1074
  const rows = Math.max(sidebar.length, detail.length);
720
1075
  for (let index = 0; index < rows; index += 1)
721
- lines.push(`${sidebar[index] ?? ""} | ${detail[index] ?? ""}`);
1076
+ lines.push(`${padTo(sidebar[index] ?? "", sidebarWidth)} | ${detail[index] ?? ""}`);
1077
+ }
1078
+ else if (selection.detailsOnly) {
1079
+ lines.push(...detailRows().flatMap((line) => wrap(line)));
722
1080
  }
723
1081
  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)));
1082
+ lines.push(...renderTree(safeWidth));
1083
+ if (!selection.treeOnly)
1084
+ lines.push("", ...detailRows().flatMap((line) => wrap(line)));
729
1085
  }
730
- if (model.unassignedAgents?.length)
1086
+ if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned"))
731
1087
  lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
732
1088
  return lines.flatMap((line) => wrap(line));
733
1089
  }
@@ -1216,23 +1572,22 @@ function projectTrusted(ctx) {
1216
1572
  const check = object(ctx) ? ctx.isProjectTrusted : undefined;
1217
1573
  return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
1218
1574
  }
1219
- function isEntryRenderer(value) { return typeof value === "function"; }
1575
+ function asFn(value) { return typeof value === "function" ? value : undefined; }
1220
1576
  function isWorkflowEventSink(value) { return object(value) && typeof value.emit === "function"; }
1221
1577
  function piHostCapabilities(pi) {
1222
1578
  if (!object(pi))
1223
1579
  return {};
1224
- const registerEntryRenderer = pi.registerEntryRenderer;
1580
+ const registerEntryRenderer = asFn(pi.registerEntryRenderer);
1225
1581
  const events = pi.events;
1226
- return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
1582
+ return { ...(registerEntryRenderer ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
1227
1583
  }
1228
- function isModelRegistryGetter(value) { return typeof value === "function"; }
1229
1584
  function contextHostCapabilities(ctx) {
1230
1585
  if (!object(ctx) || !object(ctx.modelRegistry))
1231
1586
  return {};
1232
1587
  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) } : {}) } };
1588
+ const getAll = asFn(registry.getAll);
1589
+ const getAvailable = asFn(registry.getAvailable);
1590
+ return { modelRegistry: { ...(getAll ? { getAll: () => getAll.call(registry) } : {}), ...(getAvailable ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
1236
1591
  }
1237
1592
  function modelInventory(root, registry) {
1238
1593
  const all = registry?.getAll?.() ?? registry?.getAvailable?.() ?? [];
@@ -1266,24 +1621,21 @@ async function resolveLaunchAliases(registry, staticAliases, context, availableM
1266
1621
  throw error;
1267
1622
  }
1268
1623
  }
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
1624
  function uiHostCapabilities(ui) {
1273
1625
  if (!object(ui))
1274
1626
  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 } : {}) };
1627
+ const select = asFn(ui.select);
1628
+ const input = asFn(ui.input);
1629
+ const setStatus = asFn(ui.setStatus);
1630
+ return { ...(select ? { select } : {}), ...(input ? { input } : {}), ...(setStatus ? { setStatus } : {}) };
1279
1631
  }
1280
- function tuiHostCapabilities(tui) {
1281
- if (!object(tui) || !object(tui.terminal))
1282
- return {};
1283
- return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
1632
+ function tuiRows(tui) {
1633
+ const rows = object(tui) && object(tui.terminal) ? tui.terminal.rows : undefined;
1634
+ return typeof rows === "number" && Number.isFinite(rows) ? rows : 24;
1284
1635
  }
1285
- function tuiRows(tui) { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
1286
1636
  const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
1637
+ const WORKFLOW_OVERLAY_TOP_MARGIN = 1;
1638
+ const WORKFLOW_OVERLAY_OPTIONS = { anchor: "top-left", width: "100%", maxHeight: "100%", margin: { top: WORKFLOW_OVERLAY_TOP_MARGIN } };
1287
1639
  function borderWorkflowOverlay(component, theme) {
1288
1640
  return {
1289
1641
  ...component,
@@ -1293,13 +1645,10 @@ function borderWorkflowOverlay(component, theme) {
1293
1645
  },
1294
1646
  };
1295
1647
  }
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 };
1648
+ function keybindingKeys(keybindings, name) {
1649
+ const getKeys = object(keybindings) ? asFn(keybindings.getKeys) : undefined;
1650
+ return getKeys ? getKeys.call(keybindings, name) : undefined;
1301
1651
  }
1302
- function keybindingKeys(keybindings, name) { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
1303
1652
  export default function workflowExtension(pi, home, clipboard = copyToClipboard, createSession = createNativeAgentSession, agentDir) {
1304
1653
  beginWorkflowExtensionLoading();
1305
1654
  const registry = loadingRegistry();
@@ -1558,7 +1907,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1558
1907
  catch {
1559
1908
  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
1909
  }
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 } : {}) };
1910
+ const resultPath = !node.parentId && node.options.agentIdentity ? agentIdentityPath(node.options.agentIdentity) : undefined;
1911
+ 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 } : {}) };
1562
1912
  });
1563
1913
  return { ...current, agents };
1564
1914
  });
@@ -1567,7 +1917,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1567
1917
  });
1568
1918
  const cleanupTerminalRun = async (runId) => {
1569
1919
  const run = runs.get(runId);
1570
- if (!run || !["completed", "failed", "stopped"].includes(run.lifecycle.state))
1920
+ if (!run || !HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state))
1571
1921
  return;
1572
1922
  await scheduler.cancelRun(runId);
1573
1923
  await scheduler.flush();
@@ -1705,6 +2055,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1705
2055
  throw mainAgentError(error);
1706
2056
  }
1707
2057
  },
2058
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_respond", args, theme)); },
2059
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_respond", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1708
2060
  });
1709
2061
  pi.registerTool({
1710
2062
  name: "workflow_stop",
@@ -1720,6 +2072,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1720
2072
  throw mainAgentError(error);
1721
2073
  }
1722
2074
  },
2075
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_stop", args, theme)); },
2076
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_stop", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1723
2077
  });
1724
2078
  let catalogRegistered = false;
1725
2079
  let sessionStarted = false;
@@ -1751,30 +2105,78 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1751
2105
  });
1752
2106
  catalogRegistered = true;
1753
2107
  };
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));
2108
+ const createAgentExecutor = (root) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, agentSetupHooks: registry.agentSetupHooks() }, createSession);
2109
+ const activeSnapshotTools = (tools, active) => active === "session"
2110
+ ? new Set(tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog"))
2111
+ : new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
2112
+ const resumeLaunchPrologue = async (input) => {
2113
+ const active = new Set(pi.getActiveTools().filter((tool) => !INTERNAL_WORKFLOW_TOOLS.includes(tool)));
2114
+ const missing = input.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1758
2115
  if (missing)
1759
2116
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1760
2117
  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);
2118
+ const resolution = resolveWorkflowSettings(input.cwd, input.trustedProject, settingsPath);
2119
+ const currentPolicy = resolveAgentResourcePolicy(input.cwd, input.trustedProject, settingsPath);
1764
2120
  const staticAliases = resolution.effective.modelAliases ?? {};
1765
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2121
+ const previousAliases = input.snapshot.modelAliases ?? input.snapshot.settings.modelAliases ?? {};
2122
+ const inventory = modelInventory(input.rootModel, input.modelRegistry);
2123
+ const knownModels = input.modelRegistry ? inventory.knownModels : new Set([...input.snapshot.models, ...inventory.knownModels]);
2124
+ const availableModels = input.modelRegistry ? inventory.availableModels : new Set([...input.snapshot.models, ...inventory.availableModels]);
2125
+ 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;
2126
+ const blockedAliases = input.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2127
+ const blockedAliasTargets = input.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2128
+ let script;
2129
+ if (input.withPreflight) {
2130
+ const resumeAliases = { ...previousAliases, ...currentAliases };
2131
+ script = launchScriptForSnapshot(input.snapshot, registry);
2132
+ 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);
2133
+ }
2134
+ const refreshed = resumedSnapshotSettings(input.snapshot, resolution, currentAliases);
2135
+ const snapshot = createLaunchSnapshot({ ...input.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
2136
+ return { active, settingsPath, resolution, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script };
2137
+ };
2138
+ const workflowAgentHandler = (store, metadata, lifecycle, executor, cwd, runId) => async (prompt, options, agentSignal, identity) => {
2139
+ await lifecycle.enter();
2140
+ try {
2141
+ const path = agentIdentityPath(identity);
2142
+ const replayed = await store.replay(path);
2143
+ if (replayed) {
2144
+ return replayed.value;
2145
+ }
2146
+ const worktree = agentWorktree(identity);
2147
+ const agentCwd = worktree.worktreeOwner ? (await persistWorktree(store, metadata, worktree.worktreeOwner)).cwd : cwd;
2148
+ const role = typeof options.role === "string" ? options.role : undefined;
2149
+ const model = typeof options.model === "string" ? options.model : undefined;
2150
+ const thinking = parseThinking(options.thinking);
2151
+ const requestedLabel = typeof options.label === "string" ? options.label : undefined;
2152
+ const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools } : {}) });
2153
+ const label = displayAgentName(requestedLabel, role, resolved.model);
2154
+ const tools = resolved.tools;
2155
+ const schema = object(options.outputSchema) ? options.outputSchema : undefined;
2156
+ 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 });
2157
+ const cancel = () => { scheduler.cancel(spawned.id); };
2158
+ if (agentSignal.aborted)
2159
+ cancel();
2160
+ else
2161
+ agentSignal.addEventListener("abort", cancel, { once: true });
2162
+ const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
2163
+ if (!outcome.ok)
2164
+ throw new WorkflowError(outcome.error.code, outcome.error.message);
2165
+ await store.complete(path, outcome.value);
2166
+ return outcome.value;
2167
+ }
2168
+ finally {
2169
+ await lifecycle.leave();
2170
+ }
2171
+ };
2172
+ const refreshPausedRunAliases = async (run, context) => {
2173
+ const loaded = await run.store.load();
2174
+ const trustedProject = context?.projectTrusted ?? run.projectTrusted();
1766
2175
  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 });
2176
+ 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
2177
  await run.store.saveSnapshot(snapshot);
1776
2178
  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);
2179
+ 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
2180
  run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
1779
2181
  const drift = aliasDrift(previousAliases, currentAliases);
1780
2182
  if (drift.length)
@@ -1793,19 +2195,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1793
2195
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
1794
2196
  if (missingRole)
1795
2197
  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
2198
  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
2199
  const controller = new AbortController();
1810
2200
  if (context?.signal?.aborted)
1811
2201
  controller.abort();
@@ -1813,17 +2203,12 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1813
2203
  context?.signal?.addEventListener("abort", () => { controller.abort(); }, { once: true });
1814
2204
  }
1815
2205
  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 });
2206
+ 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 });
2207
+ if (!script)
2208
+ throw new WorkflowError("INTERNAL_ERROR", "Resume preflight did not produce a launch script");
1824
2209
  await run.store.saveSnapshot(snapshot);
1825
2210
  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);
2211
+ 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
2212
  const drift = aliasDrift(previousAliases, currentAliases);
1828
2213
  if (drift.length)
1829
2214
  await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
@@ -1835,7 +2220,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1835
2220
  }
1836
2221
  catch (error) {
1837
2222
  const typed = asWorkflowError(error);
1838
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
2223
+ if (!HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) {
1839
2224
  await run.lifecycle.terminal("failed", typed.code).catch(() => undefined);
1840
2225
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } }));
1841
2226
  await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed");
@@ -1848,37 +2233,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1848
2233
  }
1849
2234
  await scheduler.cancelRun(run.store.runId);
1850
2235
  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);
2236
+ 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, 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);
1882
2237
  run.execution = execution;
1883
2238
  const completion = execution.result.then(async (value) => {
1884
2239
  await scheduler.flush();
@@ -1924,11 +2279,26 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1924
2279
  };
1925
2280
  const resumeWorkflowRun = async (runId, rawPatch, context, signal) => {
1926
2281
  const run = runs.get(runId);
1927
- if (!run)
1928
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
2282
+ if (!run) {
2283
+ const host = object(context) ? context : {};
2284
+ const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
2285
+ const sessionManager = object(host.sessionManager) ? host.sessionManager : undefined;
2286
+ const sessionId = typeof sessionManager?.getSessionId === "function" ? String(Reflect.apply(sessionManager.getSessionId, sessionManager, [])) : undefined;
2287
+ if (cwd && sessionId) {
2288
+ try {
2289
+ const state = (await new RunStore(cwd, sessionId, runId, home).load()).run.state;
2290
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", state));
2291
+ }
2292
+ catch (error) {
2293
+ if (error instanceof WorkflowError)
2294
+ throw error;
2295
+ }
2296
+ }
2297
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session`);
2298
+ }
1929
2299
  const loaded = await run.store.load();
1930
2300
  if (loaded.run.state !== "budget_exhausted")
1931
- throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
2301
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", loaded.run.state));
1932
2302
  const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
1933
2303
  const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
1934
2304
  const nextBudget = mergeBudget(currentBudget, patch);
@@ -1973,10 +2343,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1973
2343
  loaded = await sourceStore.load();
1974
2344
  }
1975
2345
  catch (error) {
1976
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load failed source run ${runId}: ${errorText(error)}`);
2346
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session: ${errorText(error)}`);
1977
2347
  }
1978
2348
  if (loaded.run.state !== "failed")
1979
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Only failed workflow runs can be retried; source is ${loaded.run.state}`);
2349
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("retry", loaded.run.state));
1980
2350
  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
2351
  throw new WorkflowError("RESUME_INCOMPATIBLE", "The source retry provenance is incomplete");
1982
2352
  const lineageRootRunId = loaded.run.retry?.lineageRootRunId ?? loaded.run.id;
@@ -2012,28 +2382,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2012
2382
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
2013
2383
  if (missingRole)
2014
2384
  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
2385
  const modelRegistry = contextHostCapabilities(context).modelRegistry;
2025
2386
  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
2387
  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);
2388
+ 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
2389
  await sourceStore.validateNamedWorktrees();
2038
2390
  for (const name of loaded.run.retry?.namedWorktrees ?? [])
2039
2391
  await sourceStore.resolveNamedWorktree(name);
@@ -2043,8 +2395,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2043
2395
  const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
2044
2396
  const childRunId = randomUUID();
2045
2397
  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 });
2398
+ const childSnapshot = childBaseSnapshot;
2048
2399
  const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
2049
2400
  const childInitialBudget = childBudget.snapshot();
2050
2401
  const retry = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
@@ -2055,7 +2406,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2055
2406
  const abortController = new AbortController();
2056
2407
  const providerErrorRecovery = createProviderErrorRecovery(context, availableModels, () => { abortController.abort(); });
2057
2408
  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 } : {}) };
2409
+ 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
2410
  runs.set(childRunId, childRun);
2060
2411
  scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
2061
2412
  await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
@@ -2086,6 +2437,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2086
2437
  throw mainAgentError(error);
2087
2438
  }
2088
2439
  },
2440
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_retry", args, theme)); },
2441
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_retry", context.args, result, options.expanded, theme, context.isError), options.expanded); },
2089
2442
  });
2090
2443
  pi.registerTool({
2091
2444
  name: "workflow_resume",
@@ -2101,6 +2454,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2101
2454
  throw mainAgentError(error);
2102
2455
  }
2103
2456
  },
2457
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_resume", args, theme)); },
2458
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_resume", context.args, result, options.expanded, theme, context.isError), options.expanded); },
2104
2459
  });
2105
2460
  pi.on("session_start", async (_event, ctx) => {
2106
2461
  if (sessionStarted)
@@ -2143,7 +2498,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2143
2498
  const roleDefinitions = loaded.snapshot.roles ?? {};
2144
2499
  const abortController = new AbortController();
2145
2500
  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 } : {}) });
2501
+ 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
2502
  for (const checkpoint of await store.awaitingCheckpoints())
2148
2503
  deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
2149
2504
  for (const decision of await store.pendingWorkflowDecisions())
@@ -2205,7 +2560,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2205
2560
  const inventory = modelInventory(rootModel, modelRegistry);
2206
2561
  const knownModels = inventory.knownModels;
2207
2562
  const availableModels = inventory.availableModels;
2208
- const rootTools = pi.getActiveTools().filter((name) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(name));
2563
+ const rootTools = pi.getActiveTools().filter((name) => !INTERNAL_WORKFLOW_TOOLS.includes(name));
2209
2564
  const trustedProject = projectTrusted(ctx);
2210
2565
  const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
2211
2566
  const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
@@ -2242,45 +2597,12 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2242
2597
  const providerPause = async () => { if (background)
2243
2598
  deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
2244
2599
  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);
2600
+ 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
2601
  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
2602
  if (params.foreground && onUpdate)
2248
2603
  onUpdate(workflowToolUpdate((await store.load()).run));
2249
2604
  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);
2605
+ 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), 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
2606
  runs.get(runId).execution = execution;
2285
2607
  await eventPublisher.runStarted(store, checked.metadata);
2286
2608
  const finish = execution.result.then(async (value) => {
@@ -2398,7 +2720,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2398
2720
  return keepContext ? "dashboard" : "done";
2399
2721
  }
2400
2722
  if (action === "delete" && stored) {
2401
- if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) {
2723
+ if (!HARD_TERMINAL_RUN_STATES.has(stored.loaded.run.state)) {
2402
2724
  ctx.ui.notify("Stop the workflow before deleting it.", "warning");
2403
2725
  return keepContext ? "dashboard" : "done";
2404
2726
  }
@@ -2591,7 +2913,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2591
2913
  }
2592
2914
  const sorted = navigatorAttentionSort(stores);
2593
2915
  const labels = navigatorRunLabels(sorted);
2594
- const terminalStates = new Set(["completed", "failed", "stopped"]);
2916
+ const terminalStates = HARD_TERMINAL_RUN_STATES;
2595
2917
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
2596
2918
  const hasFailed = sorted.some(({ loaded: { run } }) => run.state === "failed");
2597
2919
  const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : []), ...(hasFailed ? ["Delete all failed"] : [])];
@@ -2661,6 +2983,15 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2661
2983
  const loaded = await store.load();
2662
2984
  const checkpoints = await store.awaitingCheckpoints();
2663
2985
  const worktrees = await store.worktrees();
2986
+ const completedOperations = ctx.mode === "tui" ? await store.replayableOperations().catch(() => []) : [];
2987
+ const agentResults = new Map();
2988
+ for (const agent of loaded.run.agents) {
2989
+ if (agent.state !== "completed" || agent.parentId || !agent.resultPath)
2990
+ continue;
2991
+ const operation = completedOperations.find((candidate) => candidate.path === agent.resultPath);
2992
+ if (operation)
2993
+ agentResults.set(agent.id, operation.value);
2994
+ }
2664
2995
  const actions = new Map();
2665
2996
  const copies = new Map();
2666
2997
  const reviews = new Map();
@@ -2695,8 +3026,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2695
3026
  if (ctx.mode !== "tui")
2696
3027
  actions.set("Refresh", "refresh");
2697
3028
  else
2698
- actions.set("View script", "view-script");
2699
- if (loaded.run.agents.length)
3029
+ actions.set("Open script in editor", "open-script");
3030
+ if (ctx.mode !== "tui" && loaded.run.agents.length)
2700
3031
  actions.set("Agents...", "agents");
2701
3032
  if (terminalStates.has(loaded.run.state))
2702
3033
  add("Delete", "delete");
@@ -2704,31 +3035,55 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2704
3035
  addCopy("Copy run path", store.directory, "run path");
2705
3036
  addCopy("Copy run ID", store.runId, "run ID");
2706
3037
  }
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 };
3038
+ 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, agentResults, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
3039
+ };
3040
+ const agentWorktreeFor = (dashboard, agent) => agent.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === agent.worktreeOwner) : undefined;
3041
+ const agentActionLabels = (dashboard, agent) => {
3042
+ const worktree = agentWorktreeFor(dashboard, agent);
3043
+ return [
3044
+ ...(agent.attemptDetails?.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
3045
+ ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
3046
+ ...(ctx.mode === "tui" && dashboard.agentResults.has(agent.id) ? ["Open result in editor"] : []),
3047
+ "Copy agent ID",
3048
+ "Back",
3049
+ ];
2708
3050
  };
2709
- const selectAgent = async (dashboard) => {
3051
+ const forkAgentSession = async (dashboard, agent) => {
3052
+ const attempts = [...(agent.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
3053
+ const worktree = agentWorktreeFor(dashboard, agent);
3054
+ const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
3055
+ const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
3056
+ const index = choice ? choices.indexOf(choice) : -1;
3057
+ const attempt = index >= 0 ? attempts[index] : undefined;
3058
+ if (!attempt)
3059
+ return;
3060
+ const running = !SETTLED_AGENT_STATES.has(agent.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
3061
+ 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?"))
3062
+ return;
3063
+ try {
3064
+ await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
3065
+ ctx.ui.notify("Forked Pi session in pane.", "info");
3066
+ }
3067
+ catch (error) {
3068
+ ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
3069
+ }
3070
+ };
3071
+ const selectAgent = async (dashboard, requestedAgentId) => {
2710
3072
  const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
2711
3073
  const title = (agent) => agentBreadcrumb(agent, byId, true);
2712
3074
  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;
3075
+ let selected;
3076
+ if (requestedAgentId)
3077
+ selected = dashboard.agents.find((agent) => agent.id === requestedAgentId);
3078
+ else {
3079
+ const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
3080
+ const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
3081
+ selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
3082
+ }
2716
3083
  if (!selected)
2717
3084
  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
- };
3085
+ const worktree = agentWorktreeFor(dashboard, selected);
3086
+ const actions = agentActionLabels(dashboard, selected);
2732
3087
  for (;;) {
2733
3088
  const action = await ctx.ui.select(title(selected), actions);
2734
3089
  if (!action || action === "Back")
@@ -2745,63 +3100,87 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2745
3100
  await copyArtifact(worktree.path, "worktree path");
2746
3101
  continue;
2747
3102
  }
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
- }
3103
+ if (action === "Fork as Pi session in pane")
3104
+ await forkAgentSession(dashboard, selected);
2763
3105
  }
2764
3106
  };
2765
3107
  for (;;) {
2766
3108
  let view = await loadDashboard();
2767
3109
  const actionChoice = ctx.mode === "tui"
2768
3110
  ? await ctx.ui.custom((tui, theme, keybindings, done) => {
2769
- let options = [...view.actions.keys(), "Close"];
2770
- let selectedIndex = 0;
2771
3111
  let dashboardOffset = 0;
2772
3112
  let refreshing = false;
2773
3113
  let disposed = false;
3114
+ let detailsMode = false;
3115
+ let actionMode = false;
3116
+ let actionIndex = 0;
2774
3117
  let stopRequested = false;
2775
3118
  let stopStatus;
3119
+ let selectionNeedsScroll = true;
3120
+ let renderedWidth = 80;
3121
+ let refreshGeneration = 0;
2776
3122
  const initialSelection = preserveWorkflowPhaseSelection(view.phaseModel, {});
2777
- let selectedPhaseId = initialSelection.phaseId;
2778
- let selectedAgentId = initialSelection.agentId;
3123
+ let tree = buildWorkflowPhaseTree(view.phaseModel);
3124
+ let selectedNodeId = initialSelection.nodeId ?? tree.nodes[0]?.id;
3125
+ let expandedNodeIds = new Set(initialSelection.expandedNodeIds ?? workflowPhaseTreeInitialExpanded(tree));
2779
3126
  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" };
3127
+ const keyLabels = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
2781
3128
  const keyLabel = (binding, fallback) => {
2782
3129
  const keys = keybindingKeys(keybindings, binding);
2783
3130
  return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
2784
3131
  };
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 };
3132
+ const selectedAgentRecord = () => {
3133
+ const node = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
3134
+ return node?.kind === "agent" && node.agentId ? view.agents.find((agent) => agent.id === node.agentId) : undefined;
3135
+ };
3136
+ const actionOptions = () => {
3137
+ const agent = selectedAgentRecord();
3138
+ return agent ? agentActionLabels(view, agent) : [...view.actions.keys(), "Back"];
3139
+ };
3140
+ let editorRunning = false;
3141
+ const openArtifact = async (artifact, label) => {
3142
+ if (editorRunning)
3143
+ return;
3144
+ editorRunning = true;
3145
+ try {
3146
+ const command = SettingsManager.create(view.cwd, extensionAgentDir, { projectTrusted: projectTrusted(ctx) }).getExternalEditorCommand();
3147
+ if (!command) {
3148
+ ctx.ui.notify(`Cannot open ${label}: no external editor is configured.`, "warning");
3149
+ return;
3150
+ }
3151
+ const exitCode = await openWorkflowArtifact(tui, command, await artifact);
3152
+ if (exitCode !== 0) {
3153
+ const detail = exitCode === null ? "could not be started" : `exited with code ${String(exitCode)}`;
3154
+ ctx.ui.notify(`Cannot open ${label}: external editor ${detail}.`, "warning");
3155
+ }
3156
+ }
3157
+ catch (error) {
3158
+ ctx.ui.notify(`Cannot open ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
3159
+ }
3160
+ finally {
3161
+ editorRunning = false;
3162
+ tui.requestRender(true);
3163
+ }
2792
3164
  };
2793
- const updateDashboard = async (selectedOption) => {
2794
- const phaseId = selectedPhaseId;
2795
- const agentId = selectedAgentId;
3165
+ const updateDashboard = async () => {
3166
+ const generation = ++refreshGeneration;
3167
+ const hadExpandableNodes = tree.nodes.some((node) => node.children.length > 0);
2796
3168
  const next = await loadDashboard();
2797
- if (disposed)
3169
+ if (disposed || generation !== refreshGeneration)
2798
3170
  return;
3171
+ const previousNodeId = selectedNodeId;
3172
+ const previousExpanded = expandedNodeIds;
3173
+ const selectedAction = actionMode ? actionOptions()[actionIndex] : undefined;
2799
3174
  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;
3175
+ tree = buildWorkflowPhaseTree(view.phaseModel);
3176
+ selectedNodeId = preserveWorkflowPhaseTreeSelection(tree, { nodeId: previousNodeId }).nodeId;
3177
+ expandedNodeIds = new Set([...previousExpanded].filter((id) => tree.byId.has(id)));
3178
+ if (!hadExpandableNodes && !expandedNodeIds.size && tree.nodes.some((node) => node.children.length > 0))
3179
+ expandedNodeIds = new Set(workflowPhaseTreeInitialExpanded(tree));
3180
+ const nextActions = actionOptions();
3181
+ const preservedActionIndex = selectedAction ? nextActions.indexOf(selectedAction) : -1;
3182
+ actionIndex = preservedActionIndex >= 0 ? preservedActionIndex : selectedAction ? nextActions.length - 1 : Math.min(actionIndex, Math.max(0, nextActions.length - 1));
3183
+ selectionNeedsScroll = true;
2805
3184
  tui.requestRender();
2806
3185
  };
2807
3186
  const requestStop = () => {
@@ -2810,16 +3189,16 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2810
3189
  stopRequested = true;
2811
3190
  stopStatus = undefined;
2812
3191
  setWorkflowStatus(undefined);
2813
- const selectedOption = options[selectedIndex];
2814
3192
  void runAction(`stop ${store.runId}`, true, (status) => {
2815
3193
  stopStatus = status;
2816
3194
  setWorkflowStatus(status);
2817
3195
  if (!disposed)
2818
3196
  tui.requestRender();
2819
- }).then(() => updateDashboard(selectedOption)).catch((error) => {
3197
+ }).then(() => updateDashboard()).catch((error) => {
2820
3198
  if (disposed)
2821
3199
  return;
2822
3200
  stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
3201
+ setWorkflowStatus(stopStatus);
2823
3202
  tui.requestRender();
2824
3203
  }).finally(() => {
2825
3204
  stopRequested = false;
@@ -2831,80 +3210,181 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2831
3210
  if (refreshing || stopRequested)
2832
3211
  return;
2833
3212
  refreshing = true;
2834
- const selectedOption = options[selectedIndex];
2835
- void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
3213
+ void updateDashboard().catch(() => undefined).finally(() => { refreshing = false; });
2836
3214
  }, 1000);
2837
3215
  timer.unref();
2838
3216
  return borderWorkflowOverlay({
2839
3217
  render(width) {
3218
+ renderedWidth = width;
3219
+ const narrow = width < 80;
2840
3220
  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;
3221
+ const agent = selectedAgentRecord();
3222
+ const actions = actionMode ? { title: agent ? "Agent actions" : "Run actions", options: actionOptions(), index: actionIndex } : undefined;
3223
+ const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { nodeId: selectedNodeId, expandedNodeIds: [...expandedNodeIds], ...(narrow && !detailsMode ? { treeOnly: true } : {}), ...(narrow && detailsMode ? { detailsOnly: true } : {}), ...(actions ? { actions } : {}) }, styles);
3224
+ const statusLines = stopStatus ? truncateToVisualLines(styles.error(stopStatus), Number.MAX_SAFE_INTEGER, width, 0).visualLines.map((line) => line.trimEnd()) : [];
3225
+ const content = [...statusLines, ...phaseLines];
3226
+ const rows = terminalRows();
3227
+ const hintRows = rows >= 3 ? 1 : 0;
3228
+ const viewport = Math.max(1, rows - hintRows);
3229
+ const maxOffset = Math.max(0, content.length - viewport);
3230
+ dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
3231
+ if (actionMode) {
3232
+ const label = actions?.options[actionIndex];
3233
+ const actionRow = label ? content.findIndex((line) => line.includes(label)) : -1;
3234
+ if (actionRow >= 0) {
3235
+ if (actionRow < dashboardOffset)
3236
+ dashboardOffset = actionRow;
3237
+ else if (actionRow >= dashboardOffset + viewport)
3238
+ dashboardOffset = actionRow - viewport + 1;
3239
+ }
3240
+ }
3241
+ else if (!detailsMode && selectionNeedsScroll) {
3242
+ const selectedRow = content.findIndex((line) => line.startsWith("→"));
3243
+ if (selectedRow >= 0) {
3244
+ if (selectedRow < dashboardOffset)
3245
+ dashboardOffset = selectedRow;
3246
+ else if (selectedRow >= dashboardOffset + viewport)
3247
+ dashboardOffset = selectedRow - viewport + 1;
3248
+ }
3249
+ selectionNeedsScroll = false;
2850
3250
  }
2851
- const maxOffset = Math.max(0, dashboardLines.length - layout.dashboardViewport);
2852
3251
  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
- ];
3252
+ 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" : "close"}${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] ?? "";
3253
+ return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
2860
3254
  },
2861
3255
  invalidate() { },
2862
3256
  handleInput(data) {
2863
- if (stopRequested)
3257
+ if (stopRequested || editorRunning)
3258
+ return;
3259
+ const narrow = renderedWidth < 80;
3260
+ if (!actionMode && (data === "a" || data === "A")) {
3261
+ actionMode = true;
3262
+ actionIndex = 0;
3263
+ dashboardOffset = 0;
3264
+ tui.requestRender();
2864
3265
  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;
3266
+ }
3267
+ if (actionMode) {
3268
+ const options = actionOptions();
3269
+ if (keybindings.matches(data, "tui.select.cancel")) {
3270
+ actionMode = false;
3271
+ dashboardOffset = 0;
3272
+ tui.requestRender();
3273
+ return;
3274
+ }
3275
+ if (keybindings.matches(data, "tui.select.up"))
3276
+ actionIndex = (actionIndex + options.length - 1) % options.length;
3277
+ else if (keybindings.matches(data, "tui.select.down"))
3278
+ actionIndex = (actionIndex + 1) % options.length;
3279
+ else if (keybindings.matches(data, "tui.select.pageUp"))
3280
+ dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3281
+ else if (keybindings.matches(data, "tui.select.pageDown"))
3282
+ dashboardOffset += Math.max(1, terminalRows() - 1);
3283
+ else if (keybindings.matches(data, "tui.select.confirm")) {
3284
+ const action = options[actionIndex];
3285
+ const agent = selectedAgentRecord();
3286
+ if (!action || action === "Back") {
3287
+ actionMode = false;
3288
+ dashboardOffset = 0;
3289
+ }
3290
+ else if (agent) {
3291
+ const worktree = agentWorktreeFor(view, agent);
3292
+ if (action === "Open result in editor") {
3293
+ const result = view.agentResults.get(agent.id);
3294
+ if (result !== undefined)
3295
+ void openArtifact(Promise.resolve(workflowResultArtifact(result)), "agent result");
3296
+ }
3297
+ else if (action === "Copy agent ID")
3298
+ void copyArtifact(agent.id, "agent ID");
3299
+ else if (action === "Copy branch" && worktree)
3300
+ void copyArtifact(worktree.branch, "branch");
3301
+ else if (action === "Copy worktree path" && worktree)
3302
+ void copyArtifact(worktree.path, "worktree path");
3303
+ else
3304
+ done(`__workflow_fork__:${agent.id}`);
3305
+ }
3306
+ else if (action === "Open script in editor")
3307
+ void openArtifact(readFile(join(store.directory, "workflow.js"), "utf8").then(workflowScriptArtifact), "workflow script");
3308
+ else if (action === "Stop")
3309
+ requestStop();
3310
+ else
3311
+ done(action);
2876
3312
  }
3313
+ tui.requestRender();
3314
+ return;
2877
3315
  }
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;
3316
+ const current = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
3317
+ if (keybindings.matches(data, "tui.select.cancel")) {
3318
+ if (narrow && detailsMode) {
3319
+ detailsMode = false;
3320
+ selectionNeedsScroll = true;
2883
3321
  }
3322
+ else
3323
+ done(undefined);
2884
3324
  }
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));
3325
+ else if (narrow && detailsMode) {
3326
+ if (keybindings.matches(data, "tui.select.pageUp"))
3327
+ dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3328
+ else if (keybindings.matches(data, "tui.select.pageDown"))
3329
+ dashboardOffset += Math.max(1, terminalRows() - 1);
3330
+ else if (keybindings.matches(data, "tui.select.confirm")) {
3331
+ if (current?.kind === "agent" && current.agentId) {
3332
+ actionMode = true;
3333
+ actionIndex = 0;
3334
+ }
3335
+ else if (current?.children.length) {
3336
+ if (expandedNodeIds.has(current.id))
3337
+ expandedNodeIds.delete(current.id);
3338
+ else
3339
+ expandedNodeIds.add(current.id);
3340
+ }
3341
+ }
3342
+ }
3343
+ else if (keybindings.matches(data, "tui.editor.cursorLeft")) {
3344
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "left");
3345
+ selectedNodeId = next.nodeId;
3346
+ expandedNodeIds = new Set(next.expandedNodeIds);
3347
+ selectionNeedsScroll = true;
3348
+ }
3349
+ else if (keybindings.matches(data, "tui.editor.cursorRight")) {
3350
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "right");
3351
+ selectedNodeId = next.nodeId;
3352
+ expandedNodeIds = new Set(next.expandedNodeIds);
3353
+ selectionNeedsScroll = true;
2891
3354
  }
2892
- else if (keybindings.matches(data, "tui.select.pageDown")) {
2893
- dashboardOffset += Math.max(1, dashboardLayout().dashboardViewport);
3355
+ else if (keybindings.matches(data, "tui.select.up")) {
3356
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "up");
3357
+ selectedNodeId = next.nodeId;
3358
+ selectionNeedsScroll = true;
2894
3359
  }
3360
+ else if (keybindings.matches(data, "tui.select.down")) {
3361
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "down");
3362
+ selectedNodeId = next.nodeId;
3363
+ selectionNeedsScroll = true;
3364
+ }
3365
+ else if (keybindings.matches(data, "tui.select.pageUp"))
3366
+ dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3367
+ else if (keybindings.matches(data, "tui.select.pageDown"))
3368
+ dashboardOffset += Math.max(1, terminalRows() - 1);
2895
3369
  else if (keybindings.matches(data, "tui.select.confirm")) {
2896
- if (options[selectedIndex] === "Stop")
2897
- requestStop();
2898
- else
2899
- done(options[selectedIndex]);
3370
+ if (narrow)
3371
+ detailsMode = true;
3372
+ else if (current?.kind === "agent" && current.agentId) {
3373
+ actionMode = true;
3374
+ actionIndex = 0;
3375
+ }
3376
+ else if (current?.children.length) {
3377
+ if (expandedNodeIds.has(current.id))
3378
+ expandedNodeIds.delete(current.id);
3379
+ else
3380
+ expandedNodeIds.add(current.id);
3381
+ }
2900
3382
  }
2901
- else if (keybindings.matches(data, "tui.select.cancel"))
2902
- done(undefined);
2903
3383
  tui.requestRender();
2904
3384
  },
2905
3385
  dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
2906
3386
  }, theme);
2907
- }, { overlay: true })
3387
+ }, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS })
2908
3388
  : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
2909
3389
  if (!actionChoice || actionChoice === "Close")
2910
3390
  return;
@@ -2912,48 +3392,19 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2912
3392
  await selectAgent(view);
2913
3393
  continue;
2914
3394
  }
2915
- if (actionChoice === "Refresh")
3395
+ if (actionChoice.startsWith("__workflow_agent__:")) {
3396
+ await selectAgent(view, actionChoice.slice("__workflow_agent__:".length));
2916
3397
  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%" } });
3398
+ }
3399
+ if (actionChoice.startsWith("__workflow_fork__:")) {
3400
+ const agentId = actionChoice.slice("__workflow_fork__:".length);
3401
+ const agent = view.agents.find((candidate) => candidate.id === agentId);
3402
+ if (agent)
3403
+ await forkAgentSession(view, agent);
2955
3404
  continue;
2956
3405
  }
3406
+ if (actionChoice === "Refresh")
3407
+ continue;
2957
3408
  const copy = view.copies.get(actionChoice);
2958
3409
  if (copy) {
2959
3410
  await copyArtifact(copy.value, copy.artifact);
@@ -3017,7 +3468,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3017
3468
  tui.requestRender();
3018
3469
  },
3019
3470
  }, theme);
3020
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3471
+ }, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS });
3021
3472
  if (decision) {
3022
3473
  const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
3023
3474
  if (!accepted)
@@ -3044,16 +3495,13 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3044
3495
  pi.on("session_shutdown", async () => {
3045
3496
  try {
3046
3497
  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)) {
3498
+ const isTerminal = SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state);
3499
+ if (!isTerminal) {
3052
3500
  try {
3053
3501
  await run.lifecycle.terminal("interrupted");
3054
3502
  }
3055
3503
  catch (error) {
3056
- if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state))
3504
+ if (!SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state))
3057
3505
  throw error;
3058
3506
  }
3059
3507
  run.abortController.abort();