@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quintinshaw/pi-dynamic-workflows",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
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",
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);
@@ -0,0 +1,252 @@
1
+ /**
2
+ * "Workflows mode" input affordance, à la a smart input box:
3
+ *
4
+ * - While the editor text contains the word `workflow`/`workflows`, those letters
5
+ * render as a flowing rainbow, signalling that submitting will engage a workflow.
6
+ * - Pressing Backspace immediately after such a word toggles the highlight OFF
7
+ * (the word stays, but turns plain white) — a non-destructive "don't run a
8
+ * workflow after all". Re-typing a fresh trigger word turns it back on.
9
+ * - When the highlight is ON at submit time, the user's message is transformed to
10
+ * instruct Pi to actually run the workflow tool.
11
+ *
12
+ * Implementation: we replace the core editor with a thin subclass of the exported
13
+ * `CustomEditor` (which itself extends pi-tui's `Editor`), overriding only
14
+ * `render()` (to colorize) and `handleInput()` (for the Backspace toggle). All
15
+ * other editor behavior — history, autocomplete, paste, undo, multiline — is
16
+ * inherited untouched.
17
+ */
18
+
19
+ import { CustomEditor, type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
20
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
21
+
22
+ // A trigger is `workflow`/`workflows` (substring, case-insensitive) that is NOT
23
+ // immediately preceded by `/` — so a slash command like `/workflows` or `/workflow`
24
+ // is left alone (not colored, not armed).
25
+ /** Matches a trigger anywhere in the text. */
26
+ const TRIGGER = /(?<!\/)workflows?/i;
27
+ /** Global variant for finding every occurrence to colorize. */
28
+ const TRIGGER_G = /(?<!\/)workflows?/gi;
29
+ /** True when the text immediately before the cursor ends with a trigger word. */
30
+ const TRIGGER_AT_END = /(?<!\/)workflows?$/i;
31
+
32
+ /** 256-color ring cycling through the spectrum — shifted by a tick to "flow". */
33
+ export const RAINBOW = [
34
+ 196, 160, 202, 166, 208, 172, 214, 178, 220, 184, 226, 190, 118, 82, 46, 47, 48, 49, 50, 51, 45, 39, 33, 27, 21, 57,
35
+ 93, 129, 165, 201, 198, 197,
36
+ ];
37
+
38
+ export function hasTrigger(text: string): boolean {
39
+ return TRIGGER.test(text);
40
+ }
41
+
42
+ export function endsWithTrigger(textBeforeCursor: string): boolean {
43
+ return TRIGGER_AT_END.test(textBeforeCursor);
44
+ }
45
+
46
+ /** Shared, mutable view of whether "workflows mode" is currently armed. */
47
+ export interface WorkflowModeState {
48
+ active: boolean;
49
+ }
50
+
51
+ interface AnsiToken {
52
+ esc?: string;
53
+ ch?: string;
54
+ }
55
+
56
+ /**
57
+ * Split a rendered line into ANSI-escape tokens (passed through verbatim) and
58
+ * single visible-character tokens. Handles CSI sequences (`\x1b[…m`, e.g. the
59
+ * cursor's inverse-video) and APC/OSC string sequences (e.g. the zero-width
60
+ * `CURSOR_MARKER` = `\x1b_pi:c\x07`) so colorization never corrupts them.
61
+ */
62
+ export function tokenizeAnsi(line: string): AnsiToken[] {
63
+ const tokens: AnsiToken[] = [];
64
+ let i = 0;
65
+ while (i < line.length) {
66
+ if (line[i] === "\x1b") {
67
+ let j = i + 1;
68
+ const next = line[j];
69
+ if (next === "[") {
70
+ // CSI: ends at a final byte in 0x40–0x7e.
71
+ j++;
72
+ while (j < line.length && !(line[j] >= "@" && line[j] <= "~")) j++;
73
+ j++;
74
+ } else if (next === "]" || next === "_" || next === "P" || next === "^") {
75
+ // String sequence: ends at BEL (\x07) or ST (\x1b\\).
76
+ j++;
77
+ while (j < line.length && line[j] !== "\x07" && !(line[j] === "\x1b" && line[j + 1] === "\\")) j++;
78
+ if (line[j] === "\x07") j++;
79
+ else if (line[j] === "\x1b") j += 2;
80
+ } else {
81
+ j++; // lone ESC + one byte
82
+ }
83
+ tokens.push({ esc: line.slice(i, j) });
84
+ i = j;
85
+ } else {
86
+ tokens.push({ ch: line[i] });
87
+ i++;
88
+ }
89
+ }
90
+ return tokens;
91
+ }
92
+
93
+ /**
94
+ * Colorize every `workflow`/`workflows` occurrence in a rendered line with a
95
+ * flowing rainbow, leaving all ANSI escapes (cursor, markers) intact. Returns the
96
+ * line unchanged when it contains no trigger.
97
+ */
98
+ export function colorizeWorkflow(line: string, tick: number, palette: number[] = RAINBOW): string {
99
+ const tokens = tokenizeAnsi(line);
100
+ const visible = tokens
101
+ .filter((t) => t.ch !== undefined)
102
+ .map((t) => t.ch)
103
+ .join("");
104
+ if (!TRIGGER.test(visible)) return line;
105
+
106
+ const ranges: Array<[number, number]> = [];
107
+ TRIGGER_G.lastIndex = 0;
108
+ for (let m = TRIGGER_G.exec(visible); m; m = TRIGGER_G.exec(visible)) {
109
+ ranges.push([m.index, m.index + m[0].length]);
110
+ }
111
+ const inRange = (idx: number) => ranges.some(([s, e]) => idx >= s && idx < e);
112
+
113
+ let out = "";
114
+ let vi = 0;
115
+ for (const t of tokens) {
116
+ if (t.esc !== undefined) {
117
+ out += t.esc;
118
+ continue;
119
+ }
120
+ if (inRange(vi)) {
121
+ const color = palette[(vi + tick) % palette.length];
122
+ // Reset only the foreground (39) afterwards so a surrounding inverse-video
123
+ // (the cursor) is preserved.
124
+ out += `\x1b[38;5;${color}m${t.ch}\x1b[39m`;
125
+ } else {
126
+ out += t.ch ?? "";
127
+ }
128
+ vi++;
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** Backspace arrives as DEL (0x7f) or BS (0x08) depending on the terminal. */
134
+ function isBackspace(data: string): boolean {
135
+ return data === "\x7f" || data === "\b";
136
+ }
137
+
138
+ /**
139
+ * Editor that paints the trigger words and owns the on/off toggle. Reads/writes
140
+ * `state.active` so the extension's `input` handler can decide whether to force a
141
+ * workflow at submit time.
142
+ */
143
+ export class WorkflowEditor extends CustomEditor {
144
+ private tick = 0;
145
+ private timer?: ReturnType<typeof setInterval>;
146
+ /** Toggled off by Backspace-after-word; re-armed when a fresh trigger appears. */
147
+ private disabled = false;
148
+ private wasTriggered = false;
149
+
150
+ constructor(
151
+ tui: TUI,
152
+ theme: EditorTheme,
153
+ keybindings: ConstructorParameters<typeof CustomEditor>[2],
154
+ private readonly modeState: WorkflowModeState,
155
+ ) {
156
+ super(tui, theme, keybindings);
157
+ }
158
+
159
+ /** Highlighted/armed: a trigger is present and the user hasn't toggled it off. */
160
+ isActive(): boolean {
161
+ return !this.disabled && hasTrigger(this.getText());
162
+ }
163
+
164
+ override handleInput(data: string): void {
165
+ // First Backspace right after a trigger word disarms (non-destructive).
166
+ if (isBackspace(data) && this.isActive() && this.cursorAfterTrigger()) {
167
+ this.disabled = true;
168
+ this.syncState();
169
+ this.tui.requestRender();
170
+ return;
171
+ }
172
+ const before = this.getText();
173
+ super.handleInput(data);
174
+ const after = this.getText();
175
+ if (after !== before) {
176
+ const now = hasTrigger(after);
177
+ // A freshly typed trigger re-arms a previously disabled box.
178
+ if (now && !this.wasTriggered) this.disabled = false;
179
+ this.wasTriggered = now;
180
+ }
181
+ this.syncState();
182
+ }
183
+
184
+ override render(width: number): string[] {
185
+ const lines = super.render(width);
186
+ // Keep the shared state current even for non-keystroke changes (history
187
+ // recall, programmatic setText) so the submit hook reads the right value.
188
+ this.syncState();
189
+ this.reconcileAnimation();
190
+ if (!this.isActive() || lines.length === 0) return lines;
191
+ // First and last lines are the editor's horizontal borders; only the text
192
+ // lines in between are colorized.
193
+ return lines.map((ln, i) => (i === 0 || i === lines.length - 1 ? ln : colorizeWorkflow(ln, this.tick)));
194
+ }
195
+
196
+ /** Absolute text before the cursor, used to detect "right after the word". */
197
+ private cursorAfterTrigger(): boolean {
198
+ const lines = this.getLines();
199
+ const { line, col } = this.getCursor();
200
+ const before = lines.slice(0, line).join("\n") + (line > 0 ? "\n" : "") + (lines[line] ?? "").slice(0, col);
201
+ return endsWithTrigger(before);
202
+ }
203
+
204
+ private syncState(): void {
205
+ this.modeState.active = this.isActive();
206
+ }
207
+
208
+ private reconcileAnimation(): void {
209
+ const shouldRun = this.isActive() && this.focused;
210
+ if (shouldRun && !this.timer) {
211
+ this.timer = setInterval(() => {
212
+ this.tick = (this.tick + 1) % (RAINBOW.length * 6);
213
+ this.tui.requestRender();
214
+ }, 90);
215
+ // Don't keep the process alive for the animation.
216
+ (this.timer as { unref?: () => void }).unref?.();
217
+ } else if (!shouldRun && this.timer) {
218
+ clearInterval(this.timer);
219
+ this.timer = undefined;
220
+ }
221
+ }
222
+ }
223
+
224
+ /** The directive appended to a submitted message when workflows mode is armed. */
225
+ export function buildForcedWorkflowPrompt(text: string): string {
226
+ return [
227
+ text,
228
+ "",
229
+ "[workflows mode] The user armed workflows mode for this message. Handle it by",
230
+ "running the `workflow` tool: decompose the request and orchestrate subagents",
231
+ "with a workflow script, rather than answering directly.",
232
+ ].join("\n");
233
+ }
234
+
235
+ /**
236
+ * Install the workflows-mode editor and the submit-time forcing hook.
237
+ * Call once with the UI context (e.g. in `session_start`).
238
+ */
239
+ export function installWorkflowEditor(pi: ExtensionAPI, ui: ExtensionUIContext): WorkflowModeState {
240
+ const state: WorkflowModeState = { active: false };
241
+
242
+ ui.setEditorComponent((tui, theme, keybindings) => new WorkflowEditor(tui, theme, keybindings, state));
243
+
244
+ // When armed at submit time, rewrite the user's message to force a workflow.
245
+ pi.on("input", (event: { source?: string; text?: string }) => {
246
+ if (event.source !== "interactive" || !state.active || !event.text) return { action: "continue" } as const;
247
+ state.active = false; // consume the arm for this submission
248
+ return { action: "transform", text: buildForcedWorkflowPrompt(event.text) } as const;
249
+ });
250
+
251
+ return state;
252
+ }