@quintinshaw/pi-dynamic-workflows 1.7.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Interactive `/workflows` navigator, modeled on Claude Code's view:
3
+ *
4
+ * runs ──enter──▶ phases ──enter──▶ agents ──enter──▶ agent detail
5
+ * ◀──esc─── ◀──esc──── ◀──esc────
6
+ *
7
+ * Keys: ↑/↓ (or j/k) select · enter/→ drill in · esc/← back (esc at top closes)
8
+ * p pause/resume · x stop · r restart · s save · q quit
9
+ *
10
+ * The state machine and line rendering are pure and unit-tested; the pi-tui
11
+ * Component shell (openWorkflowNavigator) wires them to live manager events.
12
+ */
13
+ import { parseKey } from "@earendil-works/pi-tui";
14
+ import { registerSavedWorkflow } from "./saved-commands.js";
15
+ const STATUS_ICON = {
16
+ pending: "·",
17
+ queued: "·",
18
+ running: "◆",
19
+ paused: "⏸",
20
+ completed: "✓",
21
+ done: "✓",
22
+ failed: "✗",
23
+ error: "✗",
24
+ aborted: "⊘",
25
+ skipped: "⊘",
26
+ };
27
+ const PLAIN = { fg: (_c, t) => t, bold: (t) => t };
28
+ /** Reads run/phase/agent data from the manager, preferring live snapshots. */
29
+ export class NavigatorModel {
30
+ manager;
31
+ constructor(manager) {
32
+ this.manager = manager;
33
+ }
34
+ snapshot(runId) {
35
+ const live = this.manager.getRun(runId);
36
+ if (live)
37
+ return { snapshot: live.snapshot, status: live.status };
38
+ const p = this.manager.listRuns().find((r) => r.runId === runId);
39
+ if (!p)
40
+ return undefined;
41
+ return { snapshot: persistedToSnapshot(p), status: p.status };
42
+ }
43
+ runs() {
44
+ return this.manager.listRuns().map((p) => {
45
+ const live = this.manager.getRun(p.runId);
46
+ const agents = (live?.snapshot.agents ?? p.agents);
47
+ return {
48
+ runId: p.runId,
49
+ name: live?.snapshot.name ?? p.workflowName,
50
+ status: live?.status ?? p.status,
51
+ done: agents.filter((a) => a.status === "done").length,
52
+ total: agents.length,
53
+ tokens: (live?.snapshot.tokenUsage ?? p.tokenUsage)?.total ?? 0,
54
+ };
55
+ });
56
+ }
57
+ runName(runId) {
58
+ return this.snapshot(runId)?.snapshot.name ?? runId;
59
+ }
60
+ runStatus(runId) {
61
+ return this.snapshot(runId)?.status ?? "unknown";
62
+ }
63
+ phases(runId) {
64
+ const snap = this.snapshot(runId)?.snapshot;
65
+ if (!snap)
66
+ return [];
67
+ const order = snap.phases.length ? [...snap.phases] : [];
68
+ const byPhase = new Map();
69
+ for (const a of snap.agents) {
70
+ const key = a.phase ?? "(no phase)";
71
+ if (!byPhase.has(key))
72
+ byPhase.set(key, []);
73
+ byPhase.get(key)?.push(a);
74
+ if (!order.includes(key))
75
+ order.push(key);
76
+ }
77
+ return order.map((title) => {
78
+ const agents = byPhase.get(title) ?? [];
79
+ return {
80
+ title,
81
+ done: agents.filter((a) => a.status === "done").length,
82
+ total: agents.length,
83
+ tokens: agents.reduce((n, a) => n + (a.tokens ?? 0), 0),
84
+ };
85
+ });
86
+ }
87
+ agents(runId, phase) {
88
+ const snap = this.snapshot(runId)?.snapshot;
89
+ if (!snap)
90
+ return [];
91
+ return snap.agents
92
+ .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 }));
94
+ }
95
+ agentDetail(runId, agentId) {
96
+ return this.snapshot(runId)?.snapshot.agents.find((a) => a.id === agentId);
97
+ }
98
+ }
99
+ function persistedToSnapshot(p) {
100
+ return {
101
+ name: p.workflowName,
102
+ phases: p.phases,
103
+ currentPhase: p.currentPhase,
104
+ logs: p.logs,
105
+ agents: p.agents.map((a) => ({
106
+ id: a.id,
107
+ label: a.label,
108
+ phase: a.phase,
109
+ prompt: a.prompt,
110
+ status: a.status,
111
+ resultPreview: a.result == null ? undefined : String(typeof a.result === "string" ? a.result : JSON.stringify(a.result)),
112
+ error: a.error,
113
+ })),
114
+ agentCount: p.agents.length,
115
+ runningCount: p.agents.filter((a) => a.status === "running").length,
116
+ doneCount: p.agents.filter((a) => a.status === "done").length,
117
+ errorCount: p.agents.filter((a) => a.status === "error").length,
118
+ tokenUsage: p.tokenUsage ? { ...p.tokenUsage } : undefined,
119
+ runId: p.runId,
120
+ };
121
+ }
122
+ /** Navigation state machine: a stack of (view, cursor) frames plus detail scroll. */
123
+ export class NavigatorState {
124
+ stack = [
125
+ { kind: "runs", cursor: 0 },
126
+ ];
127
+ scroll = 0;
128
+ top() {
129
+ return this.stack[this.stack.length - 1];
130
+ }
131
+ get kind() {
132
+ return this.top().kind;
133
+ }
134
+ get cursor() {
135
+ return this.top().cursor;
136
+ }
137
+ get runId() {
138
+ return this.top().runId;
139
+ }
140
+ get phase() {
141
+ return this.top().phase;
142
+ }
143
+ get agentId() {
144
+ return this.top().agentId;
145
+ }
146
+ get depth() {
147
+ return this.stack.length;
148
+ }
149
+ /** Clamp the cursor to [0, count). */
150
+ clamp(count) {
151
+ const t = this.top();
152
+ t.cursor = count <= 0 ? 0 : Math.max(0, Math.min(t.cursor, count - 1));
153
+ }
154
+ move(delta, count) {
155
+ if (this.kind === "detail") {
156
+ this.scroll = Math.max(0, this.scroll + delta);
157
+ return;
158
+ }
159
+ if (count <= 0)
160
+ return;
161
+ const t = this.top();
162
+ t.cursor = (t.cursor + delta + count) % count;
163
+ }
164
+ /** Drill into the selected item. Returns true if the view changed. */
165
+ drill(model) {
166
+ const t = this.top();
167
+ if (t.kind === "runs") {
168
+ const runs = model.runs();
169
+ const run = runs[t.cursor];
170
+ if (!run)
171
+ return false;
172
+ this.stack.push({ kind: "phases", cursor: 0, runId: run.runId });
173
+ return true;
174
+ }
175
+ if (t.kind === "phases" && t.runId) {
176
+ const phases = model.phases(t.runId);
177
+ const ph = phases[t.cursor];
178
+ if (!ph)
179
+ return false;
180
+ this.stack.push({ kind: "agents", cursor: 0, runId: t.runId, phase: ph.title });
181
+ return true;
182
+ }
183
+ if (t.kind === "agents" && t.runId && t.phase) {
184
+ const agents = model.agents(t.runId, t.phase);
185
+ const ag = agents[t.cursor];
186
+ if (!ag)
187
+ return false;
188
+ this.scroll = 0;
189
+ this.stack.push({ kind: "detail", cursor: 0, runId: t.runId, phase: t.phase, agentId: ag.id });
190
+ return true;
191
+ }
192
+ return false;
193
+ }
194
+ /** Pop one level. Returns false when already at the top (caller should close). */
195
+ back() {
196
+ if (this.stack.length <= 1)
197
+ return false;
198
+ this.stack.pop();
199
+ this.scroll = 0;
200
+ return true;
201
+ }
202
+ /** The runId the current view acts on (for pause/stop/save). */
203
+ activeRunId(model) {
204
+ if (this.runId)
205
+ return this.runId;
206
+ if (this.kind === "runs")
207
+ return model.runs()[this.cursor]?.runId;
208
+ return undefined;
209
+ }
210
+ }
211
+ function pad(n) {
212
+ return n.toLocaleString();
213
+ }
214
+ function fmtTokens(t) {
215
+ return t > 0 ? `${pad(t)} tok` : "";
216
+ }
217
+ /** Build the lines for the current view. Pure: depends only on state + model + theme. */
218
+ export function renderNavigator(state, model, width, theme = PLAIN) {
219
+ const lines = [];
220
+ const sel = (i, text) => i === state.cursor ? theme.fg("accent", theme.bold(`❯ ${text}`)) : ` ${text}`;
221
+ const dim = (t) => theme.fg("dim", t);
222
+ if (state.kind === "runs") {
223
+ const runs = model.runs();
224
+ state.clamp(runs.length);
225
+ lines.push(theme.bold("Workflows"));
226
+ if (!runs.length)
227
+ lines.push(dim(" No runs yet. Start one with a background workflow."));
228
+ runs.forEach((r, i) => {
229
+ const icon = STATUS_ICON[r.status] ?? "?";
230
+ const meta = [`${r.done}/${r.total}`, fmtTokens(r.tokens)].filter(Boolean).join(" · ");
231
+ lines.push(sel(i, `${icon} ${r.name} ${dim(`${r.runId} · ${r.status} · ${meta}`)}`));
232
+ });
233
+ }
234
+ else if (state.kind === "phases" && state.runId) {
235
+ const phases = model.phases(state.runId);
236
+ state.clamp(phases.length);
237
+ lines.push(theme.bold(model.runName(state.runId)) + dim(` (${model.runStatus(state.runId)})`));
238
+ phases.forEach((p, i) => {
239
+ const meta = [`${p.done}/${p.total} agents`, fmtTokens(p.tokens)].filter(Boolean).join(" · ");
240
+ lines.push(sel(i, `${p.title} ${dim(meta)}`));
241
+ });
242
+ }
243
+ else if (state.kind === "agents" && state.runId && state.phase) {
244
+ const agents = model.agents(state.runId, state.phase);
245
+ state.clamp(agents.length);
246
+ lines.push(theme.bold(`${model.runName(state.runId)} › ${state.phase}`));
247
+ agents.forEach((a, i) => {
248
+ const icon = STATUS_ICON[a.status] ?? "?";
249
+ const tok = a.tokens ? dim(` ${fmtTokens(a.tokens)}`) : "";
250
+ lines.push(sel(i, `${icon} ${a.label}${tok}`));
251
+ });
252
+ }
253
+ else if (state.kind === "detail" && state.runId && state.agentId != null) {
254
+ const a = model.agentDetail(state.runId, state.agentId);
255
+ lines.push(theme.bold(a ? a.label : "agent"));
256
+ if (a) {
257
+ const body = [];
258
+ body.push(dim("Status: ") + (a.status ?? ""));
259
+ if (a.error)
260
+ body.push(dim("Error: ") + a.error);
261
+ body.push("", dim("Prompt:"));
262
+ body.push(...wrap(a.prompt ?? "", width));
263
+ body.push("", dim("Result:"));
264
+ body.push(...wrap(a.resultPreview ?? "(none)", width));
265
+ // Scrollable region.
266
+ const maxScroll = Math.max(0, body.length - 1);
267
+ state.scroll = Math.min(state.scroll, maxScroll);
268
+ lines.push(...body.slice(state.scroll));
269
+ }
270
+ }
271
+ lines.push("");
272
+ lines.push(footerHint(state, theme));
273
+ return lines;
274
+ }
275
+ function footerHint(state, theme) {
276
+ const parts = state.kind === "detail"
277
+ ? ["j/k scroll", "esc back"]
278
+ : ["↑/↓ select", "enter open", "esc back", "p pause", "x stop", "r restart", "s save", "q quit"];
279
+ return theme.fg("dim", parts.join(" · "));
280
+ }
281
+ function wrap(text, width) {
282
+ const w = Math.max(20, width - 2);
283
+ const out = [];
284
+ for (const para of String(text).split("\n")) {
285
+ if (para.length <= w) {
286
+ out.push(para);
287
+ continue;
288
+ }
289
+ let rest = para;
290
+ while (rest.length > w) {
291
+ out.push(rest.slice(0, w));
292
+ rest = rest.slice(w);
293
+ }
294
+ if (rest)
295
+ out.push(rest);
296
+ }
297
+ return out;
298
+ }
299
+ export function keyToAction(keyId, kind) {
300
+ switch (keyId) {
301
+ case "up":
302
+ return { type: "move", delta: -1 };
303
+ case "down":
304
+ return { type: "move", delta: 1 };
305
+ case "k":
306
+ return { type: "move", delta: -1 };
307
+ case "j":
308
+ return { type: "move", delta: 1 };
309
+ case "enter":
310
+ case "return":
311
+ case "right":
312
+ return kind === "detail" ? { type: "none" } : { type: "drill" };
313
+ case "escape":
314
+ case "esc":
315
+ case "left":
316
+ return { type: "back" };
317
+ case "q":
318
+ return { type: "close" };
319
+ case "p":
320
+ return { type: "pause" };
321
+ case "x":
322
+ return { type: "stop" };
323
+ case "r":
324
+ return { type: "restart" };
325
+ case "s":
326
+ return { type: "save" };
327
+ default:
328
+ return { type: "none" };
329
+ }
330
+ }
331
+ function currentCount(state, model) {
332
+ if (state.kind === "runs")
333
+ return model.runs().length;
334
+ if (state.kind === "phases" && state.runId)
335
+ return model.phases(state.runId).length;
336
+ if (state.kind === "agents" && state.runId && state.phase)
337
+ return model.agents(state.runId, state.phase).length;
338
+ return 0;
339
+ }
340
+ /**
341
+ * Open the interactive `/workflows` navigator as a focused overlay. Resolves when
342
+ * the user closes it (esc at the top level, or `q`).
343
+ */
344
+ export function openWorkflowNavigator(pi, manager, ui, opts = {}) {
345
+ const model = new NavigatorModel(manager);
346
+ const state = new NavigatorState();
347
+ return ui.custom((tui, theme, _keybindings, done) => {
348
+ const rerender = () => tui.requestRender();
349
+ const events = ["agentStart", "agentEnd", "phase", "log", "complete", "error", "stopped", "paused", "resumed"];
350
+ const onEvent = () => rerender();
351
+ for (const ev of events)
352
+ manager.on(ev, onEvent);
353
+ const cleanup = () => {
354
+ for (const ev of events)
355
+ manager.off(ev, onEvent);
356
+ };
357
+ const act = (data) => {
358
+ const action = keyToAction(parseKey(data), state.kind);
359
+ switch (action.type) {
360
+ case "move":
361
+ state.move(action.delta, currentCount(state, model));
362
+ break;
363
+ case "drill":
364
+ state.drill(model);
365
+ break;
366
+ case "back":
367
+ if (!state.back()) {
368
+ cleanup();
369
+ done();
370
+ }
371
+ break;
372
+ case "close":
373
+ cleanup();
374
+ done();
375
+ return;
376
+ case "pause": {
377
+ const id = state.activeRunId(model);
378
+ if (id)
379
+ ui.notify(manager.pause(id) ? `Paused ${id}` : `Cannot pause ${id}`, "info");
380
+ break;
381
+ }
382
+ case "stop": {
383
+ const id = state.activeRunId(model);
384
+ if (id)
385
+ ui.notify(manager.stop(id) ? `Stopped ${id}` : `Cannot stop ${id}`, "info");
386
+ break;
387
+ }
388
+ case "restart":
389
+ ui.notify("Restarting a single agent isn't supported yet", "warning");
390
+ break;
391
+ case "save": {
392
+ const id = state.activeRunId(model);
393
+ const run = id ? manager.listRuns().find((r) => r.runId === id) : undefined;
394
+ if (!run?.script) {
395
+ ui.notify("No saved run script to save", "warning");
396
+ }
397
+ else if (!opts.storage) {
398
+ ui.notify("Saving is not available (no storage)", "error");
399
+ }
400
+ else {
401
+ const name = run.workflowName || "workflow";
402
+ const saved = opts.storage.save({
403
+ name,
404
+ description: run.workflowName,
405
+ script: run.script,
406
+ location: "project",
407
+ });
408
+ registerSavedWorkflow(pi, opts.cwd ?? process.cwd(), saved);
409
+ ui.notify(`Saved /${name}`, "info");
410
+ }
411
+ break;
412
+ }
413
+ default:
414
+ return;
415
+ }
416
+ rerender();
417
+ };
418
+ const component = {
419
+ render: (width) => renderNavigator(state, model, width, theme),
420
+ handleInput: (data) => act(data),
421
+ invalidate: () => { },
422
+ dispose: () => cleanup(),
423
+ };
424
+ return component;
425
+ }, { overlay: true });
426
+ }
@@ -18,6 +18,23 @@ export interface JournalEntry {
18
18
  hash: string;
19
19
  result: unknown;
20
20
  }
