@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.
@@ -0,0 +1,228 @@
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
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
19
+ // A trigger is `workflow`/`workflows` (substring, case-insensitive) that is NOT
20
+ // immediately preceded by `/` — so a slash command like `/workflows` or `/workflow`
21
+ // is left alone (not colored, not armed).
22
+ /** Matches a trigger anywhere in the text. */
23
+ const TRIGGER = /(?<!\/)workflows?/i;
24
+ /** Global variant for finding every occurrence to colorize. */
25
+ const TRIGGER_G = /(?<!\/)workflows?/gi;
26
+ /** True when the text immediately before the cursor ends with a trigger word. */
27
+ const TRIGGER_AT_END = /(?<!\/)workflows?$/i;
28
+ /** 256-color ring cycling through the spectrum — shifted by a tick to "flow". */
29
+ export const RAINBOW = [
30
+ 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,
31
+ 93, 129, 165, 201, 198, 197,
32
+ ];
33
+ export function hasTrigger(text) {
34
+ return TRIGGER.test(text);
35
+ }
36
+ export function endsWithTrigger(textBeforeCursor) {
37
+ return TRIGGER_AT_END.test(textBeforeCursor);
38
+ }
39
+ /**
40
+ * Split a rendered line into ANSI-escape tokens (passed through verbatim) and
41
+ * single visible-character tokens. Handles CSI sequences (`\x1b[…m`, e.g. the
42
+ * cursor's inverse-video) and APC/OSC string sequences (e.g. the zero-width
43
+ * `CURSOR_MARKER` = `\x1b_pi:c\x07`) so colorization never corrupts them.
44
+ */
45
+ export function tokenizeAnsi(line) {
46
+ const tokens = [];
47
+ let i = 0;
48
+ while (i < line.length) {
49
+ if (line[i] === "\x1b") {
50
+ let j = i + 1;
51
+ const next = line[j];
52
+ if (next === "[") {
53
+ // CSI: ends at a final byte in 0x40–0x7e.
54
+ j++;
55
+ while (j < line.length && !(line[j] >= "@" && line[j] <= "~"))
56
+ j++;
57
+ j++;
58
+ }
59
+ else if (next === "]" || next === "_" || next === "P" || next === "^") {
60
+ // String sequence: ends at BEL (\x07) or ST (\x1b\\).
61
+ j++;
62
+ while (j < line.length && line[j] !== "\x07" && !(line[j] === "\x1b" && line[j + 1] === "\\"))
63
+ j++;
64
+ if (line[j] === "\x07")
65
+ j++;
66
+ else if (line[j] === "\x1b")
67
+ j += 2;
68
+ }
69
+ else {
70
+ j++; // lone ESC + one byte
71
+ }
72
+ tokens.push({ esc: line.slice(i, j) });
73
+ i = j;
74
+ }
75
+ else {
76
+ tokens.push({ ch: line[i] });
77
+ i++;
78
+ }
79
+ }
80
+ return tokens;
81
+ }
82
+ /**
83
+ * Colorize every `workflow`/`workflows` occurrence in a rendered line with a
84
+ * flowing rainbow, leaving all ANSI escapes (cursor, markers) intact. Returns the
85
+ * line unchanged when it contains no trigger.
86
+ */
87
+ export function colorizeWorkflow(line, tick, palette = RAINBOW) {
88
+ const tokens = tokenizeAnsi(line);
89
+ const visible = tokens
90
+ .filter((t) => t.ch !== undefined)
91
+ .map((t) => t.ch)
92
+ .join("");
93
+ if (!TRIGGER.test(visible))
94
+ return line;
95
+ const ranges = [];
96
+ TRIGGER_G.lastIndex = 0;
97
+ for (let m = TRIGGER_G.exec(visible); m; m = TRIGGER_G.exec(visible)) {
98
+ ranges.push([m.index, m.index + m[0].length]);
99
+ }
100
+ const inRange = (idx) => ranges.some(([s, e]) => idx >= s && idx < e);
101
+ let out = "";
102
+ let vi = 0;
103
+ for (const t of tokens) {
104
+ if (t.esc !== undefined) {
105
+ out += t.esc;
106
+ continue;
107
+ }
108
+ if (inRange(vi)) {
109
+ const color = palette[(vi + tick) % palette.length];
110
+ // Reset only the foreground (39) afterwards so a surrounding inverse-video
111
+ // (the cursor) is preserved.
112
+ out += `\x1b[38;5;${color}m${t.ch}\x1b[39m`;
113
+ }
114
+ else {
115
+ out += t.ch ?? "";
116
+ }
117
+ vi++;
118
+ }
119
+ return out;
120
+ }
121
+ /** Backspace arrives as DEL (0x7f) or BS (0x08) depending on the terminal. */
122
+ function isBackspace(data) {
123
+ return data === "\x7f" || data === "\b";
124
+ }
125
+ /**
126
+ * Editor that paints the trigger words and owns the on/off toggle. Reads/writes
127
+ * `state.active` so the extension's `input` handler can decide whether to force a
128
+ * workflow at submit time.
129
+ */
130
+ export class WorkflowEditor extends CustomEditor {
131
+ modeState;
132
+ tick = 0;
133
+ timer;
134
+ /** Toggled off by Backspace-after-word; re-armed when a fresh trigger appears. */
135
+ disabled = false;
136
+ wasTriggered = false;
137
+ constructor(tui, theme, keybindings, modeState) {
138
+ super(tui, theme, keybindings);
139
+ this.modeState = modeState;
140
+ }
141
+ /** Highlighted/armed: a trigger is present and the user hasn't toggled it off. */
142
+ isActive() {
143
+ return !this.disabled && hasTrigger(this.getText());
144
+ }
145
+ handleInput(data) {
146
+ // First Backspace right after a trigger word disarms (non-destructive).
147
+ if (isBackspace(data) && this.isActive() && this.cursorAfterTrigger()) {
148
+ this.disabled = true;
149
+ this.syncState();
150
+ this.tui.requestRender();
151
+ return;
152
+ }
153
+ const before = this.getText();
154
+ super.handleInput(data);
155
+ const after = this.getText();
156
+ if (after !== before) {
157
+ const now = hasTrigger(after);
158
+ // A freshly typed trigger re-arms a previously disabled box.
159
+ if (now && !this.wasTriggered)
160
+ this.disabled = false;
161
+ this.wasTriggered = now;
162
+ }
163
+ this.syncState();
164
+ }
165
+ render(width) {
166
+ const lines = super.render(width);
167
+ // Keep the shared state current even for non-keystroke changes (history
168
+ // recall, programmatic setText) so the submit hook reads the right value.
169
+ this.syncState();
170
+ this.reconcileAnimation();
171
+ if (!this.isActive() || lines.length === 0)
172
+ return lines;
173
+ // First and last lines are the editor's horizontal borders; only the text
174
+ // lines in between are colorized.
175
+ return lines.map((ln, i) => (i === 0 || i === lines.length - 1 ? ln : colorizeWorkflow(ln, this.tick)));
176
+ }
177
+ /** Absolute text before the cursor, used to detect "right after the word". */
178
+ cursorAfterTrigger() {
179
+ const lines = this.getLines();
180
+ const { line, col } = this.getCursor();
181
+ const before = lines.slice(0, line).join("\n") + (line > 0 ? "\n" : "") + (lines[line] ?? "").slice(0, col);
182
+ return endsWithTrigger(before);
183
+ }
184
+ syncState() {
185
+ this.modeState.active = this.isActive();
186
+ }
187
+ reconcileAnimation() {
188
+ const shouldRun = this.isActive() && this.focused;
189
+ if (shouldRun && !this.timer) {
190
+ this.timer = setInterval(() => {
191
+ this.tick = (this.tick + 1) % (RAINBOW.length * 6);
192
+ this.tui.requestRender();
193
+ }, 90);
194
+ // Don't keep the process alive for the animation.
195
+ this.timer.unref?.();
196
+ }
197
+ else if (!shouldRun && this.timer) {
198
+ clearInterval(this.timer);
199
+ this.timer = undefined;
200
+ }
201
+ }
202
+ }
203
+ /** The directive appended to a submitted message when workflows mode is armed. */
204
+ export function buildForcedWorkflowPrompt(text) {
205
+ return [
206
+ text,
207
+ "",
208
+ "[workflows mode] The user armed workflows mode for this message. Handle it by",
209
+ "running the `workflow` tool: decompose the request and orchestrate subagents",
210
+ "with a workflow script, rather than answering directly.",
211
+ ].join("\n");
212
+ }
213
+ /**
214
+ * Install the workflows-mode editor and the submit-time forcing hook.
215
+ * Call once with the UI context (e.g. in `session_start`).
216
+ */
217
+ export function installWorkflowEditor(pi, ui) {
218
+ const state = { active: false };
219
+ ui.setEditorComponent((tui, theme, keybindings) => new WorkflowEditor(tui, theme, keybindings, state));
220
+ // When armed at submit time, rewrite the user's message to force a workflow.
221
+ pi.on("input", (event) => {
222
+ if (event.source !== "interactive" || !state.active || !event.text)
223
+ return { action: "continue" };
224
+ state.active = false; // consume the arm for this submission
225
+ return { action: "transform", text: buildForcedWorkflowPrompt(event.text) };
226
+ });
227
+ return state;
228
+ }
@@ -2,7 +2,8 @@
2
2
  * Workflow manager for background execution, pause/resume, and run management.
