@quintinshaw/pi-dynamic-workflows 1.9.0 → 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.
@@ -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
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@quintinshaw/pi-dynamic-workflows",
3
- "version": "1.9.0",
4
- "description": "Claude-Code-style dynamic workflow orchestration for Pi.",
3
+ "version": "1.9.2",
4
+ "description": "Claude-Code-style dynamic workflows for Pi — fan a task out across 100s of subagents with real model routing, token/cost accounting, resume, git-worktree isolation, an interactive /workflows TUI, and a real /deep-research.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -33,8 +33,21 @@
33
33
  "keywords": [
34
34
  "pi-package",
35
35
  "pi",
36
+ "pi-coding-agent",
36
37
  "workflow",
37
- "agents"
38
+ "workflows",
39
+ "dynamic-workflows",
40
+ "orchestration",
41
+ "subagents",
42
+ "multi-agent",
43
+ "agents",
44
+ "ai-agents",
45
+ "parallel",
46
+ "fan-out",
47
+ "claude-code",
48
+ "deep-research",
49
+ "code-review",
50
+ "llm"
38
51
  ],
39
52
  "pi": {
40
53
  "extensions": [
package/src/agent.ts CHANGED
@@ -24,6 +24,22 @@ export interface WorkflowAgentOptions {
24
24
  instructions?: string;
25
25
  }
26
26
 
27
+ /**
28
+ * List the user's currently available models (those with auth configured) as
29
+ * `provider/modelId` specs. Used to tell the workflow author which models it may
30
+ * route agents to. Best-effort: returns [] if the registry can't be built.
31
+ */
32
+ export function listAvailableModelSpecs(): string[] {
33
+ try {
34
+ const dir = getAgentDir();
35
+ const auth = AuthStorage.create(join(dir, "auth.json"));
36
+ const registry = ModelRegistry.create(auth, join(dir, "models.json"));
37
+ return registry.getAvailable().map((m) => `${m.provider}/${m.id}`);
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
27
43
  /** Real token/cost usage for a single subagent run, read from the SDK session. */
28
44
  export interface AgentUsage {
29
45
  input: number;
package/src/display.ts CHANGED
@@ -13,6 +13,8 @@ export interface WorkflowAgentSnapshot {
13
13
  error?: string;
14
14
  /** Tokens used by this agent. */
15
15
  tokens?: number;
16
+ /** The model this agent ran on (provider/id), when known. */
17
+ model?: string;
16
18
  }
17
19
 
18
20
  export interface WorkflowSnapshot {
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type { AdversarialReviewConfig } from "./adversarial-review.js";
2
2
  export { generateAdversarialReviewWorkflow, generateMultiPerspectiveWorkflow } from "./adversarial-review.js";
3
3
  export type { AgentRunOptions, AgentRunResult, WorkflowAgentOptions } from "./agent.js";
4
- export { WorkflowAgent } from "./agent.js";
4
+ export { listAvailableModelSpecs, WorkflowAgent } from "./agent.js";
5
5
  export type { AutoWorkflowConfig } from "./auto-workflow.js";
6
6
  export { shouldUseWorkflow, suggestWorkflowScript } from "./auto-workflow.js";
7
7
  export { registerBuiltinWorkflows } from "./builtin-commands.js";
@@ -58,12 +58,23 @@ export type {
58
58
  } from "./workflow.js";
59
59
  export { parseWorkflowScript, runWorkflow } from "./workflow.js";
60
60
  export { registerWorkflowCommands } from "./workflow-commands.js";
61
+ export {
62
+ buildForcedWorkflowPrompt,
63
+ colorizeWorkflow,
64
+ endsWithTrigger,
65
+ hasTrigger,
66
+ installWorkflowEditor,
67
+ RAINBOW,
68
+ tokenizeAnsi,
69
+ WorkflowEditor,
70
+ type WorkflowModeState,
71
+ } from "./workflow-editor.js";
61
72
  export type { ManagedRun, WorkflowManagerOptions } from "./workflow-manager.js";
62
73
  export { WorkflowManager } from "./workflow-manager.js";
63
74
  export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
64
75
  export { createWorkflowStorage } from "./workflow-saved.js";
65
76
  export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.js";
66
- export { createWorkflowTool } from "./workflow-tool.js";
77
+ export { backgroundStartedText, createWorkflowTool } from "./workflow-tool.js";
67
78
  export {
68
79
  keyToAction,
69
80
  type NavAction,
@@ -18,6 +18,8 @@ export interface PersistedAgentState {
18
18
  error?: string;
19
19
  startedAt?: string;
20
20
  endedAt?: string;
21
+ /** The model this agent ran on (provider/id), when known. */
22
+ model?: string;
21
23
  }
22
24
 
23
25
  export interface PersistedRunState {
package/src/task-panel.ts CHANGED
@@ -1,17 +1,15 @@
1
1
  /**
2
2
  * Background-run UX, mirroring Claude Code:
3
3
  * - A live task panel below the input lists in-progress runs while you keep working.
4
- * Focus it (↓) and press enter to open the full navigator.
4
+ * It is informational; run /workflows to open the full navigator.
5
5
  * - When a background run finishes, its result is delivered back into the
6
6
  * conversation so the paused task continues with the outcome.
7
7
  */
8
8
 
9
9
  import type { ExtensionAPI, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
10
10
  import type { Component, TUI } from "@earendil-works/pi-tui";
11
- import { parseKey } from "@earendil-works/pi-tui";
12
11
  import type { ManagedRun, WorkflowManager } from "./workflow-manager.js";
13
12
  import type { WorkflowStorage } from "./workflow-saved.js";
14
- import { openWorkflowNavigator } from "./workflow-ui.js";
15
13
 
16
14
  const RUN_EVENTS = ["agentStart", "agentEnd", "phase", "log", "complete", "error", "stopped", "paused", "resumed"];
17
15
 
@@ -26,31 +24,50 @@ function deliverText(run: ManagedRun): string {
26
24
  r && typeof r.report === "string" && r.report.trim() ? r.report : JSON.stringify(run.result?.result, null, 2);
27
25
  const tokens = run.result?.tokenUsage ? ` · ${run.result.tokenUsage.total.toLocaleString()} tokens` : "";
28
26
  const agents = run.result?.agentCount ?? run.snapshot.agentCount;
29
- return `✓ Workflow "${run.snapshot.name}" finished (${agents} agents${tokens}).\n\n${body}`;
27
+ return [
28
+ `✓ Background workflow "${run.snapshot.name}" finished (${agents} agents${tokens}).`,
29
+ "Continue helping the user based on this result.",
30
+ "",
31
+ body,
32
+ ].join("\n");
30
33
  }
31
34
 
32
35
  /**
33
- * Deliver a background run's result into the conversation when it completes or
34
- * fails. Set up once per extension; idempotent via an internal guard.
36
+ * When a background run finishes (or fails), deliver its result back into the
37
+ * conversation AND continue the turn so the assistant can act on it — without
38
+ * blocking the user meanwhile:
39
+ *
40
+ * - `triggerTurn: true` starts a fresh turn when the agent is idle, feeding the
41
+ * result to the model so the paused conversation continues.
42
+ * - `deliverAs: "followUp"` means that if the user is busy in another turn, the
43
+ * result is queued and picked up after that turn finishes — never interrupting.
44
+ *
45
+ * Set up once per extension; idempotent via an internal guard.
35
46
  */
36
47
  export function installResultDelivery(pi: ExtensionAPI, manager: WorkflowManager): void {
37
48
  if ((manager as unknown as { __deliveryInstalled?: boolean }).__deliveryInstalled) return;
38
49
  (manager as unknown as { __deliveryInstalled?: boolean }).__deliveryInstalled = true;
39
50
 
51
+ const deliver = (content: string) => {
52
+ void pi.sendMessage(
53
+ { customType: "workflow-result", content, display: true },
54
+ { triggerTurn: true, deliverAs: "followUp" },
55
+ );
56
+ };
57
+
40
58
  manager.on("complete", ({ runId }: { runId: string }) => {
41
59
  const run = manager.getRun(runId);
42
- if (run) void pi.sendMessage({ customType: "workflow-result", content: deliverText(run), display: true });
60
+ // Only background/resumed runs are delivered: a foreground (sync) run already
61
+ // returns its result inline as the tool result, so re-delivering would dup it.
62
+ if (run?.background) deliver(deliverText(run));
43
63
  });
44
64
  manager.on("error", ({ runId, error }: { runId: string; error?: { message?: string } }) => {
45
- void pi.sendMessage({
46
- customType: "workflow-result",
47
- content: `✗ Workflow ${runId} failed: ${error?.message ?? "unknown error"}`,
48
- display: true,
49
- });
65
+ if (!manager.getRun(runId)?.background) return;
66
+ deliver(`✗ Background workflow ${runId} failed: ${error?.message ?? "unknown error"}`);
50
67
  });
51
68
  }
52
69
 
53
- function renderPanel(manager: WorkflowManager, theme: Theme, focused: boolean): string[] {
70
+ function renderPanel(manager: WorkflowManager, theme: Theme): string[] {
54
71
  const active = manager.listRuns().filter((r) => r.status === "running" || r.status === "paused");
55
72
  if (!active.length) return [];
56
73
  const rows = active.map((r) => {
@@ -61,36 +78,30 @@ function renderPanel(manager: WorkflowManager, theme: Theme, focused: boolean):
61
78
  const phase = live?.snapshot.currentPhase ? ` · ${live.snapshot.currentPhase}` : "";
62
79
  return ` ${icon} ${r.workflowName} ${done}/${agents.length} agents${phase}`;
63
80
  });
64
- const hint = focused
65
- ? theme.fg("accent", " enter: open · esc: back")
66
- : theme.fg("dim", " ↓ then enter, or /workflows, to open");
81
+ const hint = theme.fg("dim", " run /workflows to open");
67
82
  return [theme.bold(`Workflows running (${active.length}):`), ...rows, hint];
68
83
  }
69
84
 
70
85
  /**
71
86
  * Install the live "workflows running" panel below the editor. Re-rendered on
72
- * every manager event; focus + enter opens the navigator.
87
+ * every manager event. Informational only the user opens the navigator with
88
+ * /workflows. (`_pi`/`_opts` are kept for signature stability.)
73
89
  */
74
90
  export function installTaskPanel(
75
- pi: ExtensionAPI,
91
+ _pi: ExtensionAPI,
76
92
  manager: WorkflowManager,
77
93
  ui: ExtensionUIContext,
78
- opts: TaskPanelOptions = {},
94
+ _opts: TaskPanelOptions = {},
79
95
  ): void {
80
96
  ui.setWidget(
81
97
  "workflow-tasks",
82
98
  (tui: TUI, theme: Theme) => {
83
99
  const onEvent = () => tui.requestRender();
84
100
  for (const ev of RUN_EVENTS) manager.on(ev, onEvent);
85
- const comp: Component & { focused?: boolean; dispose?(): void } = {
86
- focused: false,
87
- render: () => renderPanel(manager, theme, comp.focused ?? false),
88
- handleInput: (data: string) => {
89
- const key = parseKey(data);
90
- if (key === "enter" || key === "return" || key === "right") {
91
- void openWorkflowNavigator(pi, manager, ui, opts);
92
- }
93
- },
101
+ // Purely informational: it lists running runs and re-renders on events. To
102
+ // open the navigator, the user runs /workflows (the panel takes no input).
103
+ const comp: Component & { dispose?(): void } = {
104
+ render: () => renderPanel(manager, theme),
94
105
  invalidate: () => {},
95
106
  dispose: () => {
96
107
  for (const ev of RUN_EVENTS) manager.off(ev, onEvent);