@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.
- package/README.md +6 -8
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/task-panel.d.ts +24 -0
- package/dist/task-panel.js +82 -0
- package/dist/workflow-commands.js +12 -0
- package/dist/workflow-manager.d.ts +3 -0
- package/dist/workflow-manager.js +3 -0
- package/dist/workflow-tool.js +4 -1
- package/dist/workflow-ui.d.ts +110 -0
- package/dist/workflow-ui.js +426 -0
- package/dist/workflow.d.ts +21 -0
- package/dist/workflow.js +54 -19
- package/extensions/workflow.ts +9 -3
- package/package.json +1 -1
- package/src/index.ts +11 -0
- package/src/task-panel.ts +103 -0
- package/src/workflow-commands.ts +12 -0
- package/src/workflow-manager.ts +5 -0
- package/src/workflow-tool.ts +4 -1
- package/src/workflow-ui.ts +496 -0
- package/src/workflow.ts +71 -27
|
@@ -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
|
+
}
|
package/dist/workflow.d.ts
CHANGED
|
@@ -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
|
-
|
|
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: () =>
|
|
52
|
-
remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget -
|
|
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 (
|
|
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
|
-
|
|
83
|
-
const label = requestedLabel || defaultAgentLabel(assignedPhase,
|
|
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
|
-
|
|
90
|
-
const label = requestedLabel || defaultAgentLabel(assignedPhase,
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
shared.tokenUsage.input += usage.input;
|
|
113
|
+
shared.tokenUsage.output += usage.output;
|
|
114
|
+
shared.tokenUsage.cost += usage.cost;
|
|
110
115
|
}
|
|
111
|
-
|
|
112
|
-
|
|
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?.(
|
|
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:
|
|
277
|
+
agentCount: shared.agentCount,
|
|
243
278
|
durationMs: Date.now() - started,
|
|
244
279
|
runId,
|
|
245
|
-
tokenUsage:
|
|
280
|
+
tokenUsage: shared.tokenUsage,
|
|
246
281
|
};
|
|
247
282
|
}
|
|
248
283
|
export function parseWorkflowScript(script) {
|
package/extensions/workflow.ts
CHANGED
|
@@ -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
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";
|