pi-workflow-engine 0.6.1 → 0.8.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.
@@ -1,12 +1,27 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { KeyId } from "@earendil-works/pi-tui";
4
+ import { resolveDynamaxShortcuts, type DynamaxShortcuts } from "./dynamax-shortcuts.ts";
3
5
 
4
6
  export interface DynamaxState {
5
7
  sticky: boolean;
6
8
  oneShotPending: boolean;
9
+ turnActive: boolean;
10
+ }
11
+
12
+ export interface DynamaxRuntime {
13
+ state: DynamaxState;
14
+ runningWorkflow?: string;
15
+ }
16
+
17
+ export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
18
+ export interface DynamaxRegistrationOptions {
19
+ openInspector?: (ctx: ExtensionContext) => Promise<void> | void;
7
20
  }
8
21
 
9
22
  export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
23
+ export const DYNAMAX_STATUS_KEY = "dynamax";
24
+ export const DYNAMAX_WIDGET_KEY = "workflow-dynamax";
10
25
 
11
26
  export const DYNAMAX_REMINDER = `
12
27
  ## dynamax workflow opt-in
@@ -23,7 +38,24 @@ Inline workflow rules:
23
38
  const DYNAMAX_CONTEXT_CUSTOM_TYPE = "workflow-dynamax-reminder";
24
39
 
25
40
  export function createDynamaxState(): DynamaxState {
26
- return { sticky: false, oneShotPending: false };
41
+ return { sticky: false, oneShotPending: false, turnActive: false };
42
+ }
43
+
44
+ export function createDynamaxRuntime(): DynamaxRuntime {
45
+ return { state: createDynamaxState() };
46
+ }
47
+
48
+ export function dynamaxSessionKey(ctx: Pick<ExtensionContext, "sessionManager">): string {
49
+ return ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
50
+ }
51
+
52
+ export function getDynamaxRuntime(store: DynamaxRuntimeStore, ctx: Pick<ExtensionContext, "sessionManager">): DynamaxRuntime {
53
+ const key = dynamaxSessionKey(ctx);
54
+ const existing = store.get(key);
55
+ if (existing) return existing;
56
+ const runtime = createDynamaxRuntime();
57
+ store.set(key, runtime);
58
+ return runtime;
27
59
  }
28
60
 
29
61
  export function hasDynamaxToken(text: string): boolean {
@@ -36,12 +68,16 @@ export function markDynamaxOneShot(state: DynamaxState): void {
36
68
 
37
69
  export function setDynamaxSticky(state: DynamaxState, sticky: boolean): void {
38
70
  state.sticky = sticky;
39
- if (!sticky) state.oneShotPending = false;
71
+ if (!sticky) {
72
+ state.oneShotPending = false;
73
+ state.turnActive = false;
74
+ }
40
75
  }
41
76
 
42
77
  export function clearDynamax(state: DynamaxState): void {
43
78
  state.sticky = false;
44
79
  state.oneShotPending = false;
80
+ state.turnActive = false;
45
81
  }
46
82
 
47
83
  export function consumeDynamaxOneShot(state: DynamaxState): boolean {
@@ -51,12 +87,32 @@ export function consumeDynamaxOneShot(state: DynamaxState): boolean {
51
87
  }
52
88
 
53
89
  export function isDynamaxActive(state: DynamaxState): boolean {
54
- return state.sticky || state.oneShotPending;
90
+ return state.sticky || state.oneShotPending || state.turnActive;
91
+ }
92
+
93
+ export function describeDynamaxState(state: DynamaxState): string {
94
+ const sticky = state.sticky ? "on" : "off";
95
+ const oneShot = state.oneShotPending ? "pending" : "clear";
96
+ const turn = state.turnActive ? "; active for current turn" : "";
97
+ return `sticky ${sticky}; one-shot ${oneShot}${turn}`;
98
+ }
99
+
100
+ export function dynamaxWidgetLine(state: DynamaxState, shortcut: KeyId | null, runningWorkflow?: string): string {
101
+ const modes = [
102
+ runningWorkflow ? `running ${runningWorkflow}` : undefined,
103
+ state.sticky ? "sticky on" : undefined,
104
+ !state.sticky && state.turnActive ? "active this turn" : undefined,
105
+ state.oneShotPending ? "one-shot pending" : undefined,
106
+ ].filter((value): value is string => value !== undefined);
107
+ const mode = modes.join(" + ") || "off";
108
+ const inspectorHint = shortcut ? `${shortcut} inspector` : "/workflow:inspector";
109
+ return `dynamax: ${mode} | /workflow:dynamax on|off | ${inspectorHint}`;
55
110
  }
56
111
 
57
112
  export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string {
58
- if (!isDynamaxActive(state)) return systemPrompt;
113
+ if (!state.sticky && !state.oneShotPending) return systemPrompt;
59
114
  state.oneShotPending = false;
115
+ state.turnActive = true;
60
116
  return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`;
