@quintinshaw/pi-dynamic-workflows 1.9.1 → 1.9.2

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.
@@ -2,7 +2,8 @@
2
2
  * Workflow manager for background execution, pause/resume, and run management.
3
3
  */
4
4
  import { EventEmitter } from "node:events";
5
- import type { WorkflowSnapshot } from "./display.js";
5
+ import type { WorkflowAgent } from "./agent.js";
6
+ import { type WorkflowSnapshot } from "./display.js";
6
7
  import { WorkflowError } from "./errors.js";
7
8
  import { type PersistedRunState, type RunPersistence, type RunStatus } from "./run-persistence.js";
8
9
  import { type JournalEntry, type WorkflowRunResult } from "./workflow.js";
@@ -19,12 +20,36 @@ export interface ManagedRun {
19
20
  args?: unknown;
20
21
  /** Accumulated agent results for resume (deterministic call index -> result). */
21
22
  journal: JournalEntry[];
23
+ /**
24
+ * True when the run was started in the background (or resumed) and the caller is
25
+ * not awaiting its result inline. Only background runs deliver their result back
26
+ * into the conversation; a foreground sync run already returns it as the tool
27
+ * result, so re-delivering would duplicate it.
28
+ */
29
+ background: boolean;
30
+ }
31
+ /** Per-execution options shared by sync, background, and resume runs. */
32
+ export interface ExecOptions {
33
+ /** Replay these journaled agent results for the unchanged prefix (resume). */
34
+ resumeJournal?: Map<number, JournalEntry>;
35
+ /** Cap on total agents for this run. */
36
+ maxAgents?: number;
37
+ /** Per-agent timeout in milliseconds. */
38
+ agentTimeoutMs?: number;
39
+ /** Host signal (e.g. tool/Esc) that should abort this run when fired. */
40
+ externalSignal?: AbortSignal;
41
+ /** Called with the live snapshot on every progress event. */
42
+ onProgress?: (snapshot: WorkflowSnapshot) => void;
22
43
  }
23
44
  export interface WorkflowManagerOptions {
24
45
  cwd?: string;
25
46
  concurrency?: number;
26
47
  /** Resolve a saved-workflow name to its script, enabling nested `workflow('name')`. */
27
48
  loadSavedWorkflow?: (name: string) => string | undefined;
49
+ /** Inject a custom agent runner (tests); defaults to a real subagent session. */
50
+ agent?: Pick<WorkflowAgent, "run">;
51
+ /** The session's main model (provider/id), for auto-tiering explore agents. */
52
+ mainModel?: string;
28
53
  }
