@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.
@@ -3,7 +3,8 @@
3
3
  */
4
4
 
5
5
  import { EventEmitter } from "node:events";
6
- import type { WorkflowSnapshot } from "./display.js";
6
+ import type { WorkflowAgent } from "./agent.js";
7
+ import { preview, type WorkflowSnapshot } from "./display.js";
7
8
  import { WorkflowError, WorkflowErrorCode } from "./errors.js";
8
9
  import {
9
10
  createRunPersistence,
@@ -27,6 +28,27 @@ export interface ManagedRun {
27
28
  args?: unknown;
28
29
  /** Accumulated agent results for resume (deterministic call index -> result). */
29
30
  journal: JournalEntry[];
31
+ /**
32
+ * True when the run was started in the background (or resumed) and the caller is
33
+ * not awaiting its result inline. Only background runs deliver their result back
34
+ * into the conversation; a foreground sync run already returns it as the tool
35
+ * result, so re-delivering would duplicate it.
36
+ */
37
+ background: boolean;
38
+ }
39
+
40
+ /** Per-execution options shared by sync, background, and resume runs. */
41
+ export interface ExecOptions {
42
+ /** Replay these journaled agent results for the unchanged prefix (resume). */
43
+ resumeJournal?: Map<number, JournalEntry>;
44
+ /** Cap on total agents for this run. */
45
+ maxAgents?: number;
46
+ /** Per-agent timeout in milliseconds. */
47
+ agentTimeoutMs?: number;
48
+ /** Host signal (e.g. tool/Esc) that should abort this run when fired. */
49
+ externalSignal?: AbortSignal;
50
+ /** Called with the live snapshot on every progress event. */
51
+ onProgress?: (snapshot: WorkflowSnapshot) => void;
30
52
  }
31
53
 
32
54
  export interface WorkflowManagerOptions {
@@ -34,6 +56,10 @@ export interface WorkflowManagerOptions {
34
56
  concurrency?: number;
35
57
  /** Resolve a saved-workflow name to its script, enabling nested `workflow('name')`. */
36
58
  loadSavedWorkflow?: (name: string) => string | undefined;
59
+ /** Inject a custom agent runner (tests); defaults to a real subagent session. */
60
+ agent?: Pick<WorkflowAgent, "run">;
61
+ /** The session's main model (provider/id), for auto-tiering explore agents. */
62
+ mainModel?: string;
37
63
  }
38
64
 
39
65
  export class WorkflowManager extends EventEmitter {
@@ -42,15 +68,25 @@ export class WorkflowManager extends EventEmitter {
42
68
  private cwd: string;
43
69
  private concurrency: number;
44
70
  private loadSavedWorkflow?: (name: string) => string | undefined;
71
+ private agent?: Pick<WorkflowAgent, "run">;
72
+ /** The session's main model (provider/id), for auto-tiering explore agents. */
73
+ private mainModel?: string;
45
74
 
46
75
  constructor(options: WorkflowManagerOptions = {}) {
47
76
  super();
48
77
  this.cwd = options.cwd ?? process.cwd();
49
78
  this.concurrency = options.concurrency ?? 8;
50
79
  this.loadSavedWorkflow = options.loadSavedWorkflow;
80
+ this.agent = options.agent;
81
+ this.mainModel = options.mainModel;
51
82
  this.persistence = createRunPersistence(this.cwd);
52
83
  }
53
84
 
85
+ /** Set the session's main model (provider/id). Used to auto-tier explore agents. */
86
+ setMainModel(spec: string | undefined): void {
87
+ this.mainModel = spec;
88
+ }
89
+
54
90
  /**
55
91
  * Start a workflow in the background.
56
92
  * Returns immediately with a run ID; the workflow executes asynchronously.
@@ -79,6 +115,7 @@ export class WorkflowManager extends EventEmitter {
79
115
  script,
80
116
  args,
81
117
  journal: [],
118
+ background: true,
82
119
  };
83
120
 
84
121
  this.runs.set(runId, managed);
@@ -104,15 +141,25 @@ export class WorkflowManager extends EventEmitter {
104
141
  }
105
142
 
106
143
  /**
107
- * Execute a workflow synchronously (blocking).
144
+ * Execute a workflow synchronously (blocking) while still tracking it like a
145
+ * background run, so the `/workflows` navigator and the live task panel see it.
146
+ * `onProgress` fires on every progress event with the current snapshot, letting
147
+ * a caller (e.g. the workflow tool) drive its own inline display.
108
148
  */
109
- async runSync(script: string, args?: unknown): Promise<WorkflowRunResult> {
110
- const runId = generateRunId();
111
- const controller = new AbortController();
112
- const parsed = parseWorkflowScript(script);
149
+ async runSync(script: string, args?: unknown, exec: ExecOptions = {}): Promise<WorkflowRunResult> {
150
+ const managed = this.createManaged(script, args);
151
+ this.runs.set(managed.runId, managed);
152
+ // Persist the initial state immediately so listRuns()/the task panel can see
153
+ // the run the moment it starts, not only after the first agent journals.
154
+ this.persistRun(managed);
155
+ return this.executeRun(managed, script, args, exec);
156
+ }
113
157
 
114
- const managed: ManagedRun = {
115
- runId,
158
+ /** Build a fresh managed run with an empty snapshot. */
159
+ private createManaged(script: string, args?: unknown): ManagedRun {
160
+ const parsed = parseWorkflowScript(script);
161
+ return {
162
+ runId: generateRunId(),
116
163
  status: "running",
117
164
  snapshot: {
118
165
  name: parsed.meta.name,
@@ -125,29 +172,38 @@ export class WorkflowManager extends EventEmitter {
125
172
  doneCount: 0,
126
173
  errorCount: 0,
127
174
  },
128
- controller,
175
+ controller: new AbortController(),
129
176
  startedAt: new Date(),
130
177
  script,
131
178
  args,
132
179
  journal: [],
180
+ background: false,
133
181
  };
134
-
135
- this.runs.set(runId, managed);
136
- return this.executeRun(managed, script, args);
137
182
  }
138
183
 
139
184
  private async executeRun(
140
185
  managed: ManagedRun,
141
186
  script: string,
142
187
  args?: unknown,
143
- resumeJournal?: Map<number, JournalEntry>,
188
+ exec: ExecOptions = {},
144
189
  ): Promise<WorkflowRunResult> {
190
+ const { resumeJournal, maxAgents, agentTimeoutMs, externalSignal, onProgress } = exec;
191
+ const progress = () => onProgress?.(managed.snapshot);
192
+ // Let a host abort (e.g. Esc during a blocking tool call) cancel this run.
193
+ if (externalSignal) {
194
+ if (externalSignal.aborted) managed.controller.abort();
195
+ else externalSignal.addEventListener("abort", () => managed.controller.abort(), { once: true });
196
+ }
145
197
  try {
146
198
  const result = await runWorkflow(script, {
147
199
  cwd: this.cwd,
148
200
  args,
201
+ agent: this.agent,
202
+ mainModel: this.mainModel,
149
203
  signal: managed.controller.signal,
150
204
  concurrency: this.concurrency,
205
+ maxAgents,
206
+ agentTimeoutMs,
151
207
  loadSavedWorkflow: this.loadSavedWorkflow,
152
208
  resumeJournal,
153
209
  resumeFromRunId: resumeJournal ? managed.runId : undefined,
@@ -160,6 +216,7 @@ export class WorkflowManager extends EventEmitter {
160
216
  onLog: (message) => {
161
217
  managed.snapshot.logs.push(message);
162
218
  this.emit("log", { runId: managed.runId, message });
219
+ progress();
163
220
  },
164
221
  onPhase: (title) => {
165
222
  managed.snapshot.currentPhase = title;
@@ -167,6 +224,7 @@ export class WorkflowManager extends EventEmitter {
167
224
  managed.snapshot.phases.push(title);
168
225
  }
169
226
  this.emit("phase", { runId: managed.runId, title });
227
+ progress();
170
228
  },
171
229
  onAgentStart: (event) => {
172
230
  managed.snapshot.agents.push({
@@ -175,8 +233,10 @@ export class WorkflowManager extends EventEmitter {
175
233
  phase: event.phase,
176
234
  prompt: event.prompt,
177
235
  status: "running",
236
+ model: event.model,
178
237
  });
179
238
  this.emit("agentStart", { runId: managed.runId, ...event });
239
+ progress();
180
240
  },
181
241
  onAgentEnd: (event) => {
182
242
  const agent = [...managed.snapshot.agents]
@@ -184,8 +244,17 @@ export class WorkflowManager extends EventEmitter {
184
244
  .find((a) => a.label === event.label && a.status === "running");
185
245
  if (agent) {
186
246
  agent.status = event.result === null ? "error" : "done";
247
+ agent.resultPreview = preview(event.result);
248
+ agent.tokens = event.tokens;
249
+ if (event.model) agent.model = event.model;
187
250
  }
188
251
  this.emit("agentEnd", { runId: managed.runId, ...event });
252
+ progress();
253
+ },
254
+ onTokenUsage: (usage) => {
255
+ managed.snapshot.tokenUsage = usage;
256
+ this.emit("tokenUsage", { runId: managed.runId, usage });
257
+ progress();
189
258
  },
190
259
  });
191
260
 
@@ -241,6 +310,13 @@ export class WorkflowManager extends EventEmitter {
241
310
  })),
242
311
  logs: managed.snapshot.logs,
243
312
  result: managed.result?.result,
313
+ tokenUsage: managed.snapshot.tokenUsage
314
+ ? {
315
+ input: managed.snapshot.tokenUsage.input,
316
+ output: managed.snapshot.tokenUsage.output,
317
+ total: managed.snapshot.tokenUsage.total,
318
+ }
319
+ : undefined,
244
320
  startedAt: managed.startedAt.toISOString(),
245
321
  updatedAt: new Date().toISOString(),
246
322
  completedAt: managed.status === "completed" ? new Date().toISOString() : undefined,
@@ -292,13 +368,14 @@ export class WorkflowManager extends EventEmitter {
292
368
  script: persisted.script,
293
369
  args: persisted.args,
294
370
  journal: persisted.journal ?? [],
371
+ background: true,
295
372
  };
296
373
  this.runs.set(runId, managed);
297
374
 
298
375
  const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e] as const));
299
376
  this.emit("resumed", { runId });
300
377
  // Run in the background; executeRun records status/errors on the managed run.
301
- void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => {});
378
+ void this.executeRun(managed, persisted.script, persisted.args, { resumeJournal }).catch(() => {});
302
379
  return true;
303
380
  }
304
381
 
@@ -1,19 +1,39 @@
1
1
  import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
+ import { listAvailableModelSpecs } from "./agent.js";
4
5
  import {
5
6
  createToolUpdateWorkflowDisplay,
6
7
  createWorkflowSnapshot,
7
- preview,
8
8
  recomputeWorkflowSnapshot,
9
9
  renderWorkflowText,
10
10
  type WorkflowSnapshot,
11
11
  } from "./display.js";
12
12
  import { WorkflowError, WorkflowErrorCode } from "./errors.js";
13
- import { parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
13
+ import { parseWorkflowScript, type WorkflowRunResult } from "./workflow.js";
14
14
  import { WorkflowManager } from "./workflow-manager.js";
15
15
  import { createWorkflowStorage, type WorkflowStorage } from "./workflow-saved.js";
16
16
 
17
+ /**
18
+ * Per-agent model-routing policy handed to the workflow author (the model). It
19
+ * states the rule and lists the user's currently available models, then lets the
20
+ * author choose each agent's model via opts.model — no hardcoded family mapping.
21
+ */
22
+ function modelRoutingGuideline(): string {
23
+ const available = listAvailableModelSpecs();
24
+ const list = available.length
25
+ ? `The user's currently available models (route only to these) are: ${available.join(", ")}.`
26
+ : "Use models the user has configured.";
27
+ return [
28
+ "For workflow, decide each agent's model yourself via opts.model, following this policy:",
29
+ "If the user named a specific model, use exactly that.",
30
+ "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.",
31
+ "For analysis/synthesis/judgment/decision/verification agents, omit opts.model so the agent runs on the main model.",
32
+ "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).",
33
+ list,
34
+ ].join(" ");
35
+ }
36
+
17
37
  const workflowToolSchema = Type.Object({
18
38
  script: Type.String({
19
39
  description: [
@@ -28,7 +48,8 @@ const workflowToolSchema = Type.Object({
28
48
  ),
29
49
  background: Type.Optional(
30
50
  Type.Boolean({
31
- description: "Run the workflow in the background. Default: false. When true, returns immediately with a run ID.",
51
+ description:
52
+ "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).",
32
53
  }),
33
54
  ),
34
55
  maxAgents: Type.Optional(
@@ -62,8 +83,13 @@ export interface WorkflowToolOptions {
62
83
 
63
84
  export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefinition<typeof workflowToolSchema, any> {
64
85
  const storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
65
- const manager = options.manager ?? new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
66
- const loadSavedWorkflow = (name: string) => storage.load(name)?.script;
86
+ const manager =
87
+ options.manager ??
88
+ new WorkflowManager({
89
+ cwd: options.cwd,
90
+ concurrency: options.concurrency,
91
+ loadSavedWorkflow: (name: string) => storage.load(name)?.script,
92
+ });
67
93
 
68
94
  return defineTool({
69
95
  name: "workflow",
@@ -87,38 +113,35 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
87
113
  "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.",
88
114
  "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.",
89
115
  "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.",
116
+ modelRoutingGuideline(),
90
117
  "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.",
91
- "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.",
118
+ "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).",
92
119
  "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.",
93
120
  ],
94
121
  parameters: workflowToolSchema,
95
122
  prepareArguments(args) {
96
123
  return normalizeWorkflowToolArgs(args);
97
124
  },
98
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
125
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
99
126
  const script = normalizeWorkflowScript(params.script);
100
127
  const parsed = parseWorkflowScript(script);
101
128
 
102
- // Background execution
103
- if (params.background) {
129
+ // Background execution is the default: return immediately so the turn ends
130
+ // and the user isn't blocked. The result is delivered back into the
131
+ // conversation when the run finishes (see installResultDelivery). Only an
132
+ // explicit `background: false` blocks for the result inline.
133
+ if (params.background ?? true) {
104
134
  const { runId } = manager.startInBackground(script, params.args);
105
135
  return {
106
- content: [
107
- {
108
- type: "text",
109
- text: [
110
- `Workflow "${parsed.meta.name}" started in background.`,
111
- `Run ID: ${runId}`,
112
- `Use /workflows status ${runId} to check progress.`,
113
- `Use /workflows stop ${runId} to cancel.`,
114
- ].join("\n"),
115
- },
116
- ],
136
+ content: [{ type: "text", text: backgroundStartedText(parsed.meta.name, runId) }],
117
137
  details: { runId, background: true },
118
138
  };
119
139
  }
120
140
 
121
- // Synchronous execution (blocking)
141
+ // Synchronous execution (blocking) — but routed through the manager so the
142
+ // run shows up live in the /workflows navigator and the task panel while it
143
+ // runs, then stays in history afterwards. We still block on the result and
144
+ // return it inline, so the model gets the full output in the same turn.
122
145
  let snapshot: WorkflowSnapshot = createWorkflowSnapshot(parsed.meta);
123
146
  const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, {
124
147
  key: "workflow",
@@ -128,55 +151,15 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
128
151
  showResultPreviews: false,
129
152
  });
130
153
 
131
- const update = () => {
132
- snapshot = recomputeWorkflowSnapshot(snapshot);
133
- display.update(snapshot);
134
- };
135
-
136
154
  let result: WorkflowRunResult;
137
155
  try {
138
- result = await runWorkflow(script, {
139
- cwd: options.cwd ?? ctx.cwd,
140
- args: params.args,
141
- signal,
142
- concurrency: options.concurrency,
156
+ result = await manager.runSync(script, params.args, {
143
157
  maxAgents: params.maxAgents,
144
158
  agentTimeoutMs: params.agentTimeoutMs,
145
- loadSavedWorkflow,
146
- onLog(message) {
147
- snapshot.logs.push(message);
148
- update();
149
- },
150
- onPhase(title) {
151
- snapshot.currentPhase = title;
152
- if (!snapshot.phases.includes(title)) snapshot.phases.push(title);
153
- update();
154
- },
155
- onAgentStart(event) {
156
- if (signal?.aborted) throw new Error("Workflow was aborted");
157
- snapshot.agents.push({
158
- id: snapshot.agents.length + 1,
159
- label: event.label,
160
- phase: event.phase,
161
- prompt: event.prompt,
162
- status: "running",
163
- });
164
- update();
165
- },
166
- onAgentEnd(event) {
167
- const agent = [...snapshot.agents]
168
- .reverse()
169
- .find((item) => item.label === event.label && item.status === "running");
170
- if (agent) {
171
- agent.status = event.result === null ? "error" : "done";
172
- agent.resultPreview = preview(event.result);
173
- agent.tokens = event.tokens;
174
- }
175
- update();
176
- },
177
- onTokenUsage(usage) {
178
- snapshot.tokenUsage = usage;
179
- update();
159
+ externalSignal: signal,
160
+ onProgress(live) {
161
+ snapshot = recomputeWorkflowSnapshot(live);
162
+ display.update(snapshot);
180
163
  },
181
164
  });
182
165
  } catch (error) {
@@ -245,6 +228,25 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
245
228
  });
246
229
  }
247
230
 
231
+ /**
232
+ * The tool result returned when a workflow starts in the background. It both
233
+ * informs the model and tells it to reassure the user: the run continues on its
234
+ * own and the conversation will resume automatically when it finishes, so the
235
+ * user can just wait here (or go do something else).
236
+ */
237
+ export function backgroundStartedText(name: string, runId: string): string {
238
+ return [
239
+ `Workflow "${name}" started in the background.`,
240
+ `Run ID: ${runId}`,
241
+ "It keeps running on its own. When it finishes, the result is delivered back",
242
+ "here and the conversation continues automatically — the user does not need to",
243
+ "do anything. Tell the user they can simply wait here for it to finish (it will",
244
+ "resume the conversation by itself), or keep chatting / working on other things",
245
+ "in the meantime; either way the result will come back to this conversation.",
246
+ `They can also track or cancel it with /workflows status ${runId} or /workflows stop ${runId}.`,
247
+ ].join("\n");
248
+ }
249
+
248
250
  function normalizeWorkflowToolArgs(args: unknown): WorkflowToolInput {
249
251
  if (!args || typeof args !== "object") throw new Error("workflow requires an object argument with a script string");
250
252
  const value = args as Record<string, unknown>;
@@ -63,6 +63,14 @@ interface AgentRow {
63
63
  status: string;
64
64
  phase?: string;
65
65
  tokens?: number;
66
+ model?: string;
67
+ }
68
+
69
+ /** Short, human-friendly model label: drop the provider prefix for display. */
70
+ function shortModel(model: string | undefined): string | undefined {
71
+ if (!model) return undefined;
72
+ const slash = model.indexOf("/");
73
+ return slash > 0 ? model.slice(slash + 1) : model;
66
74
  }
67
75
 
68
76
  /** Reads run/phase/agent data from the manager, preferring live snapshots. */
@@ -127,7 +135,7 @@ export class NavigatorModel {
127
135
  if (!snap) return [];
128
136
  return snap.agents
129
137
  .filter((a) => (a.phase ?? "(no phase)") === phase)
130
- .map((a) => ({ id: a.id, label: a.label, status: a.status, phase: a.phase, tokens: a.tokens }));
138
+ .map((a) => ({ id: a.id, label: a.label, status: a.status, phase: a.phase, tokens: a.tokens, model: a.model }));
131
139
  }
132
140
 
133
141
  agentDetail(runId: string, agentId: number): WorkflowAgentSnapshot | undefined {
@@ -150,6 +158,7 @@ function persistedToSnapshot(p: PersistedRunState): WorkflowSnapshot {
150
158
  resultPreview:
151
159
  a.result == null ? undefined : String(typeof a.result === "string" ? a.result : JSON.stringify(a.result)),
152
160
  error: a.error,
161
+ model: a.model,
153
162
  })),
154
163
  agentCount: p.agents.length,
155
164
  runningCount: p.agents.filter((a) => a.status === "running").length,
@@ -293,8 +302,9 @@ export function renderNavigator(
293
302
  lines.push(theme.bold(`${model.runName(state.runId)} › ${state.phase}`));
294
303
  agents.forEach((a, i) => {
295
304
  const icon = STATUS_ICON[a.status] ?? "?";
296
- const tok = a.tokens ? dim(` ${fmtTokens(a.tokens)}`) : "";
297
- lines.push(sel(i, `${icon} ${a.label}${tok}`));
305
+ const mdl = shortModel(a.model);
306
+ const meta = [mdl, a.tokens ? fmtTokens(a.tokens) : undefined].filter(Boolean).join(" · ");
307
+ lines.push(sel(i, `${icon} ${a.label}${meta ? dim(` ${meta}`) : ""}`));
298
308
  });
299
309
  } else if (state.kind === "detail" && state.runId && state.agentId != null) {
300
310
  const a = model.agentDetail(state.runId, state.agentId);
@@ -302,6 +312,7 @@ export function renderNavigator(
302
312
  if (a) {
303
313
  const body: string[] = [];
304
314
  body.push(dim("Status: ") + (a.status ?? ""));
315
+ if (a.model) body.push(dim("Model: ") + (shortModel(a.model) ?? ""));
305
316
  if (a.error) body.push(dim("Error: ") + a.error);
306
317
  body.push("", dim("Prompt:"));
307
318
  body.push(...wrap(a.prompt ?? "", width));
@@ -454,9 +465,20 @@ export function openWorkflowNavigator(
454
465
  if (id) ui.notify(manager.stop(id) ? `Stopped ${id}` : `Cannot stop ${id}`, "info");
455
466
  break;
456
467
  }
457
- case "restart":
458
- ui.notify("Restarting a single agent isn't supported yet", "warning");
468
+ case "restart": {
469
+ // Restart re-runs the whole workflow from scratch as a fresh
470
+ // background run (per-agent restart isn't meaningful — agents are
471
+ // driven by the script). The new run auto-delivers when it finishes.
472
+ const id = state.activeRunId(model);
473
+ const run = id ? manager.listRuns().find((r) => r.runId === id) : undefined;
474
+ if (!run?.script) {
475
+ ui.notify(id ? `Cannot restart ${id} (no script saved)` : "No run selected to restart", "warning");
476
+ break;
477
+ }
478
+ const { runId: newId } = manager.startInBackground(run.script, run.args);
479
+ ui.notify(`Restarted ${run.workflowName || "workflow"} as ${newId}`, "info");
459
480
  break;
481
+ }
460
482
  case "save": {
461
483
  const id = state.activeRunId(model);
462
484
  const run = id ? manager.listRuns().find((r) => r.runId === id) : undefined;
package/src/workflow.ts CHANGED
@@ -48,6 +48,8 @@ export interface SharedRuntime {
48
48
  export interface WorkflowRunOptions extends WorkflowAgentOptions {
49
49
  args?: unknown;
50
50
  agent?: Pick<WorkflowAgent, "run">;
51
+ /** The session's main model (provider/id), shown in /workflows for default agents. */
52
+ mainModel?: string;
51
53
  concurrency?: number;
52
54
  tokenBudget?: number | null;
53
55
  signal?: AbortSignal;
@@ -72,7 +74,14 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
72
74
  onLog?: (message: string) => void;
73
75
  onPhase?: (title: string) => void;
74
76
  onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
75
- onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number; worktree?: string }) => void;
77
+ onAgentEnd?: (event: {
78
+ label: string;
79
+ phase?: string;
80
+ result: unknown;
81
+ tokens?: number;
82
+ worktree?: string;
83
+ model?: string;
84
+ }) => void;
76
85
  onTokenUsage?: (usage: { input: number; output: number; total: number; cost: number }) => void;
77
86
  }
78
87
 
@@ -96,6 +105,12 @@ export interface AgentOptions<TSchemaDef extends TSchema | undefined = TSchema |
96
105
  label?: string;
97
106
  phase?: string;
98
107
  schema?: TSchemaDef;
108
+ /**
109
+ * Run this agent on a specific model (`provider/modelId` or a bare `modelId`).
110
+ * The workflow author chooses per-agent models per the routing policy in the
111
+ * tool guidelines (e.g. a lighter model for exploration, the main model for
112
+ * analysis). When omitted, the session's main model is used.
113
+ */
99
114
  model?: string;
100
115
  isolation?: "worktree";
101
116
  agentType?: string;
@@ -203,6 +218,10 @@ export async function runWorkflow<T = unknown>(
203
218
  const requestedLabel = agentOptions.label?.trim();
204
219
  // Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
205
220
  const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
221
+ // For display in /workflows: the model this agent runs on — its explicit/phase
222
+ // spec, else the session's main model. The real resolved id overrides this via
223
+ // onModelResolved once the subagent session is created.
224
+ let displayModel = modelSpec ?? options.mainModel;
206
225
 
207
226
  // Deterministic resume key: assigned at lexical call time, before the limiter,
208
227
  // so parallel()/pipeline() fan-out is reproducible for a fixed script.
@@ -215,8 +234,8 @@ export async function runWorkflow<T = unknown>(
215
234
  if (cached && cached.hash === callHash) {
216
235
  shared.agentCount++;
217
236
  const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
218
- options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
219
- options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
237
+ options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: displayModel });
238
+ options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0, model: displayModel });
220
239
  return cached.result;
221
240
  }
222
241
 
@@ -225,7 +244,7 @@ export async function runWorkflow<T = unknown>(
225
244
  const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
226
245
  const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
227
246
 
228
- options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
247
+ options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: displayModel });
229
248
 
230
249
  // Optional per-agent worktree isolation (deterministic name -> stable resume keys).
231
250
  let worktree: Worktree | undefined;
@@ -262,6 +281,9 @@ export async function runWorkflow<T = unknown>(
262
281
  instructions: buildAgentInstructions(assignedPhase, agentOptions),
263
282
  model: modelSpec,
264
283
  cwd: runCwd,
284
+ onModelResolved: (id: string) => {
285
+ displayModel = id;
286
+ },
265
287
  onUsage: (u: AgentUsage) => {
266
288
  usage = u;
267
289
  },
@@ -274,7 +296,7 @@ export async function runWorkflow<T = unknown>(
274
296
 
275
297
  const tokens = recordTokens(result);
276
298
  options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
277
- options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
299
+ options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd, model: displayModel });
278
300
  return result;
279
301
  } catch (error) {
280
302
  if (options.signal?.aborted) throw error;