61
117
  }
62
118
 
@@ -65,52 +121,174 @@ export function appendDynamaxContextReminder(messages: AgentMessage[], state: Dy
65
121
  return [...messages, createDynamaxContextMessage()];
66
122
  }
67
123
 
68
- export function registerDynamax(pi: ExtensionAPI): void {
69
- const state = createDynamaxState();
124
+ export function registerDynamax(
125
+ pi: ExtensionAPI,
126
+ shortcuts: DynamaxShortcuts = resolveDynamaxShortcuts(),
127
+ options: DynamaxRegistrationOptions = {},
128
+ ): void {
129
+ const runtimes: DynamaxRuntimeStore = new Map();
130
+ const openInspector =
131
+ options.openInspector ??
132
+ ((ctx: ExtensionContext): void => {
133
+ ctx.ui.notify("No workflow inspector is available yet", "warning");
134
+ });
135
+ const refreshIfVisible = (ctx: ExtensionContext): void => {
136
+ const runtime = getDynamaxRuntime(runtimes, ctx);
137
+ if (isDynamaxActive(runtime.state) || runtime.runningWorkflow) {
138
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
139
+ }
140
+ };
141
+
142
+ pi.on("session_start", (_event, ctx) => {
143
+ updateDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx), shortcuts);
144
+ });
145
+
146
+ pi.on("session_shutdown", (_event, ctx) => {
147
+ clearDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx));
148
+ });
70
149
 
