pi-workflow-engine 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-workflow-engine",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "A customisable multi-agent workflow-orchestration engine for the pi coding agent.",
6
6
  "keywords": ["pi-package"],
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "scripts": {
18
18
  "typecheck": "tsc --noEmit",
19
+ "test:ui": "bun scripts/test-workflow-ui.ts",
19
20
  "prepublishOnly": "tsc --noEmit"
20
21
  },
21
22
  "peerDependencies": {
@@ -49,11 +49,13 @@ function lastAssistantText(state: AgentState): string {
49
49
  export async function runAgent(rc: RunContext, prompt: string, opts: AgentOptions = {}): Promise<unknown> {
50
50
  const label = opts.label ?? "agent";
51
51
 
52
- // Acquire a global concurrency slot before spawning the session.
52
+ // Track queued agents before acquiring a global concurrency slot.
53
+ const rowId = rc.progress.agentQueued(opts.phase, label);
53
54
  return rc.semaphore.run(async () => {
54
- rc.progress.agentStart(opts.phase, label);
55
+ rc.progress.agentStart(opts.phase, label, rowId);
55
56
 
56
57
  let captured: unknown = null;
58
+ let failed = false;
57
59
  const customTools: ToolDefinition[] = opts.schema
58
60
  ? [
59
61
  defineTool({
@@ -92,7 +94,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
92
94
 
93
95
  const unsubscribe = session.subscribe((event) => {
94
96
  if (event.type === "tool_execution_start" && event.toolName !== FINAL_TOOL) {
95
- rc.progress.agentTool(label, event.toolName);
97
+ rc.progress.agentTool(label, event.toolName, rowId);
96
98
  }
97
99
  });
98
100
 
@@ -107,12 +109,14 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
107
109
  }
108
110
  return lastAssistantText(session.state);
109
111
  } catch (error) {
112
+ failed = true;
113
+ rc.progress.agentFailed(label, error, rowId);
110
114
  rc.progress.log(`${label} failed: ${error instanceof Error ? error.message : String(error)}`);
111
115
  throw error;
112
116
  } finally {
113
117
  unsubscribe();
114
118
  session.dispose();
115
- rc.progress.agentDone(label);
119
+ if (!failed) rc.progress.agentDone(label, rowId);
116
120
  }
117
121
  });
118
122
  }
package/src/engine.ts CHANGED
@@ -3,7 +3,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { parallel, pipeline, Semaphore } from "./concurrency.ts";
4
4
  import { runAgent, type RunContext } from "./agent-runner.ts";
5
5
  import { ProgressTracker } from "./progress.ts";
6
- import type { AgentOptions, WorkflowApi, WorkflowModule } from "./types.ts";
6
+ import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowRunOptions } from "./types.ts";
7
+ import { WorkflowInspector } from "./ui/workflow-inspector.ts";
7
8
 
8
9
  /** Default global cap on concurrent agents per run. */
9
10
  const DEFAULT_CONCURRENCY = Math.min(8, Math.max(2, cpus().length));
@@ -12,8 +13,22 @@ const DEFAULT_CONCURRENCY = Math.min(8, Math.max(2, cpus().length));
12
13
  * Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
13
14
  * to a shared semaphore + progress tracker), invoke the workflow, return its result.
14
15
  */
15
- export async function runWorkflow(ctx: ExtensionContext, mod: WorkflowModule, args: string): Promise<unknown> {
16
+ export async function runWorkflow(
17
+ ctx: ExtensionContext,
18
+ mod: WorkflowModule,
19
+ args: string,
20
+ options: WorkflowRunOptions = {},
21
+ ): Promise<unknown> {
16
22
  const progress = new ProgressTracker(ctx, mod.meta.name);
23
+ if (options.inspect && ctx.hasUI) {
24
+ void ctx.ui
25
+ .custom<void>(
26
+ (tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
27
+ { overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
28
+ )
29
+ .catch((error: unknown) => progress.log(`inspector failed: ${error instanceof Error ? error.message : String(error)}`));
30
+ }
31
+
17
32
  const rc: RunContext = {
18
33
  cwd: ctx.cwd,
19
34
  hostModel: ctx.model,
@@ -31,7 +46,9 @@ export async function runWorkflow(ctx: ExtensionContext, mod: WorkflowModule, ar
31
46
  pipeline,
32
47
  phase: (title) => progress.phase(title),
33
48
  log: (message) => progress.log(message),
49
+ progress: (event) => progress.event(event),
34
50
  args,
51
+ cwd: ctx.cwd,
35
52
  signal: ctx.signal,
36
53
  };
37
54
 
package/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { Type } from "typebox";
3
3
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import { Text } from "@earendil-works/pi-tui";
4
5
  import { discoverWorkflows } from "./discovery.ts";
5
6
  import { runWorkflow } from "./engine.ts";
7
+ import type { WorkflowModule, WorkflowRunOptions } from "./types.ts";
8
+ import { isWorkflowResult, renderWorkflowResult, type WorkflowResultEnvelope } from "./ui/workflow-result-renderer.ts";
6
9
 
7
10
  /** Repo root (this file lives in <repo>/src/index.ts). */
8
11
  const REPO_DIR = fileURLToPath(new URL("..", import.meta.url));
@@ -18,34 +21,84 @@ function formatReport(name: string, result: unknown): string {
18
21
  return [`## Workflow: ${name}`, "", "```json", JSON.stringify(result, null, 2), "```"].join("\n");
19
22
  }
20
23
 
24
+ function workflowEnvelope(name: string, result: unknown): WorkflowResultEnvelope {
25
+ return { name, result, completedAt: Date.now() };
26
+ }
27
+
28
+ interface WorkflowInvocation {
29
+ name: string;
30
+ args: string;
31
+ options: WorkflowRunOptions;
32
+ }
33
+
34
+ function parseWorkflowInvocation(input: string): WorkflowInvocation {
35
+ const trimmed = input.trim();
36
+ const space = trimmed.indexOf(" ");
37
+ const name = space === -1 ? trimmed : trimmed.slice(0, space);
38
+ const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
39
+ const inspect = /(?:^|\s)--inspect(?:\s|$)/.test(rest);
40
+ const cleanedArgs = rest.replace(/(?:^|\s)--inspect(?=\s|$)/g, " ").trim();
41
+ return { name, args: cleanedArgs, options: inspect ? { inspect: true } : {} };
42
+ }
43
+
44
+ async function pickWorkflow(
45
+ workflows: ReadonlyMap<string, WorkflowModule>,
46
+ ctx: ExtensionCommandContext,
47
+ ): Promise<WorkflowInvocation | undefined> {
48
+ const choices = [...workflows.values()].map((mod) => `${mod.meta.name} — ${mod.meta.description}`);
49
+ const choice = await ctx.ui.select("Run workflow", choices);
50
+ if (!choice) return undefined;
51
+
52
+ const separator = choice.indexOf(" — ");
53
+ const name = separator === -1 ? choice : choice.slice(0, separator);
54
+ const args = name === "code-review" ? (await ctx.ui.input("Code-review target/instructions", "Blank = auto-detect diff"))?.trim() ?? "" : "";
55
+ const inspect = await ctx.ui.confirm("Open inspector?", "Open a live workflow inspector while this workflow runs?");
56
+ return { name, args, options: { inspect } };
57
+ }
58
+
59
+ async function sendWorkflowResult(
60
+ pi: ExtensionAPI,
61
+ ctx: ExtensionCommandContext,
62
+ name: string,
63
+ mod: WorkflowModule,
64
+ args: string,
65
+ options: WorkflowRunOptions,
66
+ ): Promise<void> {
67
+ const result = await runWorkflow(ctx, mod, args, options);
68
+ pi.sendMessage(
69
+ { customType: "workflow-result", content: formatReport(name, result), display: true, details: workflowEnvelope(name, result) },
70
+ { triggerTurn: false },
71
+ );
72
+ }
73
+
21
74
  export default function workflowEngine(pi: ExtensionAPI): void {
75
+ pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
76
+ const details = message.details;
77
+ if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
78
+ return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
79
+ });
80
+
22
81
  // /workflow <name> [args] — user-invoked.
23
82
  pi.registerCommand("workflow", {
24
83
  description: "Run a multi-agent workflow: /workflow <name> [args]",
25
84
  handler: async (args: string, ctx: ExtensionCommandContext) => {
26
- const trimmed = args.trim();
27
- const space = trimmed.indexOf(" ");
28
- const name = space === -1 ? trimmed : trimmed.slice(0, space);
29
- const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
30
-
31
85
  const workflows = await discoverWorkflows(REPO_DIR);
32
86
  const available = [...workflows.keys()].join(", ") || "(none)";
87
+ const direct = parseWorkflowInvocation(args);
88
+ const invocation = direct.name ? direct : ctx.hasUI ? await pickWorkflow(workflows, ctx) : undefined;
33
89
 
34
- if (!name) {
90
+ if (!invocation) {
35
91
  ctx.ui.notify(`Usage: /workflow <name> [args]. Available: ${available}`, "warning");
36
92
  return;
37
93
  }
38
- const mod = workflows.get(name);
94
+
95
+ const mod = workflows.get(invocation.name);
39
96
  if (!mod) {
40
- ctx.ui.notify(`Unknown workflow "${name}". Available: ${available}`, "error");
97
+ ctx.ui.notify(`Unknown workflow "${invocation.name}". Available: ${available}`, "error");
41
98
  return;
42
99
  }
43
100
 
44
- const result = await runWorkflow(ctx, mod, rest);
45
- pi.sendMessage(
46
- { customType: "workflow-result", content: formatReport(name, result), display: true, details: result },
47
- { triggerTurn: false },
48
- );
101
+ await sendWorkflowResult(pi, ctx, invocation.name, mod, invocation.args, invocation.options);
49
102
  },
50
103
  });
51
104
 
@@ -59,6 +112,18 @@ export default function workflowEngine(pi: ExtensionAPI): void {
59
112
  name: Type.String({ description: "Workflow name, e.g. code-review" }),
60
113
  args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
61
114
  }),
115
+ renderCall(args, theme) {
116
+ const suffix = args.args ? ` ${theme.fg("dim", args.args)}` : "";
117
+ return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name)}${suffix}`, 0, 0);
118
+ },
119
+ renderResult(result, { expanded, isPartial }, theme) {
120
+ if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
121
+ const details = result.details;
122
+ if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
123
+ const first = result.content[0];
124
+ const text = first?.type === "text" ? first.text : "Workflow finished.";
125
+ return new Text(theme.fg("muted", text), 0, 0);
126
+ },
62
127
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
63
128
  const workflows = await discoverWorkflows(REPO_DIR);
64
129
  const mod = workflows.get(params.name);
@@ -70,7 +135,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
70
135
  };
71
136
  }
72
137
  const result = await runWorkflow(ctx, mod, params.args ?? "");
73
- return { content: [{ type: "text", text: summarize(result) }], details: result };
138
+ return { content: [{ type: "text", text: summarize(result) }], details: workflowEnvelope(params.name, result) };
74
139
  },
75
140
  });
76
141
  }
package/src/progress.ts CHANGED
@@ -1,9 +1,63 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { TUI } from "@earendil-works/pi-tui";
3
+ import type { WorkflowLaneItemStatus, WorkflowProgressEvent } from "./types.ts";
4
+ import { statusText } from "./ui/workflow-format.ts";
5
+ import { createWorkflowWidget, type WorkflowWidget } from "./ui/workflow-widget.ts";
6
+
7
+ export type AgentRowStatus = "queued" | "running" | "done" | "failed";
8
+
9
+ export interface AgentRowSnapshot {
10
+ readonly id: number;
11
+ readonly label: string;
12
+ readonly status: AgentRowStatus;
13
+ readonly startedAt?: number;
14
+ readonly doneAt?: number;
15
+ readonly toolUses: number;
16
+ readonly lastTool?: string;
17
+ readonly error?: string;
18
+ }
19
+
20
+ export interface PhaseSnapshot {
21
+ readonly title: string;
22
+ readonly agents: readonly AgentRowSnapshot[];
23
+ }
24
+
25
+ export interface WorkflowCounterSnapshot {
26
+ readonly key: string;
27
+ readonly label: string;
28
+ readonly value: number;
29
+ }
30
+
31
+ export interface WorkflowLaneItemSnapshot {
32
+ readonly lane: string;
33
+ readonly title: string;
34
+ readonly subtitle?: string;
35
+ readonly status: WorkflowLaneItemStatus;
36
+ readonly details?: string;
37
+ readonly createdAt: number;
38
+ }
39
+
40
+ export interface WorkflowProgressSnapshot {
41
+ readonly title: string;
42
+ readonly startedAt: number;
43
+ readonly doneAt?: number;
44
+ readonly currentPhase: string;
45
+ readonly phases: readonly PhaseSnapshot[];
46
+ readonly counters: readonly WorkflowCounterSnapshot[];
47
+ readonly summary: readonly [string, string | number][];
48
+ readonly lanes: readonly [string, readonly WorkflowLaneItemSnapshot[]][];
49
+ readonly logs: readonly string[];
50
+ }
2
51
 
3
52
  interface AgentRow {
53
+ id: number;
4
54
  label: string;
5
- status: "running" | "done";
55
+ status: AgentRowStatus;
56
+ startedAt?: number;
57
+ doneAt?: number;
58
+ toolUses: number;
6
59
  lastTool?: string;
60
+ error?: string;
7
61
  }
8
62
 
9
63
  interface Phase {
@@ -11,21 +65,48 @@ interface Phase {
11
65
  agents: AgentRow[];
12
66
  }
13
67
 
68
+ interface WorkflowCounter {
69
+ key: string;
70
+ label: string;
71
+ value: number;
72
+ }
73
+
74
+ interface WorkflowLaneItem {
75
+ lane: string;
76
+ title: string;
77
+ subtitle?: string;
78
+ status: WorkflowLaneItemStatus;
79
+ details?: string;
80
+ createdAt: number;
81
+ }
82
+
83
+ const LOG_LIMIT = 24;
84
+
14
85
  /**
15
- * Renders a live phase/agent tree into the pi TUI via `ctx.ui.setWidget`.
16
- * In non-interactive modes (print/JSON/RPC) it falls back to terse stderr breadcrumbs
17
- * so it never corrupts stdout.
86
+ * Tracks live workflow state for widgets, footer/status text, result renderers,
87
+ * and headless stderr breadcrumbs.
18
88
  */
19
89
  export class ProgressTracker {
20
90
  private readonly phases: Phase[] = [];
21
91
  private readonly logs: string[] = [];
22
- private current = "Workflow";
92
+ private readonly counters = new Map<string, WorkflowCounter>();
93
+ private readonly summary = new Map<string, string | number>();
94
+ private readonly lanes = new Map<string, WorkflowLaneItem[]>();
95
+ private readonly startedAt = Date.now();
96
+ private doneAt: number | undefined;
97
+ private currentPhase = "Workflow";
98
+ private nextAgentId = 1;
99
+ private widget: WorkflowWidget | undefined;
100
+ private widgetRegistered = false;
101
+ private tui: Pick<TUI, "requestRender"> | undefined;
102
+ private widgetInterval: ReturnType<typeof setInterval> | undefined;
103
+ private lastStatusText: string | undefined;
23
104
 
24
105
  constructor(
25
106
  private readonly ctx: ExtensionContext,
26
107
  private readonly title: string,
27
108
  ) {
28
- this.ensurePhase(this.current);
109
+ this.ensurePhase(this.currentPhase);
29
110
  }
30
111
 
31
112
  private ensurePhase(title: string): Phase {
@@ -38,7 +119,7 @@ export class ProgressTracker {
38
119
  }
39
120
 
40
121
  phase(title: string): void {
41
- this.current = title;
122
+ this.currentPhase = title;
42
123
  this.ensurePhase(title);
43
124
  if (!this.ctx.hasUI) process.stderr.write(`[${this.title}] ${title}\n`);
44
125
  this.render();
@@ -46,31 +127,121 @@ export class ProgressTracker {
46
127
 
47
128
  log(message: string): void {
48
129
  this.logs.push(message);
49
- if (this.logs.length > 6) this.logs.shift();
130
+ while (this.logs.length > LOG_LIMIT) this.logs.shift();
50
131
  if (!this.ctx.hasUI) process.stderr.write(`[${this.title}] ${message}\n`);
51
132
  this.render();
52
133
  }
53
134
 
54
- agentStart(phase: string | undefined, label: string): void {
55
- this.ensurePhase(phase ?? this.current).agents.push({ label, status: "running" });
135
+ event(event: WorkflowProgressEvent): void {
136
+ switch (event.type) {
137
+ case "counter":
138
+ this.counters.set(event.key, { key: event.key, label: event.label, value: event.value });
139
+ break;
140
+ case "counter_delta": {
141
+ const current = this.counters.get(event.key);
142
+ this.counters.set(event.key, {
143
+ key: event.key,
144
+ label: event.label,
145
+ value: (current?.value ?? 0) + event.delta,
146
+ });
147
+ break;
148
+ }
149
+ case "lane_item": {
150
+ const lane = this.lanes.get(event.lane) ?? [];
151
+ lane.push({
152
+ lane: event.lane,
153
+ title: event.title,
154
+ subtitle: event.subtitle,
155
+ status: event.status,
156
+ details: event.details,
157
+ createdAt: Date.now(),
158
+ });
159
+ this.lanes.set(event.lane, lane);
160
+ break;
161
+ }
162
+ case "summary":
163
+ this.summary.set(event.key, event.value);
164
+ break;
165
+ }
166
+ this.render();
167
+ }
168
+
169
+ agentQueued(phase: string | undefined, label: string): number {
170
+ const id = this.nextAgentId++;
171
+ this.ensurePhase(phase ?? this.currentPhase).agents.push({ label, id, status: "queued", toolUses: 0 });
172
+ this.render();
173
+ return id;
174
+ }
175
+
176
+ agentStart(phase: string | undefined, label: string, id?: number): void {
177
+ const row = id === undefined ? undefined : this.findRowById(id);
178
+ if (row) {
179
+ row.status = "running";
180
+ row.startedAt = Date.now();
181
+ row.error = undefined;
182
+ } else {
183
+ this.ensurePhase(phase ?? this.currentPhase).agents.push({
184
+ label,
185
+ id: this.nextAgentId++,
186
+ status: "running",
187
+ startedAt: Date.now(),
188
+ toolUses: 0,
189
+ });
190
+ }
56
191
  this.render();
57
192
  }
58
193
 
59
- agentTool(label: string, tool: string): void {
60
- const row = this.findRow(label);
61
- if (row) row.lastTool = tool;
194
+ agentTool(label: string, tool: string, id?: number): void {
195
+ const row = this.findRow(label, id);
196
+ if (row) {
197
+ row.lastTool = tool;
198
+ row.toolUses += 1;
199
+ }
62
200
  this.render();
63
201
  }
64
202
 
65
- agentDone(label: string): void {
66
- const row = this.findRow(label);
67
- if (row && row.status === "running") row.status = "done";
203
+ agentDone(label: string, id?: number): void {
204
+ const row = this.findRow(label, id);
205
+ if (row && row.status !== "failed") {
206
+ row.status = "done";
207
+ row.doneAt = Date.now();
208
+ }
68
209
  this.render();
69
210
  }
70
211
 
71
- private findRow(label: string): AgentRow | undefined {
212
+ agentFailed(label: string, error: unknown, id?: number): void {
213
+ const row = this.findRow(label, id);
214
+ if (row) {
215
+ row.status = "failed";
216
+ row.doneAt = Date.now();
217
+ row.error = errorMessage(error);
218
+ }
219
+ this.render();
220
+ }
221
+
222
+ snapshot(): WorkflowProgressSnapshot {
223
+ return {
224
+ title: this.title,
225
+ startedAt: this.startedAt,
226
+ doneAt: this.doneAt,
227
+ currentPhase: this.currentPhase,
228
+ phases: this.phases.map((phase) => ({
229
+ title: phase.title,
230
+ agents: phase.agents.map((agent) => ({ ...agent })),
231
+ })),
232
+ counters: [...this.counters.values()].map((counter) => ({ ...counter })),
233
+ summary: [...this.summary.entries()],
234
+ lanes: [...this.lanes.entries()].map(([lane, items]) => [lane, items.map((item) => ({ ...item }))]),
235
+ logs: [...this.logs],
236
+ };
237
+ }
238
+
239
+ private findRow(label: string, id?: number): AgentRow | undefined {
240
+ if (id !== undefined) return this.findRowById(id);
72
241
  for (let i = this.phases.length - 1; i >= 0; i--) {
73
- const running = this.phases[i].agents.find((agent) => agent.label === label && agent.status === "running");
242
+ const running = this.phases[i].agents.find(
243
+ (agent) => agent.label === label && (agent.status === "running" || agent.status === "queued"),
244
+ );
74
245
  if (running) return running;
75
246
  }
76
247
  for (let i = this.phases.length - 1; i >= 0; i--) {
@@ -80,29 +251,93 @@ export class ProgressTracker {
80
251
  return undefined;
81
252
  }
82
253
 
254
+ private findRowById(id: number): AgentRow | undefined {
255
+ for (const phase of this.phases) {
256
+ const row = phase.agents.find((agent) => agent.id === id);
257
+ if (row) return row;
258
+ }
259
+ return undefined;
260
+ }
261
+
83
262
  private lines(): string[] {
84
263
  const out: string[] = [`⚙ ${this.title}`];
85
264
  for (const phase of this.phases) {
86
265
  if (phase.agents.length === 0 && phase.title === "Workflow") continue;
87
266
  out.push(` ${phase.title}`);
88
267
  for (const agent of phase.agents) {
89
- const icon = agent.status === "done" ? "✓" : "⏳";
268
+ const icon = agent.status === "done" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "queued" ? "○" : "⏳";
90
269
  const tool = agent.status === "running" && agent.lastTool ? ` · ${agent.lastTool}` : "";
91
- out.push(` ${icon} ${agent.label}${tool}`);
270
+ const error = agent.status === "failed" && agent.error ? ` · ${agent.error}` : "";
271
+ out.push(` ${icon} ${agent.label}${tool}${error}`);
92
272
  }
93
273
  }
94
- for (const line of this.logs) out.push(` · ${line}`);
274
+ const counterLine = [...this.counters.values()].map((counter) => `${counter.label}: ${counter.value}`).join(" · ");
275
+ if (counterLine) out.push(` ${counterLine}`);
276
+ for (const line of this.logs.slice(-6)) out.push(` · ${line}`);
95
277
  return out;
96
278
  }
97
279
 
98
280
  private render(): void {
99
- if (this.ctx.hasUI) this.ctx.ui.setWidget("workflow", this.lines());
281
+ if (!this.ctx.hasUI) return;
282
+ this.ensureWidget();
283
+ this.widget?.invalidate();
284
+ this.tui?.requestRender();
285
+ this.publishStatus();
286
+ }
287
+
288
+ private publishStatus(): void {
289
+ const next = statusText(this.snapshot(), this.ctx.ui.theme);
290
+ if (next === this.lastStatusText) return;
291
+ this.ctx.ui.setStatus("workflow", next);
292
+ this.lastStatusText = next;
293
+ }
294
+
295
+ private ensureWidget(): void {
296
+ if (this.widgetRegistered) return;
297
+ this.widget = createWorkflowWidget(() => this.snapshot());
298
+ this.ctx.ui.setWidget(
299
+ "workflow",
300
+ (tui, theme) => {
301
+ this.tui = tui;
302
+ return {
303
+ render: (width?: number) => this.widget?.render(width ?? tui.terminal.columns, theme) ?? [],
304
+ invalidate: () => this.widget?.invalidate(),
305
+ };
306
+ },
307
+ { placement: "aboveEditor" },
308
+ );
309
+ this.widgetRegistered = true;
310
+ this.startWidgetTimer();
100
311
  }
101
312
 
102
- /** Clear the widget; optionally leave a one-line status in the footer. */
313
+ private startWidgetTimer(): void {
314
+ if (this.widgetInterval !== undefined) return;
315
+ this.widgetInterval = setInterval(() => {
316
+ this.widget?.nextFrame();
317
+ this.tui?.requestRender();
318
+ }, 100);
319
+ }
320
+
321
+ private stopWidgetTimer(): void {
322
+ if (this.widgetInterval === undefined) return;
323
+ clearInterval(this.widgetInterval);
324
+ this.widgetInterval = undefined;
325
+ }
326
+
327
+ /** Clear live workflow surfaces; optionally leave a one-line final status. */
103
328
  done(status?: string): void {
329
+ this.doneAt = Date.now();
330
+ this.stopWidgetTimer();
104
331
  if (!this.ctx.hasUI) return;
105
332
  this.ctx.ui.setWidget("workflow", undefined);
106
333
  this.ctx.ui.setStatus("workflow", status);
334
+ this.lastStatusText = status;
335
+ this.widgetRegistered = false;
336
+ this.widget = undefined;
337
+ this.tui = undefined;
107
338
  }
108
339
  }
340
+
341
+ function errorMessage(error: unknown): string {
342
+ return error instanceof Error ? error.message : String(error);
343
+ }
package/src/types.ts CHANGED
@@ -8,7 +8,34 @@ export interface WorkflowMeta {
8
8
  phases?: Array<{ title: string }>;
9
9
  }
10
10
 
11
+ export interface WorkflowRunStats {
12
+ files: number;
13
+ candidates: number;
14
+ verified: number;
15
+ kept: number;
16
+ [key: string]: string | number;
17
+ }
18
+
19
+ export interface WorkflowRunOptions {
20
+ inspect?: boolean;
21
+ }
22
+
11
23
  /** Options for a single `agent()` call. */
24
+ export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
25
+
26
+ export type WorkflowProgressEvent =
27
+ | { type: "counter"; key: string; label: string; value: number }
28
+ | { type: "counter_delta"; key: string; label: string; delta: number }
29
+ | {
30
+ type: "lane_item";
31
+ lane: string;
32
+ title: string;
33
+ subtitle?: string;
34
+ status: WorkflowLaneItemStatus;
35
+ details?: string;
36
+ }
37
+ | { type: "summary"; key: string; value: string | number };
38
+
12
39
  export interface AgentOptions<S extends TSchema = TSchema> {
13
40
  /** Label shown in the progress tree (e.g. "find:logic-bugs"). */
14
41
  label?: string;
@@ -44,8 +71,12 @@ export interface WorkflowApi {
44
71
  phase(title: string): void;
45
72
  /** Emit a progress line. */
46
73
  log(message: string): void;
74
+ /** Emit a structured progress event for native workflow UI surfaces. */
75
+ progress(event: WorkflowProgressEvent): void;
47
76
  /** Raw argument string passed after the workflow name. */
48
77
  args: string;
78
+ /** Working directory of the host session (typically the repo root). */
79
+ cwd: string;
49
80
  /** Abort signal for the run, propagated from the host. */
50
81
  signal: AbortSignal | undefined;
51
82
  }