21
+ /**
22
+ * Global resources shared across a run and any workflow() nested inside it, so
23
+ * the 16-concurrent / 1000-total caps and the token budget hold across nesting
24
+ * instead of each level getting its own limiter and counters.
25
+ */
26
+ export interface SharedRuntime {
27
+ limiter: <T>(fn: () => Promise<T>) => Promise<T>;
28
+ agentCount: number;
29
+ spent: number;
30
+ tokenUsage: {
31
+ input: number;
32
+ output: number;
33
+ total: number;
34
+ cost: number;
35
+ };
36
+ depth: number;
37
+ }
21
38
  export interface WorkflowRunOptions extends WorkflowAgentOptions {
22
39
  args?: unknown;
23
40
  agent?: Pick<WorkflowAgent, "run">;
@@ -38,6 +55,10 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
38
55
  resumeFromRunId?: string;
39
56
  /** Called after each live agent completes so the caller can persist the journal. */
40
57
  onAgentJournal?: (entry: JournalEntry) => void;
58
+ /** Internal: shared runtime inherited by a nested workflow() call. */
59
+ sharedRuntime?: SharedRuntime;
60
+ /** Resolve a saved-workflow name to its script, enabling `workflow('name', args)`. */
61
+ loadSavedWorkflow?: (name: string) => string | undefined;
41
62
  onLog?: (message: string) => void;
42
63
  onPhase?: (title: string) => void;
43
64
  onAgentStart?: (event: {
package/dist/workflow.js CHANGED
@@ -27,14 +27,19 @@ export async function runWorkflow(script, options = {}) {
27
27
  const state = {
28
28
  logs: [],
29
29
  phases: [],
30
- agentCount: 0,
31
30
  callSeq: 0,
32
- spent: 0,
33
- tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
34
31
  };
35
32
  const agentRunner = options.agent ?? new WorkflowAgent(options);
36
33
  const concurrency = Math.max(1, Math.min(options.concurrency ?? Math.max(1, (globalThis.navigator?.hardwareConcurrency ?? 8) - 2), MAX_CONCURRENCY));
37
- const limiter = createLimiter(concurrency);
34
+ // Global caps + budget are shared with any nested workflow() so they hold across nesting.
35
+ const shared = options.sharedRuntime ?? {
36
+ limiter: createLimiter(concurrency),
37
+ agentCount: 0,
38
+ spent: 0,
39
+ tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
40
+ depth: 0,
41
+ };
42
+ const limiter = shared.limiter;
38
43
  const log = (message) => {
39
44
  const text = String(message);
40
45
  state.logs.push(text);
@@ -48,8 +53,8 @@ export async function runWorkflow(script, options = {}) {
48
53
  };
49
54
  const budget = Object.freeze({
50
55
  total: options.tokenBudget ?? null,
51
- spent: () => state.spent,
52
- remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - state.spent)),
56
+ spent: () => shared.spent,
57
+ remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - shared.spent)),
53
58
  });
54
59
  const throwIfAborted = () => {
55
60
  if (options.signal?.aborted) {
@@ -59,7 +64,7 @@ export async function runWorkflow(script, options = {}) {
59
64
  const agent = async (prompt, agentOptions = {}) => {
60
65
  throwIfAborted();
61
66
  // Check agent limit
62
- if (state.agentCount >= maxAgents) {
67
+ if (shared.agentCount >= maxAgents) {
63
68
  throw new WorkflowError(`Agent limit exceeded (${maxAgents}). Use maxAgents option to increase the limit.`, WorkflowErrorCode.AGENT_LIMIT_EXCEEDED, { recoverable: false });
64
69
  }
65
70
  if (budget.total !== null && budget.remaining() <= 0) {
@@ -79,15 +84,15 @@ export async function runWorkflow(script, options = {}) {
79
84
  // consuming a concurrency slot, tokens, or a real subagent run.
80
85
  const cached = options.resumeJournal?.get(callIndex);
81
86
  if (cached && cached.hash === callHash) {
82
- state.agentCount++;
83
- const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
87
+ shared.agentCount++;
88
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
84
89
  options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
85
90
  options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
86
91
  return cached.result;
87
92
  }
88
93
  return limiter(async () => {
89
- state.agentCount++;
90
- const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
94
+ shared.agentCount++;
95
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
91
96
  const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
92
97
  options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
93
98
  // Optional per-agent worktree isolation (deterministic name -> stable resume keys).
@@ -104,12 +109,12 @@ export async function runWorkflow(script, options = {}) {
104
109
  const recordTokens = (result) => {
105
110
  const tokens = usage && usage.total > 0 ? usage.total : estimateTokens(result) + estimateTokens(prompt);
106
111
  if (usage) {
107
- state.tokenUsage.input += usage.input;
108
- state.tokenUsage.output += usage.output;
109
- state.tokenUsage.cost += usage.cost;
112
+ shared.tokenUsage.input += usage.input;
113
+ shared.tokenUsage.output += usage.output;
114
+ shared.tokenUsage.cost += usage.cost;
110
115
  }
111
- state.tokenUsage.total += tokens;
112
- state.spent += tokens;
116
+ shared.tokenUsage.total += tokens;
117
+ shared.spent += tokens;
113
118
  return tokens;
114
119
  };
115
120
  try {
@@ -198,10 +203,40 @@ export async function runWorkflow(script, options = {}) {
198
203
  return value;
199
204
  }));
200
205
  };
206
+ // Nested workflow(): run a saved workflow (or a raw script) inline, sharing this
207
+ // run's limiter/counters/budget so the global caps hold. One level deep only.
208
+ const workflowFn = async (nameOrScript, childArgs) => {
209
+ throwIfAborted();
210
+ if (shared.depth >= 1) {
211
+ throw new WorkflowError("workflow() can nest only one level deep", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
212
+ recoverable: false,
213
+ });
214
+ }
215
+ const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
216
+ const childScript = resolved ?? String(nameOrScript);
217
+ shared.depth++;
218
+ try {
219
+ const child = await runWorkflow(childScript, {
220
+ ...options,
221
+ args: childArgs,
222
+ sharedRuntime: shared,
223
+ // A nested run is its own script; never reuse the parent's resume journal.
224
+ resumeJournal: undefined,
225
+ resumeFromRunId: undefined,
226
+ runId: `${runId}-nested${shared.depth}`,
227
+ persistLogs: false,
228
+ });
229
+ return child.result;
230
+ }
231
+ finally {
232
+ shared.depth--;
233
+ }
234
+ };
201
235
  const context = vm.createContext({
202
236
  agent,
203
237
  parallel,
204
238
  pipeline,
239
+ workflow: workflowFn,
205
240
  log,
206
241
  phase,
207
242
  args: options.args,
@@ -233,16 +268,16 @@ export async function runWorkflow(script, options = {}) {
233
268
  log(`Logs persisted to ${logFile}`);
234
269
  }
235
270
  // Emit final token usage
236
- options.onTokenUsage?.(state.tokenUsage);
271
+ options.onTokenUsage?.(shared.tokenUsage);
237
272
  return {
238
273
  meta,
239
274
  result: result,
240
275
  logs: state.logs,
241
276
  phases: state.phases,
242
- agentCount: state.agentCount,
277
+ agentCount: shared.agentCount,
243
278
  durationMs: Date.now() - started,
244
279
  runId,
245
- tokenUsage: state.tokenUsage,
280
+ tokenUsage: shared.tokenUsage,
246
281
  };
247
282
  }
248
283
  export function parseWorkflowScript(script) {
@@ -1,7 +1,9 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  createWorkflowStorage,
4
4
  createWorkflowTool,
5
+ installResultDelivery,
6
+ installTaskPanel,
5
7
  registerAllSavedWorkflows,
6
8
  registerBuiltinWorkflows,
7
9
  registerWorkflowCommands,
@@ -12,19 +14,23 @@ export default function extension(pi: ExtensionAPI) {
12
14
  // Single manager/storage shared by the workflow tool and the /workflows command,
13
15
  // so background runs started by the tool are reachable from the command.
14
16
  const cwd = process.cwd();
15
- const manager = new WorkflowManager({ cwd });
16
17
  const storage = createWorkflowStorage(cwd);
18
+ const manager = new WorkflowManager({ cwd, loadSavedWorkflow: (name) => storage.load(name)?.script });
17
19
 
18
20
  const workflowTool = createWorkflowTool({ cwd, manager, storage });
19
21
  pi.registerTool(workflowTool);
20
22
  registerWorkflowCommands(pi, manager, { storage, cwd });
21
23
  registerBuiltinWorkflows(pi, { cwd });
22
24
  registerAllSavedWorkflows(pi, cwd, storage);
25
+ // Deliver a background run's result into the conversation when it finishes.
26
+ installResultDelivery(pi, manager);
23
27
 
24
- pi.on("session_start", () => {
28
+ pi.on("session_start", (_event: unknown, ctx: ExtensionContext) => {
25
29
  const active = pi.getActiveTools();
26
30
  if (!active.includes(workflowTool.name)) {
27
31
  pi.setActiveTools([...active, workflowTool.name]);
28
32
  }
33
+ // Live "workflows running" panel below the input (focus + enter to open).
34
+ installTaskPanel(pi, manager, ctx.ui, { storage, cwd });
29
35
  });
30
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quintinshaw/pi-dynamic-workflows",
3
- "version": "1.7.1",
3
+ "version": "1.9.0",
4
4
  "description": "Claude-Code-style dynamic workflow orchestration for Pi.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -45,10 +45,12 @@ export {
45
45
  } from "./saved-commands.js";
46
46
  export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.js";
47
47
  export { createStructuredOutputTool } from "./structured-output.js";
48
+ export { installResultDelivery, installTaskPanel, type TaskPanelOptions } from "./task-panel.js";
48
49
  export { createWebFetchTool, createWebSearchTool, createWebTools } from "./web-tools.js";
49
50
  export type {
50
51
  AgentOptions,
51
52
  JournalEntry,
53
+ SharedRuntime,
52
54
  WorkflowMeta,
53
55
  WorkflowMetaPhase,
54
56
  WorkflowRunOptions,
@@ -62,5 +64,14 @@ export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
62
64
  export { createWorkflowStorage } from "./workflow-saved.js";
63
65
  export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.js";
64
66
  export { createWorkflowTool } from "./workflow-tool.js";
67
+ export {
68
+ keyToAction,
69
+ type NavAction,
70
+ NavigatorModel,
71
+ NavigatorState,
72
+ openWorkflowNavigator,
73
+ renderNavigator,
74
+ type ViewKind,
75
+ } from "./workflow-ui.js";
65
76
  export type { Worktree } from "./worktree.js";
66
77
  export { createWorktree, removeWorktree } from "./worktree.js";