71
- pi.on("input", (event) => {
150
+ pi.on("input", (event, ctx) => {
72
151
  if (event.source === "extension") return { action: "continue" };
73
- if (hasDynamaxToken(event.text)) markDynamaxOneShot(state);
152
+ const runtime = getDynamaxRuntime(runtimes, ctx);
153
+ if (hasDynamaxToken(event.text)) {
154
+ markDynamaxOneShot(runtime.state);
155
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
156
+ }
74
157
  return { action: "continue" };
75
158
  });
76
159
 
77
- pi.on("before_agent_start", (event) => {
78
- const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, state);
160
+ pi.on("before_agent_start", (event, ctx) => {
161
+ const runtime = getDynamaxRuntime(runtimes, ctx);
162
+ const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, runtime.state);
79
163
  if (systemPrompt === event.systemPrompt) return undefined;
164
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
80
165
  return { systemPrompt };
81
166
  });
82
167
 
83
- pi.on("context", (event) => {
84
- const messages = appendDynamaxContextReminder(event.messages, state);
168
+ pi.on("agent_start", (_event, ctx) => {
169
+ refreshIfVisible(ctx);
170
+ });
171
+
172
+ pi.on("message_start", (_event, ctx) => {
173
+ refreshIfVisible(ctx);
174
+ });
175
+
176
+ pi.on("message_update", (_event, ctx) => {
177
+ refreshIfVisible(ctx);
178
+ });
179
+
180
+ pi.on("message_end", (_event, ctx) => {
181
+ refreshIfVisible(ctx);
182
+ });
183
+
184
+ pi.on("turn_end", (_event, ctx) => {
185
+ refreshIfVisible(ctx);
186
+ });
187
+
188
+ pi.on("agent_end", (_event, ctx) => {
189
+ const runtime = getDynamaxRuntime(runtimes, ctx);
190
+ runtime.runningWorkflow = undefined;
191
+ if (!runtime.state.sticky) runtime.state.turnActive = false;
192
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
193
+ });
194
+
195
+ pi.on("tool_execution_start", (event, ctx) => {
196
+ const runtime = getDynamaxRuntime(runtimes, ctx);
197
+ if (event.toolName === "workflow") {
198
+ runtime.runningWorkflow = workflowLabel(event.args);
199
+ }
200
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
201
+ return undefined;
202
+ });
203
+
204
+ pi.on("tool_execution_update", (_event, ctx) => {
205
+ refreshIfVisible(ctx);
206
+ });
207
+
208
+ pi.on("tool_execution_end", (event, ctx) => {
209
+ const runtime = getDynamaxRuntime(runtimes, ctx);
210
+ if (event.toolName === "workflow") {
211
+ runtime.runningWorkflow = undefined;
212
+ }
213
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
214
+ return undefined;
215
+ });
216
+
217
+ pi.on("context", (event, ctx) => {
218
+ const runtime = getDynamaxRuntime(runtimes, ctx);
219
+ const messages = appendDynamaxContextReminder(event.messages, runtime.state);
85
220
  if (messages === event.messages) return undefined;
86
221
  return { messages };
87
222
  });
88
223
 
224
+ if (shortcuts.inspector) {
225
+ pi.registerShortcut(shortcuts.inspector, {
226
+ description: "Open workflow inspector",
227
+ handler: async (ctx) => {
228
+ await openInspector(ctx);
229
+ },
230
+ });
231
+ }
232
+
89
233
  pi.registerCommand("workflow:dynamax", {
90
- description: "Toggle dynamax workflow orchestration opt-in: /workflow:dynamax on|off|status",
234
+ description: "Toggle Dynamax workflow orchestration: /workflow:dynamax [on|off|status]",
91
235
  handler: async (args, ctx) => {
236
+ const runtime = getDynamaxRuntime(runtimes, ctx);
92
237
  const action = args.trim().toLowerCase();
238
+ if (action === "") {
239
+ ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}. Usage: /workflow:dynamax [on|off|status]`, "info");
240
+ return;
241
+ }
93
242
  if (action === "on") {
94
- setDynamaxSticky(state, true);
95
- ctx.ui.notify("dynamax workflow orchestration is on for this session", "info");
243
+ setDynamaxSticky(runtime.state, true);
244
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
245
+ ctx.ui.notify("Dynamax workflow orchestration is on for this session", "info");
96
246
  return;
97
247
  }
98
248
  if (action === "off") {
99
- clearDynamax(state);
100
- ctx.ui.notify("dynamax workflow orchestration is off", "info");
249
+ clearDynamax(runtime.state);
250
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
251
+ ctx.ui.notify("Dynamax workflow orchestration is off", "info");
101
252
  return;
102
253
  }
103
254
  if (action === "status") {
104
- const sticky = state.sticky ? "on" : "off";
105
- const oneShot = state.oneShotPending ? "; one-shot opt-in pending" : "";
106
- ctx.ui.notify(`dynamax sticky mode is ${sticky}${oneShot}`, "info");
255
+ ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}`, "info");
107
256
  return;
108
257
  }
109
- ctx.ui.notify("Usage: /workflow:dynamax on|off|status", "warning");
258
+ ctx.ui.notify("Usage: /workflow:dynamax [on|off|status]", "warning");
110
259
  },
111
260
  });
112
261
  }
113
262
 
263
+ export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime, shortcuts: DynamaxShortcuts): void {
264
+ if (!ctx.hasUI) return;
265
+ ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
266
+ ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
267
+ if (!isDynamaxActive(runtime.state) && !runtime.runningWorkflow) {
268
+ clearDynamaxSurfaces(ctx, runtime);
269
+ return;
270
+ }
271
+
272
+ const status = dynamaxWidgetLine(runtime.state, shortcuts.inspector, runtime.runningWorkflow);
273
+ ctx.ui.setStatus(DYNAMAX_STATUS_KEY, status);
274
+ }
275
+
276
+ export function clearDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime): void {
277
+ if (!ctx.hasUI) return;
278
+ ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
279
+ ctx.ui.setStatus(DYNAMAX_STATUS_KEY, undefined);
280
+ ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
281
+ }
282
+
283
+ function workflowLabel(args: unknown): string {
284
+ if (!isRecord(args)) return "workflow";
285
+ const name = args.name;
286
+ if (typeof name === "string" && name.trim()) return name.trim();
287
+ const script = args.script;
288
+ if (typeof script === "string" && script.trim()) return "inline workflow";
289
+ return "workflow";
290
+ }
291
+
114
292
  function createDynamaxContextMessage(): AgentMessage {
115
293
  return {
116
294
  role: "custom",
@@ -121,3 +299,7 @@ function createDynamaxContextMessage(): AgentMessage {
121
299
  timestamp: Date.now(),
122
300
  };
123
301
  }
302
+
303
+ function isRecord(value: unknown): value is Record<string, unknown> {
304
+ return typeof value === "object" && value !== null;
305
+ }
@@ -4,13 +4,37 @@ import { linkAbortSignal } from "./cancellation.ts";
4
4
  import { runAgent, type RunContext } from "./agent-runner.ts";
5
5
  import { ProgressTracker } from "./progress.ts";
6
6
  import { createPerfRecorder, type PerfSnapshot } from "./perf.ts";
7
+ import { createWorkflowUsageRecorder } from "./usage.ts";
7
8
  import { defaultConcurrency, resolveWorkflowRunOptions } from "./options.ts";
8
- import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowRunOptions } from "./types.ts";
9
+ import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
9
10
  import { WorkflowInspector } from "./ui/workflow-inspector.ts";