29
54
  export declare class WorkflowManager extends EventEmitter {
30
55
  private runs;
@@ -32,7 +57,12 @@ export declare class WorkflowManager extends EventEmitter {
32
57
  private cwd;
33
58
  private concurrency;
34
59
  private loadSavedWorkflow?;
60
+ private agent?;
61
+ /** The session's main model (provider/id), for auto-tiering explore agents. */
62
+ private mainModel?;
35
63
  constructor(options?: WorkflowManagerOptions);
64
+ /** Set the session's main model (provider/id). Used to auto-tier explore agents. */
65
+ setMainModel(spec: string | undefined): void;
36
66
  /**
37
67
  * Start a workflow in the background.
38
68
  * Returns immediately with a run ID; the workflow executes asynchronously.
@@ -42,9 +72,14 @@ export declare class WorkflowManager extends EventEmitter {
42
72
  promise: Promise<WorkflowRunResult>;
43
73
  };
44
74
  /**
45
- * Execute a workflow synchronously (blocking).
75
+ * Execute a workflow synchronously (blocking) while still tracking it like a
76
+ * background run, so the `/workflows` navigator and the live task panel see it.
77
+ * `onProgress` fires on every progress event with the current snapshot, letting
78
+ * a caller (e.g. the workflow tool) drive its own inline display.
46
79
  */
47
- runSync(script: string, args?: unknown): Promise<WorkflowRunResult>;
80
+ runSync(script: string, args?: unknown, exec?: ExecOptions): Promise<WorkflowRunResult>;
81
+ /** Build a fresh managed run with an empty snapshot. */
82
+ private createManaged;
48
83
  private executeRun;
49
84
  private persistRun;
50
85
  /**
@@ -2,6 +2,7 @@
2
2
  * Workflow manager for background execution, pause/resume, and run management.
3
3
  */
4
4
  import { EventEmitter } from "node:events";
5
+ import { preview } from "./display.js";
5
6
  import { WorkflowError, WorkflowErrorCode } from "./errors.js";
6
7
  import { createRunPersistence, generateRunId, } from "./run-persistence.js";
7
8
  import { parseWorkflowScript, runWorkflow } from "./workflow.js";
@@ -11,13 +12,22 @@ export class WorkflowManager extends EventEmitter {
11
12
  cwd;
12
13
  concurrency;
13
14
  loadSavedWorkflow;
15
+ agent;
16
+ /** The session's main model (provider/id), for auto-tiering explore agents. */
17
+ mainModel;
14
18
  constructor(options = {}) {
15
19
  super();
16
20
  this.cwd = options.cwd ?? process.cwd();
17
21
  this.concurrency = options.concurrency ?? 8;
18
22
  this.loadSavedWorkflow = options.loadSavedWorkflow;
23
+ this.agent = options.agent;
24
+ this.mainModel = options.mainModel;
19
25
  this.persistence = createRunPersistence(this.cwd);
20
26
  }
27
+ /** Set the session's main model (provider/id). Used to auto-tier explore agents. */
28
+ setMainModel(spec) {
29
+ this.mainModel = spec;
30
+ }
21
31
  /**
22
32
  * Start a workflow in the background.
23
33
  * Returns immediately with a run ID; the workflow executes asynchronously.
@@ -45,6 +55,7 @@ export class WorkflowManager extends EventEmitter {
45
55
  script,
46
56
  args,
47
57
  journal: [],
58
+ background: true,
48
59
  };
49
60
  this.runs.set(runId, managed);
50
61
  // Persist initial state
@@ -65,14 +76,24 @@ export class WorkflowManager extends EventEmitter {
65
76
  return { runId, promise };
66
77
  }
67
78
  /**
68
- * Execute a workflow synchronously (blocking).
79
+ * Execute a workflow synchronously (blocking) while still tracking it like a
80
+ * background run, so the `/workflows` navigator and the live task panel see it.
81
+ * `onProgress` fires on every progress event with the current snapshot, letting
82
+ * a caller (e.g. the workflow tool) drive its own inline display.
69
83
  */
70
- async runSync(script, args) {
71
- const runId = generateRunId();
72
- const controller = new AbortController();
84
+ async runSync(script, args, exec = {}) {
85
+ const managed = this.createManaged(script, args);
86
+ this.runs.set(managed.runId, managed);
87
+ // Persist the initial state immediately so listRuns()/the task panel can see
88
+ // the run the moment it starts, not only after the first agent journals.
89
+ this.persistRun(managed);
90
+ return this.executeRun(managed, script, args, exec);
91
+ }
92
+ /** Build a fresh managed run with an empty snapshot. */
93
+ createManaged(script, args) {
73
94
  const parsed = parseWorkflowScript(script);
74
- const managed = {
75
- runId,
95
+ return {
96
+ runId: generateRunId(),
76
97
  status: "running",
77
98
  snapshot: {
78
99
  name: parsed.meta.name,
@@ -85,22 +106,34 @@ export class WorkflowManager extends EventEmitter {
85
106
  doneCount: 0,
86
107
  errorCount: 0,
87
108
  },
88
- controller,
109
+ controller: new AbortController(),
89
110
  startedAt: new Date(),
90
111
  script,
91
112
  args,
92
113
  journal: [],
114
+ background: false,
93
115
  };
94
- this.runs.set(runId, managed);
95
- return this.executeRun(managed, script, args);
96
116
  }
97
- async executeRun(managed, script, args, resumeJournal) {
117
+ async executeRun(managed, script, args, exec = {}) {
118
+ const { resumeJournal, maxAgents, agentTimeoutMs, externalSignal, onProgress } = exec;
119
+ const progress = () => onProgress?.(managed.snapshot);
120
+ // Let a host abort (e.g. Esc during a blocking tool call) cancel this run.
121
+ if (externalSignal) {
122
+ if (externalSignal.aborted)
123
+ managed.controller.abort();
124
+ else
125
+ externalSignal.addEventListener("abort", () => managed.controller.abort(), { once: true });
126
+ }
98
127
  try {
99
128
  const result = await runWorkflow(script, {
100
129
  cwd: this.cwd,
101
130
  args,
131
+ agent: this.agent,
132
+ mainModel: this.mainModel,
102
133
  signal: managed.controller.signal,
103
134
  concurrency: this.concurrency,
135
+ maxAgents,
136
+ agentTimeoutMs,
104
137
  loadSavedWorkflow: this.loadSavedWorkflow,
105
138
  resumeJournal,
106
139
  resumeFromRunId: resumeJournal ? managed.runId : undefined,
@@ -113,6 +146,7 @@ export class WorkflowManager extends EventEmitter {
113
146
  onLog: (message) => {
114
147
  managed.snapshot.logs.push(message);
115
148
  this.emit("log", { runId: managed.runId, message });
149
+ progress();
116
150
  },
117
151
  onPhase: (title) => {
118
152
  managed.snapshot.currentPhase = title;
@@ -120,6 +154,7 @@ export class WorkflowManager extends EventEmitter {
120
154
  managed.snapshot.phases.push(title);
121
155
  }
122
156
  this.emit("phase", { runId: managed.runId, title });
157
+ progress();
123
158
  },
124
159
  onAgentStart: (event) => {
125
160
  managed.snapshot.agents.push({
@@ -128,8 +163,10 @@ export class WorkflowManager extends EventEmitter {
128
163
  phase: event.phase,
129
164
  prompt: event.prompt,
130
165
  status: "running",
166
+ model: event.model,
131
167
  });
132
168
  this.emit("agentStart", { runId: managed.runId, ...event });
169
+ progress();
133
170
  },
134
171
  onAgentEnd: (event) => {
135
172
  const agent = [...managed.snapshot.agents]
@@ -137,8 +174,18 @@ export class WorkflowManager extends EventEmitter {
137
174
  .find((a) => a.label === event.label && a.status === "running");
138
175
  if (agent) {
139
176
  agent.status = event.result === null ? "error" : "done";
177
+ agent.resultPreview = preview(event.result);
178
+ agent.tokens = event.tokens;
179
+ if (event.model)
180
+ agent.model = event.model;
140
181
  }
141
182
  this.emit("agentEnd", { runId: managed.runId, ...event });
183
+ progress();
184
+ },
185
+ onTokenUsage: (usage) => {
186
+ managed.snapshot.tokenUsage = usage;
187
+ this.emit("tokenUsage", { runId: managed.runId, usage });
188
+ progress();
142
189
  },
143
190
  });
144
191
  managed.status = "completed";
@@ -184,6 +231,13 @@ export class WorkflowManager extends EventEmitter {
184
231
  })),
185
232
  logs: managed.snapshot.logs,
186
233
  result: managed.result?.result,
234
+ tokenUsage: managed.snapshot.tokenUsage
235
+ ? {
236
+ input: managed.snapshot.tokenUsage.input,
237
+ output: managed.snapshot.tokenUsage.output,
238
+ total: managed.snapshot.tokenUsage.total,
239
+ }
240
+ : undefined,
187
241
  startedAt: managed.startedAt.toISOString(),
188
242
  updatedAt: new Date().toISOString(),
189
243
  completedAt: managed.status === "completed" ? new Date().toISOString() : undefined,
@@ -233,12 +287,13 @@ export class WorkflowManager extends EventEmitter {
233
287
  script: persisted.script,
234
288
  args: persisted.args,
235
289
  journal: persisted.journal ?? [],
290
+ background: true,
236
291
  };
237
292
  this.runs.set(runId, managed);
238
293
  const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e]));
239
294
  this.emit("resumed", { runId });
240
295
  // Run in the background; executeRun records status/errors on the managed run.
241
- void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => { });
296
+ void this.executeRun(managed, persisted.script, persisted.args, { resumeJournal }).catch(() => { });
242
297
  return true;
243
298
  }
244
299
  /**
@@ -25,4 +25,11 @@ export interface WorkflowToolOptions {
25
25
  storage?: WorkflowStorage;
26
26
  }
27
27
  export declare function createWorkflowTool(options?: WorkflowToolOptions): ToolDefinition<typeof workflowToolSchema, any>;
28
+ /**
29
+ * The tool result returned when a workflow starts in the background. It both
30
+ * informs the model and tells it to reassure the user: the run continues on its
31
+ * own and the conversation will resume automatically when it finishes, so the
32
+ * user can just wait here (or go do something else).
33
+ */
34
+ export declare function backgroundStartedText(name: string, runId: string): string;
28
35
  export {};
@@ -1,11 +1,31 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
- import { createToolUpdateWorkflowDisplay, createWorkflowSnapshot, preview, recomputeWorkflowSnapshot, renderWorkflowText, } from "./display.js";
4
+ import { listAvailableModelSpecs } from "./agent.js";
5
+ import { createToolUpdateWorkflowDisplay, createWorkflowSnapshot, recomputeWorkflowSnapshot, renderWorkflowText, } from "./display.js";
5
6
  import { WorkflowError, WorkflowErrorCode } from "./errors.js";
6
- import { parseWorkflowScript, runWorkflow } from "./workflow.js";
7
+ import { parseWorkflowScript } from "./workflow.js";
7
8
  import { WorkflowManager } from "./workflow-manager.js";
8
9
  import { createWorkflowStorage } from "./workflow-saved.js";
10
+ /**
11
+ * Per-agent model-routing policy handed to the workflow author (the model). It
12
+ * states the rule and lists the user's currently available models, then lets the
13
+ * author choose each agent's model via opts.model — no hardcoded family mapping.
14
+ */
15
+ function modelRoutingGuideline() {
16
+ const available = listAvailableModelSpecs();
17
+ const list = available.length
18
+ ? `The user's currently available models (route only to these) are: ${available.join(", ")}.`
19
+ : "Use models the user has configured.";
20
+ return [
21
+ "For workflow, decide each agent's model yourself via opts.model, following this policy:",
22
+ "If the user named a specific model, use exactly that.",
23
+ "Otherwise, for exploration/search/inventory/gathering agents, pick a model one tier BELOW the main model in the SAME family (e.g. Claude→Haiku, ChatGPT/GPT→a mini, DeepSeek→a lighter/flash variant), choosing the closest match from the available list.",
24
+ "For analysis/synthesis/judgment/decision/verification agents, omit opts.model so the agent runs on the main model.",
25
+ "Never route to a model that is not in the available list; if no suitable lighter sibling exists, omit opts.model (use the main model).",
26
+ list,
27
+ ].join(" ");
28
+ }
9
29
  const workflowToolSchema = Type.Object({
10
30
  script: Type.String({
11
31
  description: [
@@ -17,7 +37,7 @@ const workflowToolSchema = Type.Object({
17
37
  }),
18
38
  args: Type.Optional(Type.Any({ description: "Optional JSON value exposed to the workflow script as global `args`." })),
19
39
  background: Type.Optional(Type.Boolean({
20
- description: "Run the workflow in the background. Default: false. When true, returns immediately with a run ID.",
40
+ description: "Run the workflow in the background. Default: true the tool returns immediately with a run ID, the turn ends so the user isn't blocked, and the result is delivered back into the conversation when it finishes. Set to false only when you need the result inline in this same turn (the call will block until the workflow completes).",
21
41
  })),
22
42
  maxAgents: Type.Optional(Type.Number({
23
43
  description: "Maximum number of agents allowed in this run. Default: 1000.",
@@ -28,8 +48,12 @@ const workflowToolSchema = Type.Object({
28
48
  });
29
49
  export function createWorkflowTool(options = {}) {
30
50
  const storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
31
- const manager = options.manager ?? new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
32
- const loadSavedWorkflow = (name) => storage.load(name)?.script;
51
+ const manager = options.manager ??
52
+ new WorkflowManager({
53
+ cwd: options.cwd,
54
+ concurrency: options.concurrency,
55
+ loadSavedWorkflow: (name) => storage.load(name)?.script,
56
+ });
33
57
  return defineTool({
34
58
  name: "workflow",
35
59
  label: "Workflow",
@@ -51,36 +75,33 @@ export function createWorkflowTool(options = {}) {
51
75
  "For workflow, failed agent(), parallel(), or pipeline() branches return null and log the failure unless the workflow is aborted. Check for nulls before synthesizing conclusions.",
52
76
  "For workflow, include a final synthesis/assertion agent when combining multiple subagent results; return a compact JSON-serializable value with ok/verdict plus the important outputs.",
53
77
  "For workflow, if agent() needs machine-readable output, pass a plain JSON Schema via opts.schema; agent() will return the validated object. Use JSON Schema syntax, not TypeScript or TypeBox constructors.",
78
+ modelRoutingGuideline(),
54
79
  "For workflow, do not assume the parent assistant has repository code context inside subagents; include enough task context and relevant paths in each agent prompt.",
55
- "For workflow, set background: true to run asynchronously. The workflow will return immediately with a run ID that can be used to check status later.",
80
+ "For workflow, runs are background by default: the tool returns immediately with a run ID, the turn ends so the user isn't blocked, and the result is delivered back into the conversation when the run finishes. Pass background: false only when you must use the result inline in this same turn (it will block).",
56
81
  "For workflow, you may call `await workflow('saved-name', argsObject)` to run a saved workflow inline and use its result; nesting is one level deep only, and the global 16-concurrent / 1000-total caps hold across the nesting.",
57
82
  ],
58
83
  parameters: workflowToolSchema,
59
84
  prepareArguments(args) {
60
85
  return normalizeWorkflowToolArgs(args);
61
86
  },
62
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
87
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
63
88
  const script = normalizeWorkflowScript(params.script);
64
89
  const parsed = parseWorkflowScript(script);
65
- // Background execution
66
- if (params.background) {
90
+ // Background execution is the default: return immediately so the turn ends
91
+ // and the user isn't blocked. The result is delivered back into the
92
+ // conversation when the run finishes (see installResultDelivery). Only an
93
+ // explicit `background: false` blocks for the result inline.
94
+ if (params.background ?? true) {
67
95
  const { runId } = manager.startInBackground(script, params.args);
68
96
  return {
69
- content: [
70
- {
71
- type: "text",
72
- text: [
73
- `Workflow "${parsed.meta.name}" started in background.`,
74
- `Run ID: ${runId}`,
75
- `Use /workflows status ${runId} to check progress.`,
76
- `Use /workflows stop ${runId} to cancel.`,
77
- ].join("\n"),
78
- },
79
- ],
97
+ content: [{ type: "text", text: backgroundStartedText(parsed.meta.name, runId) }],
80
98
  details: { runId, background: true },
81
99
  };
82
100
  }
83
- // Synchronous execution (blocking)
101
+ // Synchronous execution (blocking) — but routed through the manager so the
102
+ // run shows up live in the /workflows navigator and the task panel while it
103
+ // runs, then stays in history afterwards. We still block on the result and
104
+ // return it inline, so the model gets the full output in the same turn.
84
105
  let snapshot = createWorkflowSnapshot(parsed.meta);
85
106
  const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, {
86
107
  key: "workflow",
@@ -89,56 +110,15 @@ export function createWorkflowTool(options = {}) {
89
110
  maxLogs: 1,
90
111
  showResultPreviews: false,
91
112
  });
92
- const update = () => {
93
- snapshot = recomputeWorkflowSnapshot(snapshot);
94
- display.update(snapshot);
95
- };
96
113
  let result;
97
114
  try {
98
- result = await runWorkflow(script, {
99
- cwd: options.cwd ?? ctx.cwd,
100
- args: params.args,
101
- signal,
102
- concurrency: options.concurrency,
115
+ result = await manager.runSync(script, params.args, {
103
116
  maxAgents: params.maxAgents,
104
117
  agentTimeoutMs: params.agentTimeoutMs,
105
- loadSavedWorkflow,
106
- onLog(message) {
107
- snapshot.logs.push(message);
108
- update();
109
- },
110
- onPhase(title) {
111
- snapshot.currentPhase = title;
112
- if (!snapshot.phases.includes(title))
113
- snapshot.phases.push(title);
114
- update();
115
- },
116
- onAgentStart(event) {
117
- if (signal?.aborted)
118
- throw new Error("Workflow was aborted");
119
- snapshot.agents.push({
120
- id: snapshot.agents.length + 1,
121
- label: event.label,
122
- phase: event.phase,
123
- prompt: event.prompt,
124
- status: "running",
125
- });
126
- update();
127
- },
128
- onAgentEnd(event) {
129
- const agent = [...snapshot.agents]
130
- .reverse()
131
- .find((item) => item.label === event.label && item.status === "running");
132
- if (agent) {
133
- agent.status = event.result === null ? "error" : "done";
134
- agent.resultPreview = preview(event.result);
135
- agent.tokens = event.tokens;
136
- }
137
- update();
138
- },
139
- onTokenUsage(usage) {
140
- snapshot.tokenUsage = usage;
141
- update();
118
+ externalSignal: signal,
119
+ onProgress(live) {
120
+ snapshot = recomputeWorkflowSnapshot(live);
121
+ display.update(snapshot);
142
122
  },
143
123
  });
144
124
  }
@@ -199,6 +179,24 @@ export function createWorkflowTool(options = {}) {
199
179
  },
200
180
  });
201
181
  }
182
+ /**
183
+ * The tool result returned when a workflow starts in the background. It both
184
+ * informs the model and tells it to reassure the user: the run continues on its
185
+ * own and the conversation will resume automatically when it finishes, so the
186
+ * user can just wait here (or go do something else).
187
+ */
188
+ export function backgroundStartedText(name, runId) {
189
+ return [
190
+ `Workflow "${name}" started in the background.`,
191
+ `Run ID: ${runId}`,
192
+ "It keeps running on its own. When it finishes, the result is delivered back",
193
+ "here and the conversation continues automatically — the user does not need to",
194
+ "do anything. Tell the user they can simply wait here for it to finish (it will",
195
+ "resume the conversation by itself), or keep chatting / working on other things",
196
+ "in the meantime; either way the result will come back to this conversation.",
197
+ `They can also track or cancel it with /workflows status ${runId} or /workflows stop ${runId}.`,
198
+ ].join("\n");
199
+ }
202
200
  function normalizeWorkflowToolArgs(args) {
203
201
  if (!args || typeof args !== "object")
204
202
  throw new Error("workflow requires an object argument with a script string");
@@ -40,6 +40,7 @@ interface AgentRow {
40
40
  status: string;
41
41
  phase?: string;
42
42
  tokens?: number;
43
+ model?: string;
43
44
  }
44
45
  /** Reads run/phase/agent data from the manager, preferring live snapshots. */
45
46
  export declare class NavigatorModel {
@@ -25,6 +25,13 @@ const STATUS_ICON = {
25
25
  skipped: "⊘",
26
26
  };
27
27
  const PLAIN = { fg: (_c, t) => t, bold: (t) => t };
28
+ /** Short, human-friendly model label: drop the provider prefix for display. */
29
+ function shortModel(model) {
30
+ if (!model)
31
+ return undefined;
32
+ const slash = model.indexOf("/");
33
+ return slash > 0 ? model.slice(slash + 1) : model;
34
+ }
28
35
  /** Reads run/phase/agent data from the manager, preferring live snapshots. */
29
36
  export class NavigatorModel {
30
37
  manager;
@@ -90,7 +97,7 @@ export class NavigatorModel {
90
97
  return [];
91
98
  return snap.agents
92
99
  .filter((a) => (a.phase ?? "(no phase)") === phase)
93
- .map((a) => ({ id: a.id, label: a.label, status: a.status, phase: a.phase, tokens: a.tokens }));
100
+ .map((a) => ({ id: a.id, label: a.label, status: a.status, phase: a.phase, tokens: a.tokens, model: a.model }));
94
101
  }
95
102
  agentDetail(runId, agentId) {
96
103
  return this.snapshot(runId)?.snapshot.agents.find((a) => a.id === agentId);
@@ -110,6 +117,7 @@ function persistedToSnapshot(p) {
110
117
  status: a.status,
111
118
  resultPreview: a.result == null ? undefined : String(typeof a.result === "string" ? a.result : JSON.stringify(a.result)),
112
119
  error: a.error,
120
+ model: a.model,
113
121
  })),
114
122
  agentCount: p.agents.length,
115
123
  runningCount: p.agents.filter((a) => a.status === "running").length,
@@ -246,8 +254,9 @@ export function renderNavigator(state, model, width, theme = PLAIN) {
246
254
  lines.push(theme.bold(`${model.runName(state.runId)} › ${state.phase}`));
247
255
  agents.forEach((a, i) => {
248
256
  const icon = STATUS_ICON[a.status] ?? "?";
249
- const tok = a.tokens ? dim(` ${fmtTokens(a.tokens)}`) : "";
250
- lines.push(sel(i, `${icon} ${a.label}${tok}`));
257
+ const mdl = shortModel(a.model);
258
+ const meta = [mdl, a.tokens ? fmtTokens(a.tokens) : undefined].filter(Boolean).join(" · ");
259
+ lines.push(sel(i, `${icon} ${a.label}${meta ? dim(` ${meta}`) : ""}`));
251
260
  });
252
261
  }
253
262
  else if (state.kind === "detail" && state.runId && state.agentId != null) {
@@ -256,6 +265,8 @@ export function renderNavigator(state, model, width, theme = PLAIN) {
256
265
  if (a) {
257
266
  const body = [];
258
267
  body.push(dim("Status: ") + (a.status ?? ""));
268
+ if (a.model)
269
+ body.push(dim("Model: ") + (shortModel(a.model) ?? ""));
259
270
  if (a.error)
260
271
  body.push(dim("Error: ") + a.error);
261
272
  body.push("", dim("Prompt:"));
@@ -385,9 +396,20 @@ export function openWorkflowNavigator(pi, manager, ui, opts = {}) {
385
396
  ui.notify(manager.stop(id) ? `Stopped ${id}` : `Cannot stop ${id}`, "info");
386
397
  break;
387
398
  }
388
- case "restart":
389
- ui.notify("Restarting a single agent isn't supported yet", "warning");
399
+ case "restart": {
400
+ // Restart re-runs the whole workflow from scratch as a fresh
401
+ // background run (per-agent restart isn't meaningful — agents are
402
+ // driven by the script). The new run auto-delivers when it finishes.
403
+ const id = state.activeRunId(model);
404
+ const run = id ? manager.listRuns().find((r) => r.runId === id) : undefined;
405
+ if (!run?.script) {
406
+ ui.notify(id ? `Cannot restart ${id} (no script saved)` : "No run selected to restart", "warning");
407
+ break;
408
+ }
409
+ const { runId: newId } = manager.startInBackground(run.script, run.args);
410
+ ui.notify(`Restarted ${run.workflowName || "workflow"} as ${newId}`, "info");
390
411
  break;
412
+ }
391
413
  case "save": {
392
414
  const id = state.activeRunId(model);
393
415
  const run = id ? manager.listRuns().find((r) => r.runId === id) : undefined;
@@ -38,6 +38,8 @@ export interface SharedRuntime {
38
38
  export interface WorkflowRunOptions extends WorkflowAgentOptions {
39
39
  args?: unknown;
40
40
  agent?: Pick<WorkflowAgent, "run">;
41
+ /** The session's main model (provider/id), shown in /workflows for default agents. */
42
+ mainModel?: string;
41
43
  concurrency?: number;
42
44
  tokenBudget?: number | null;
43
45
  signal?: AbortSignal;
@@ -73,6 +75,7 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
73
75
  result: unknown;
74
76
  tokens?: number;
75
77
  worktree?: string;
78
+ model?: string;
76
79
  }) => void;
77
80
  onTokenUsage?: (usage: {
78
81
  input: number;
@@ -100,6 +103,12 @@ export interface AgentOptions<TSchemaDef extends TSchema | undefined = TSchema |
100
103
  label?: string;
101
104
  phase?: string;
102
105
  schema?: TSchemaDef;
106
+ /**
107
+ * Run this agent on a specific model (`provider/modelId` or a bare `modelId`).
108
+ * The workflow author chooses per-agent models per the routing policy in the
109
+ * tool guidelines (e.g. a lighter model for exploration, the main model for
110
+ * analysis). When omitted, the session's main model is used.
111
+ */
103
112
  model?: string;
104
113
  isolation?: "worktree";
105
114
  agentType?: string;
package/dist/workflow.js CHANGED
@@ -76,6 +76,10 @@ export async function runWorkflow(script, options = {}) {
76
76
  const requestedLabel = agentOptions.label?.trim();
77
77
  // Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
78
78
  const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
79
+ // For display in /workflows: the model this agent runs on — its explicit/phase
80
+ // spec, else the session's main model. The real resolved id overrides this via
81
+ // onModelResolved once the subagent session is created.
82
+ let displayModel = modelSpec ?? options.mainModel;
79
83
  // Deterministic resume key: assigned at lexical call time, before the limiter,
80
84
  // so parallel()/pipeline() fan-out is reproducible for a fixed script.
81
85
  const callIndex = state.callSeq++;
@@ -86,15 +90,15 @@ export async function runWorkflow(script, options = {}) {
86
90
  if (cached && cached.hash === callHash) {
87
91
  shared.agentCount++;
88
92
  const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
89
- options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
90
- options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
93
+ options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: displayModel });
94
+ options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0, model: displayModel });
91
95
  return cached.result;
92
96
  }
93
97
  return limiter(async () => {
94
98
  shared.agentCount++;
95
99
  const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
96
100
  const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
97
- options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
101
+ options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: displayModel });
98
102
  // Optional per-agent worktree isolation (deterministic name -> stable resume keys).
99
103
  let worktree;
100
104
  if (agentOptions.isolation === "worktree") {
@@ -127,6 +131,9 @@ export async function runWorkflow(script, options = {}) {
127
131
  instructions: buildAgentInstructions(assignedPhase, agentOptions),
128
132
  model: modelSpec,
129
133
  cwd: runCwd,
134
+ onModelResolved: (id) => {
135
+ displayModel = id;
136
+ },
130
137
  onUsage: (u) => {
131
138
  usage = u;
132
139
  },
@@ -134,7 +141,7 @@ export async function runWorkflow(script, options = {}) {
134
141
  throwIfAborted();
135
142
  const tokens = recordTokens(result);
136
143
  options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
137
- options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
144
+ options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd, model: displayModel });
138
145
  return result;
139
146
  }
140
147
  catch (error) {
@@ -4,6 +4,7 @@ import {
4
4
  createWorkflowTool,
5
5
  installResultDelivery,
6
6
  installTaskPanel,
7
+ installWorkflowEditor,
7
8
  registerAllSavedWorkflows,
8
9
  registerBuiltinWorkflows,
9
10
  registerWorkflowCommands,
@@ -24,13 +25,24 @@ export default function extension(pi: ExtensionAPI) {
24
25
  registerAllSavedWorkflows(pi, cwd, storage);
25
26
  // Deliver a background run's result into the conversation when it finishes.
26
27
  installResultDelivery(pi, manager);
28
+ // "Workflows mode": type `workflow(s)` to arm a forced workflow (animated),
29
+ // Backspace right after the word disarms it. Registers the `input` hook now;
30
+ // the editor itself is installed once the UI is available (session_start).
31
+ let editorInstalled = false;
27
32
 
28
33
  pi.on("session_start", (_event: unknown, ctx: ExtensionContext) => {
29
34
  const active = pi.getActiveTools();
30
35
  if (!active.includes(workflowTool.name)) {
31
36
  pi.setActiveTools([...active, workflowTool.name]);
32
37
  }
38
+ // Tell the manager the session's main model so "explore" agents auto-tier
39
+ // down to a lighter same-family sibling (e.g. Claude → Haiku).
40
+ manager.setMainModel(ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
33
41
  // Live "workflows running" panel below the input (focus + enter to open).
34
42
  installTaskPanel(pi, manager, ctx.ui, { storage, cwd });
43
+ if (!editorInstalled) {
44
+ installWorkflowEditor(pi, ctx.ui);
45
+ editorInstalled = true;
46
+ }
35
47
  });
36
48
  }