3
3
  */
4
4
  import { EventEmitter } from "node:events";
5
- import type { WorkflowSnapshot } from "./display.js";
5
+ import type { WorkflowAgent } from "./agent.js";
6
+ import { type WorkflowSnapshot } from "./display.js";
6
7
  import { WorkflowError } from "./errors.js";
7
8
  import { type PersistedRunState, type RunPersistence, type RunStatus } from "./run-persistence.js";
8
9
  import { type JournalEntry, type WorkflowRunResult } from "./workflow.js";
@@ -19,12 +20,36 @@ export interface ManagedRun {
19
20
  args?: unknown;
20
21
  /** Accumulated agent results for resume (deterministic call index -> result). */
21
22
  journal: JournalEntry[];
23
+ /**
24
+ * True when the run was started in the background (or resumed) and the caller is
25
+ * not awaiting its result inline. Only background runs deliver their result back
26
+ * into the conversation; a foreground sync run already returns it as the tool
27
+ * result, so re-delivering would duplicate it.
28
+ */
29
+ background: boolean;
30
+ }
31
+ /** Per-execution options shared by sync, background, and resume runs. */
32
+ export interface ExecOptions {
33
+ /** Replay these journaled agent results for the unchanged prefix (resume). */
34
+ resumeJournal?: Map<number, JournalEntry>;
35
+ /** Cap on total agents for this run. */
36
+ maxAgents?: number;
37
+ /** Per-agent timeout in milliseconds. */
38
+ agentTimeoutMs?: number;
39
+ /** Host signal (e.g. tool/Esc) that should abort this run when fired. */
40
+ externalSignal?: AbortSignal;
41
+ /** Called with the live snapshot on every progress event. */
42
+ onProgress?: (snapshot: WorkflowSnapshot) => void;
22
43
  }