10
11
 
11
12
  /** Default global cap on concurrent agents per run. */
12
13
  const DEFAULT_CONCURRENCY = defaultConcurrency();
13
14
 
15
+ /** The workflow-facing slice of the progress tracker (satisfied by `ProgressTracker`). */
16
+ export interface WorkflowProgress {
17
+ phase(title: string): void;
18
+ log(message: string): void;
19
+ event(event: WorkflowProgressEvent): void;
20
+ }
21
+
22
+ /** Per-context knobs threaded through `runWorkflowWithContext` (and re-derived for each sub-workflow). */
23
+ export interface WorkflowContextOptions {
24
+ /** Controller whose `.signal` equals `rc.signal`; aborts in-flight `parallel` siblings on failure. */
25
+ abortController: AbortController;
26
+ /** Eager-submission cap for `parallel`. */
27
+ submissionLimit: number;
28
+ /** Resolve a sub-workflow reference for `api.workflow()`. Omit to disable composition. */
29
+ resolveWorkflow?: (ref: WorkflowRef) => Promise<WorkflowModule>;
30
+ /** Nesting depth. Sub-workflows run at depth >= 1, where `api.workflow()` rejects. */
31
+ depth?: number;
32
+ /** Prefix applied to phase titles so sub-workflow phases nest as "<name> ▸ <title>". */
33
+ progressPrefix?: string;
34
+ /** Namespace applied to child structured progress keys/lanes/logs. */
35
+ progressNamespace?: string;
36
+ }
37
+
14
38
  /**
15
39
  * Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
16
40
  * to a shared semaphore + progress tracker), invoke the workflow, return its result.
@@ -23,6 +47,8 @@ export async function runWorkflow(
23
47
  ): Promise<unknown> {
24
48
  const resolvedOptions = resolveWorkflowRunOptions(options);
25
49
  const progress = new ProgressTracker(ctx, mod.meta.name);
50
+ const progressSource = { snapshot: () => progress.snapshot() };
51
+ resolvedOptions.onProgressSource?.(progressSource);
26
52
  if (resolvedOptions.inspect && ctx.hasUI) {
27
53
  void ctx.ui
28
54
  .custom<void>(
@@ -33,6 +59,7 @@ export async function runWorkflow(
33
59
  }
34
60
 
35
61
  const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
62
+ const usage = createWorkflowUsageRecorder();
36
63
  const runAbortController = new AbortController();
37
64
  const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
38
65
  const unlinkOptionAbortSignal = linkAbortSignal(resolvedOptions.signal, runAbortController);
@@ -44,31 +71,23 @@ export async function runWorkflow(
44
71
  progress,
45
72
  signal: runAbortController.signal,
46
73
  perf,
74
+ usage,
47
75
  };
48
76
 
49
- const agent = ((prompt: string, opts?: AgentOptions) => runAgent(rc, prompt, opts)) as WorkflowApi["agent"];
50
-
51
- const api: WorkflowApi = {
52
- agent,
53
- parallel: (thunks) =>
54
- parallel(thunks, {
55
- signal: runAbortController.signal,
77
+ try {
78
+ // perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
79
+ return await perf.time("workflow.total_ms", () =>
80
+ runWorkflowWithContext(rc, progress, mod, args, {
56
81
  abortController: runAbortController,
57
- limit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
82
+ submissionLimit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
83
+ resolveWorkflow: resolvedOptions.resolveWorkflow,
84
+ depth: 0,
85
+ progressPrefix: "",
58
86
  }),
59
- pipeline,
60
- phase: (title) => progress.phase(title),
61
- log: (message) => progress.log(message),
62
- progress: (event) => progress.event(event),
63
- args,
64
- cwd: ctx.cwd,
65
- signal: runAbortController.signal,
66
- };
67
-
68
- try {
69
- return await perf.time("workflow.total_ms", () => mod.default(api));
87
+ );
70
88
  } finally {
71
89
  try {
90
+ resolvedOptions.onUsageSnapshot?.(usage.snapshot());
72
91
  const snapshot = perf.snapshot();
73
92
  if (resolvedOptions.perf) {
74
93
  resolvedOptions.onPerfSnapshot?.(snapshot);
@@ -77,12 +96,123 @@ export async function runWorkflow(
77
96
  progress.done();
78
97
  resolvedOptions.onProgressSnapshot?.(progress.snapshot());
79
98
  } finally {
99
+ resolvedOptions.onProgressSource?.(undefined);
80
100
  unlinkContextAbortSignal();
81
101
  unlinkOptionAbortSignal();
82
102
  }
83
103
  }
84
104
  }
85
105
 
106
+ /**
107
+ * Build the `WorkflowApi` from an existing run context and invoke the module. Reused for both
108
+ * the top-level run and every `api.workflow()` sub-step, so children share the parent's semaphore,
109
+ * abort signal, and perf sink. No setup/teardown of its own — that belongs to `runWorkflow`.
110
+ */
111
+ export async function runWorkflowWithContext(
112
+ rc: RunContext,
113
+ progress: WorkflowProgress,
114
+ mod: WorkflowModule,
115
+ args: string,
116
+ opts: WorkflowContextOptions,
117
+ ): Promise<unknown> {
118
+ const depth = opts.depth ?? 0;
119
+ const scope = createWorkflowScope(progress, opts.progressPrefix ?? "", opts.progressNamespace);
120
+
121
+ const agent = ((prompt: string, agentOpts?: AgentOptions) => runAgent(rc, prompt, scope.agentOptions(agentOpts))) as WorkflowApi["agent"];
122
+
123
+ const workflow: WorkflowApi["workflow"] =
124
+ depth >= 1
125
+ ? async () => {
126
+ throw new Error("workflow() can only nest one level deep");
127
+ }
128
+ : async (ref, childArgs) => {
129
+ if (!opts.resolveWorkflow) throw new Error("sub-workflows are not enabled in this context");
130
+ const childMod = await opts.resolveWorkflow(ref);
131
+ // A child controller isolates the child's failures from the parent (so the parent can
132
+ // try/catch a failed sub-workflow), while still cancelling the child when the run aborts.
133
+ const childController = new AbortController();
134
+ const unlink = linkAbortSignal(rc.signal, childController);
135
+ try {
136
+ return await runWorkflowWithContext({ ...rc, signal: childController.signal }, progress, childMod, childArgs ?? "", {
137
+ abortController: childController,
138
+ submissionLimit: opts.submissionLimit,
139
+ resolveWorkflow: opts.resolveWorkflow,
140
+ depth: depth + 1,
141
+ progressPrefix: `${childMod.meta.name} ▸ `,
142
+ progressNamespace: childMod.meta.name,
143
+ });
144
+ } finally {
145
+ unlink();
146
+ scope.restorePhase();
147
+ }
148
+ };
149
+
150
+ const api: WorkflowApi = {
151
+ agent,
152
+ workflow,
153
+ parallel: (thunks) =>
154
+ parallel(thunks, {
155
+ signal: rc.signal,
156
+ abortController: opts.abortController,
157
+ limit: opts.submissionLimit,
158
+ }),
159
+ pipeline,
160
+ phase: scope.phase,
161
+ log: scope.log,
162
+ progress: scope.event,
163
+ args,
164
+ cwd: rc.cwd,
165
+ signal: rc.signal,
166
+ };
167
+
168
+ return await mod.default(api);
169
+ }
170
+
171
+ interface WorkflowScope {
172
+ agentOptions(opts: AgentOptions | undefined): AgentOptions;
173
+ phase(title: string): void;
174
+ log(message: string): void;
175
+ event(event: WorkflowProgressEvent): void;
176
+ restorePhase(): void;
177
+ }
178
+
179
+ function createWorkflowScope(progress: WorkflowProgress, prefix: string, namespace: string | undefined): WorkflowScope {
180
+ let currentPhase = `${prefix}Workflow`;
181
+ const display = (title: string) => `${prefix}${title}`;
182
+ return {
183
+ agentOptions(opts) {
184
+ const phase = opts?.phase ? display(opts.phase) : currentPhase;
185
+ return opts ? { ...opts, phase } : { phase };
186
+ },
187
+ phase(title) {
188
+ currentPhase = display(title);
189
+ progress.phase(currentPhase);
190
+ },
191
+ log(message) {
192
+ progress.log(namespace ? `${namespace}: ${message}` : message);
193
+ },
194
+ event(event) {
195
+ progress.event(namespace ? namespaceProgressEvent(namespace, event) : event);
196
+ },
197
+ restorePhase() {
198
+ progress.phase(currentPhase);
199
+ },
200
+ };
201
+ }
202
+
203
+ function namespaceProgressEvent(namespace: string, event: WorkflowProgressEvent): WorkflowProgressEvent {
204
+ switch (event.type) {
205
+ case "counter":
206
+ return { ...event, key: `${namespace}.${event.key}` };
207
+ case "counter_delta":
208
+ return { ...event, key: `${namespace}.${event.key}` };
209
+ case "lane_item":
210
+ return { ...event, lane: `${namespace} ▸ ${event.lane}` };
211
+ case "summary":
212
+ return { ...event, key: `${namespace}.${event.key}` };
213
+ }
214
+ }
215
+
86
216
  function formatPerfSummary(snapshot: PerfSnapshot): string {
87
217
  const parts = snapshot.aggregates
88
218
  .filter((aggregate) => aggregate.count > 0)
@@ -28,7 +28,7 @@ export type InlineCommentStatus =
28
28
  | { readonly issueId: string; readonly status: "skipped"; readonly reason: string }
29
29
  | { readonly issueId: string; readonly status: "failed"; readonly reason: string };
30
30
 
31
- const PR_VIEW_JSON_FIELDS = "number,headRefOid,url,headRepositoryOwner,headRepository";
31
+ const PR_VIEW_JSON_FIELDS = "number,headRefOid,url";
32
32
 
33
33
  export async function resolveGitHubPrContext(
34
34
  exec: ExecLike,
@@ -47,10 +47,10 @@ export async function resolveGitHubPrContext(
47
47
  if (number === undefined) return { ok: false, reason: "No GitHub PR number found." };
48
48
  if (!headSha) return { ok: false, reason: "GitHub PR head SHA is missing." };
49
49
 
50
- const ownerFromPr = nestedStringField(prView.value, "headRepositoryOwner", "login");
51
- const repoFromPr = nestedStringField(prView.value, "headRepository", "name");
52
- let owner = ownerFromPr;
53
- let repo = repoFromPr;
50
+ const url = stringField(prView.value, "url");
51
+ const baseFromUrl = parsePullRequestUrl(url);
52
+ let owner = baseFromUrl?.owner;
53
+ let repo = baseFromUrl?.repo;
54
54
  if (!owner || !repo) {
55
55
  const repoView = await runJson(exec, cwd, ["repo", "view", "--json", "nameWithOwner"]);
56
56
  if (!repoView.ok) return { ok: false, reason: repoView.reason };
@@ -61,7 +61,6 @@ export async function resolveGitHubPrContext(
61
61
  }
62
62
  if (!owner || !repo) return { ok: false, reason: "GitHub repository owner/name is missing." };
63
63
 
64
- const url = stringField(prView.value, "url");
65
64
  return { ok: true, context: { owner, repo, number, headSha, url } };
66
65
  }
67
66
 
@@ -159,12 +158,6 @@ function stringField(value: unknown, key: string): string | undefined {
159
158
  return typeof field === "string" && field.trim().length > 0 ? field : undefined;
160
159
  }
161
160
 
162
- function nestedStringField(value: unknown, objectKey: string, key: string): string | undefined {
163
- if (!isRecord(value)) return undefined;
164
- const nested = value[objectKey];
165
- return stringField(nested, key);
166
- }
167
-
168
161
  function parseNameWithOwner(value: string | undefined): { readonly owner: string; readonly repo: string } | undefined {
169
162
  if (!value) return undefined;
170
163
  const [owner, repo, extra] = value.split("/");
@@ -172,6 +165,18 @@ function parseNameWithOwner(value: string | undefined): { readonly owner: string
172
165
  return { owner, repo };
173
166
  }
174
167
 
168
+ function parsePullRequestUrl(value: string | undefined): { readonly owner: string; readonly repo: string } | undefined {
169
+ if (!value) return undefined;
170
+ try {
171
+ const parsed = new URL(value);
172
+ const [owner, repo, type, number, extra] = parsed.pathname.split("/").filter(Boolean);
173
+ if (!owner || !repo || type !== "pull" || !number || extra !== undefined) return undefined;
174
+ return { owner, repo };
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+
175
180
  function parsePostedCommentUrl(stdout: string): string | undefined {
176
181
  try {
177
182
  return stringField(JSON.parse(stdout), "html_url");
@@ -3,6 +3,10 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
3
  import type { Pipeline } from "./concurrency.ts";
4
4
  import type { PerfSink, PerfSnapshot } from "./perf.ts";
5
5
  import type { WorkflowProgressSnapshot } from "./progress.ts";
6
+ import type { WorkflowUsageSnapshot } from "./usage.ts";
7
+
8
+ /** A reference to a registered workflow by name. */
9
+ export type WorkflowRef = string;
6
10
 
7
11
  /** Metadata every workflow module must export. */
8
12
  export interface WorkflowMeta {
@@ -29,12 +33,22 @@ export interface WorkflowRunOptions {
29
33
  signal?: AbortSignal;
30
34
  /** Internal recorder override for command/tool invocation timing. */
31
35
  perfRecorder?: PerfSink;
36
+ /** Resolve a sub-workflow reference to a module, enabling `api.workflow()`. When omitted, `api.workflow()` throws. */
37
+ resolveWorkflow?: (ref: WorkflowRef) => Promise<WorkflowModule>;
32
38
  /** Called with the final performance snapshot when perf is enabled. */
33
39
  onPerfSnapshot?: (snapshot: PerfSnapshot) => void;
40
+ /** Called with the final workflow subagent usage snapshot. */
41
+ onUsageSnapshot?: (snapshot: WorkflowUsageSnapshot) => void;
42
+ /** Called with the live progress source while a workflow is running, then undefined when it ends. */
43
+ onProgressSource?: (source: WorkflowProgressSource | undefined) => void;
34
44
  /** Called with the final completed progress snapshot after live workflow UI teardown. */
35
45
  onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void;
36
46
  }
37
47
 
48
+ export interface WorkflowProgressSource {
49
+ snapshot(): WorkflowProgressSnapshot;
50
+ }
51
+
38
52
  /** Options for a single `agent()` call. */
39
53
  export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
40
54
 
@@ -56,7 +70,10 @@ export interface AgentOptions<S extends TSchema = TSchema> {
56
70
  label?: string;
57
71
  /** Phase to group this agent under in the progress tree. */
58
72
  phase?: string;
59
- /** Model id (e.g. "claude-opus-4-5"); omit to inherit the host's current model. */
73
+ /**
74
+ * Optional model id. Omit to inherit the host model. Explicit refs are strict:
75
+ * bare ids resolve as Anthropic shorthand; use "provider/id" for other providers.
76
+ */
60
77
  model?: string;
61
78
  /** Reasoning effort for this agent. */
62
79
  thinkingLevel?: ThinkingLevel;
@@ -75,6 +92,13 @@ export interface WorkflowApi {
75
92
  agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S> | null>;
76
93
  /** Run a subagent and return its final assistant text. */
77
94
  agent(prompt: string, opts?: AgentOptions): Promise<string>;
95
+ /**
96
+ * Run another registered workflow inline as a sub-step and return its result. The child shares
97
+ * this run's concurrency cap, abort signal, and perf sink. Nests one level only: calling
98
+ * `workflow()` from within a sub-workflow rejects. Rejects on unknown name — catch it to handle
99
+ * a missing sub-workflow gracefully.
100
+ */
101
+ workflow(ref: WorkflowRef, args?: string): Promise<unknown>;
78
102
  /** Run every thunk concurrently and wait for all (a barrier). */
79
103
  parallel<T>(thunks: Array<() => Promise<T>>): Promise<T[]>;
80
104
  /** Run each item through all stages independently — no barrier between stages. */