23
44
  export interface WorkflowManagerOptions {
24
45
  cwd?: string;
25
46
  concurrency?: number;
26
47
  /** Resolve a saved-workflow name to its script, enabling nested `workflow('name')`. */
27
48
  loadSavedWorkflow?: (name: string) => string | undefined;
49
+ /** Inject a custom agent runner (tests); defaults to a real subagent session. */
50
+ agent?: Pick<WorkflowAgent, "run">;
51
+ /** The session's main model (provider/id), for auto-tiering explore agents. */
52
+ mainModel?: string;
28
53
  }
29
54
  export declare class WorkflowManager extends EventEmitter {
30
55
  private runs;
@@ -32,7 +57,12 @@ export declare class WorkflowManager extends EventEmitter {
32
57
  private cwd;
33
58
  private concurrency;
34
59
  private loadSavedWorkflow?;
60
+ private agent?;
61
+ /** The session's main model (provider/id), for auto-tiering explore agents. */
62
+ private mainModel?;
35
63
  constructor(options?: WorkflowManagerOptions);
64
+ /** Set the session's main model (provider/id). Used to auto-tier explore agents. */
65
+ setMainModel(spec: string | undefined): void;
36
66
  /**
37
67
  * Start a workflow in the background.
38
68
  * Returns immediately with a run ID; the workflow executes asynchronously.
@@ -42,9 +72,14 @@ export declare class WorkflowManager extends EventEmitter {
42
72
  promise: Promise<WorkflowRunResult>;
43
73
  };
44
74
  /**
45
- * Execute a workflow synchronously (blocking).
75
+ * Execute a workflow synchronously (blocking) while still tracking it like a
76
+ * background run, so the `/workflows` navigator and the live task panel see it.
77
+ * `onProgress` fires on every progress event with the current snapshot, letting
78
+ * a caller (e.g. the workflow tool) drive its own inline display.
46
79
  */
47
- runSync(script: string, args?: unknown): Promise<WorkflowRunResult>;
80
+ runSync(script: string, args?: unknown, exec?: ExecOptions): Promise<WorkflowRunResult>;
81
+ /** Build a fresh managed run with an empty snapshot. */
82
+ private createManaged;
48
83
  private executeRun;
49
84
  private persistRun;
50
85
  /**
@@ -2,6 +2,7 @@
2
2
  * Workflow manager for background execution, pause/resume, and run management.
3
3
  */
4
4
  import { EventEmitter } from "node:events";
5
+ import { preview } from "./display.js";
5
6
  import { WorkflowError, WorkflowErrorCode } from "./errors.js";
6
7
  import { createRunPersistence, generateRunId, } from "./run-persistence.js";
7
8
  import { parseWorkflowScript, runWorkflow } from "./workflow.js";
@@ -11,13 +12,22 @@ export class WorkflowManager extends EventEmitter {
11
12
  cwd;
12
13
  concurrency;
13
14
  loadSavedWorkflow;
15
+ agent;
16
+ /** The session's main model (provider/id), for auto-tiering explore agents. */
17
+ mainModel;
14
18
  constructor(options = {}) {
15
19
  super();
16
20
  this.cwd = options.cwd ?? process.cwd();
17
21
  this.concurrency = options.concurrency ?? 8;
18
22
  this.loadSavedWorkflow = options.loadSavedWorkflow;
23
+ this.agent = options.agent;
24
+ this.mainModel = options.mainModel;
19
25
  this.persistence = createRunPersistence(this.cwd);
20
26
  }
27
+ /** Set the session's main model (provider/id). Used to auto-tier explore agents. */
28
+ setMainModel(spec) {
29
+ this.mainModel = spec;
30
+ }
21
31
  /**
22
32
  * Start a workflow in the background.
23
33
  * Returns immediately with a run ID; the workflow executes asynchronously.
@@ -45,6 +55,7 @@ export class WorkflowManager extends EventEmitter {
45
55
  script,
46
56
  args,
47
57
  journal: [],
58
+ background: true,
48
59
  };
49
60
  this.runs.set(runId, managed);
50
61
  // Persist initial state
@@ -65,14 +76,24 @@ export class WorkflowManager extends EventEmitter {
65
76
  return { runId, promise };
66
77
  }
67
78
  /**
68
- * Execute a workflow synchronously (blocking).
79
+ * Execute a workflow synchronously (blocking) while still tracking it like a
80
+ * background run, so the `/workflows` navigator and the live task panel see it.
81
+ * `onProgress` fires on every progress event with the current snapshot, letting
82
+ * a caller (e.g. the workflow tool) drive its own inline display.
69
83
  */
70
- async runSync(script, args) {
71
- const runId = generateRunId();
72
- const controller = new AbortController();
84
+ async runSync(script, args, exec = {}) {
85
+ const managed = this.createManaged(script, args);
86
+ this.runs.set(managed.runId, managed);
87
+ // Persist the initial state immediately so listRuns()/the task panel can see
88
+ // the run the moment it starts, not only after the first agent journals.
89
+ this.persistRun(managed);
90
+ return this.executeRun(managed, script, args, exec);
91
+ }
92
+ /** Build a fresh managed run with an empty snapshot. */
93
+ createManaged(script, args) {
73
94
  const parsed = parseWorkflowScript(script);
74
- const managed = {
75
- runId,
95
+ return {
96
+ runId: generateRunId(),
76
97
  status: "running",
77
98
  snapshot: {
78
99
  name: parsed.meta.name,
@@ -85,22 +106,34 @@ export class WorkflowManager extends EventEmitter {
85
106
  doneCount: 0,
86
107
  errorCount: 0,
87
108
  },
88
- controller,
109
+ controller: new AbortController(),
89
110
  startedAt: new Date(),
90
111
  script,
91
112
  args,
92
113
  journal: [],
114
+ background: false,
93
115
  };
94
- this.runs.set(runId, managed);
95
- return this.executeRun(managed, script, args);
96
116
  }
97
- async executeRun(managed, script, args, resumeJournal) {
117
+ async executeRun(managed, script, args, exec = {}) {
118
+ const { resumeJournal, maxAgents, agentTimeoutMs, externalSignal, onProgress } = exec;
119
+ const progress = () => onProgress?.(managed.snapshot);
120
+ // Let a host abort (e.g. Esc during a blocking tool call) cancel this run.
121
+ if (externalSignal) {
122
+ if (externalSignal.aborted)
123
+ managed.controller.abort();
124
+ else
125
+ externalSignal.addEventListener("abort", () => managed.controller.abort(), { once: true });
126
+ }
98
127
  try {
99
128
  const result = await runWorkflow(script, {
100
129
  cwd: this.cwd,
101
130
  args,
131
+ agent: this.agent,
132
+ mainModel: this.mainModel,
102
133
  signal: managed.controller.signal,
103
134
  concurrency: this.concurrency,
135
+ maxAgents,
136
+ agentTimeoutMs,
104
137
  loadSavedWorkflow: this.loadSavedWorkflow,
105
138
  resumeJournal,
106
139
  resumeFromRunId: resumeJournal ? managed.runId : undefined,
@@ -113,6 +146,7 @@ export class WorkflowManager extends EventEmitter {
113
146
  onLog: (message) => {
114
147
  managed.snapshot.logs.push(message);
115
148
  this.emit("log", { runId: managed.runId, message });
149
+ progress();
116
150
  },
117
151
  onPhase: (title) => {
118
152
  managed.snapshot.currentPhase = title;
@@ -120,6 +154,7 @@ export class WorkflowManager extends EventEmitter {
120
154
  managed.snapshot.phases.push(title);
121
155
  }
122
156
  this.emit("phase", { runId: managed.runId, title });
157
+ progress();
123
158
  },
124
159
  onAgentStart: (event) => {
125
160
  managed.snapshot.agents.push({
@@ -128,8 +163,10 @@ export class WorkflowManager extends EventEmitter {
128
163
  phase: event.phase,
129
164
  prompt: event.prompt,
130
165
  status: "running",
166
+ model: event.model,
131
167
  });
132
168
  this.emit("agentStart", { runId: managed.runId, ...event });
169
+ progress();
133
170
  },
134
171
  onAgentEnd: (event) => {
135
172
  const agent = [...managed.snapshot.agents]
@@ -137,8 +174,18 @@ export class WorkflowManager extends EventEmitter {
137
174
  .find((a) => a.label === event.label && a.status === "running");
138
175
  if (agent) {
139
176
  agent.status = event.result === null ? "error" : "done";
177
+ agent.resultPreview = preview(event.result);
178
+ agent.tokens = event.tokens;
179
+ if (event.model)
180
+ agent.model = event.model;
140
181
  }
141
182
  this.emit("agentEnd", { runId: managed.runId, ...event });
183
+ progress();
184
+ },
185
+ onTokenUsage: (usage) => {
186
+ managed.snapshot.tokenUsage = usage;
187
+ this.emit("tokenUsage", { runId: managed.runId, usage });
188
+ progress();
142
189
  },
143
190
  });
144
191
  managed.status = "completed";
@@ -184,6 +231,13 @@ export class WorkflowManager extends EventEmitter {
184
231
  })),
185
232
  logs: managed.snapshot.logs,
186
233
  result: managed.result?.result,
234
+ tokenUsage: managed.snapshot.tokenUsage
235
+ ? {
236
+ input: managed.snapshot.tokenUsage.input,
237
+ output: managed.snapshot.tokenUsage.output,
238
+ total: managed.snapshot.tokenUsage.total,
239
+ }
240
+ : undefined,
187
241
  startedAt: managed.startedAt.toISOString(),
188
242
  updatedAt: new Date().toISOString(),
189
243
  completedAt: managed.status === "completed" ? new Date().toISOString() : undefined,
@@ -233,12 +287,13 @@ export class WorkflowManager extends EventEmitter {
233
287
  script: persisted.script,
234
288
  args: persisted.args,
235
289
  journal: persisted.journal ?? [],
290
+ background: true,
236
291
  };
237
292
  this.runs.set(runId, managed);
238
293
  const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e]));
239
294
  this.emit("resumed", { runId });
240
295
  // Run in the background; executeRun records status/errors on the managed run.
241
- void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => { });
296
+ void this.executeRun(managed, persisted.script, persisted.args, { resumeJournal }).catch(() => { });
242
297
  return true;
243
298
  }
244
299
  /**
@@ -25,4 +25,11 @@ export interface WorkflowToolOptions {
25
25
  storage?: WorkflowStorage;
26
26
  }
27
27
  export declare function createWorkflowTool(options?: WorkflowToolOptions): ToolDefinition<typeof workflowToolSchema, any>;
28
+ /**
29
+ * The tool result returned when a workflow starts in the background. It both
30
+ * informs the model and tells it to reassure the user: the run continues on its
31
+ * own and the conversation will resume automatically when it finishes, so the
32
+ * user can just wait here (or go do something else).
33
+ */
34
+ export declare function backgroundStartedText(name: string, runId: string): string;
28
35
  export {};