pi-workflow-engine 1.1.0 → 1.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.
Files changed (52) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -78
  2. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
  3. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  4. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
  5. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -3
  6. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  7. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  8. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  9. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  10. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  11. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +114 -31
  12. package/.pi/extensions/pi-workflow-engine/src/engine.ts +74 -18
  13. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
  14. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
  15. package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
  16. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
  17. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
  18. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  19. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  20. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -4
  21. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  22. package/.pi/extensions/pi-workflow-engine/src/types.ts +44 -9
  23. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  24. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  25. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  26. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  27. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  28. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  29. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +17 -6
  30. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  31. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  32. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  33. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  34. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  35. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  44. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
  45. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
  46. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
  47. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
  48. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  49. package/README.md +11 -3
  50. package/USAGE.md +121 -22
  51. package/package.json +13 -4
  52. package/skills/workflow-authoring/SKILL.md +160 -0
@@ -4,11 +4,19 @@ import { getSelectListTheme, keyText, VERSION, type ExtensionAPI, type Extension
4
4
  import type { AutocompleteItem, SelectItem } from "@earendil-works/pi-tui";
5
5
  import type { WorkflowProgressSnapshot } from "./src/progress-types.ts";
6
6
  import type { WorkflowRunState } from "./src/workflow-run-record.ts";
7
- import { WORKFLOW_TOOL_NAME, type LoadedWorkflow, type WorkflowModule, type WorkflowProgressSource, type WorkflowRef } from "./src/types.ts";
7
+ import {
8
+ WORKFLOW_TOOL_NAME,
9
+ type LoadedWorkflow,
10
+ type WorkflowModule,
11
+ type WorkflowProgressSource,
12
+ type WorkflowRef,
13
+ type WorkflowRunMetadata,
14
+ } from "./src/types.ts";
8
15
  import { showWorkflowInspector } from "./src/ui/workflow-inspector.ts";
9
16
  import { WorkflowPicker } from "./src/ui/workflow-picker.ts";
10
17
  import type { PerfSink } from "./src/perf.ts";
11
- import { ADAPTIVE_WORKFLOW_GUIDANCE, registerDynamax } from "./src/dynamax.ts";
18
+ import type { DiscoverWorkflowsOptions } from "./src/discovery.ts";
19
+ import { registerDynamax, resolveTurnBudget, type DynamaxHandle } from "./src/dynamax.ts";
12
20
  import { sessionKey } from "./src/session-identity.ts";
13
21
  import { resolveDynamaxShortcuts, type DynamaxShortcuts } from "./src/dynamax-shortcuts.ts";
14
22
  import { ReviewSessionCoordinator } from "./src/review/review-session-coordinator.ts";
@@ -32,7 +40,7 @@ import {
32
40
  WORKFLOW_USAGE_LIMIT_DELAY_MIN_MS,
33
41
  } from "./src/options.ts";
34
42
  import { executeWorkflowInvocation, type WorkflowResultEnvelope } from "./src/workflow-execution.ts";
35
- import { formatWorkflowResultForContext } from "./src/workflow-result-context.ts";
43
+ import { formatWorkflowResultForContext, formatWorkflowRunFileLines, formatWorkflowRunLine } from "./src/workflow-result-context.ts";
36
44
  import { registerWorkflowModelProfileCommand } from "./src/model-profile-command.ts";
37
45
  import { BackgroundWorkflowCoordinator } from "./src/background-workflows.ts";
38
46
  import { backgroundUnavailableResult, startBackgroundWorkflowTool } from "./src/background-workflow-tool.ts";
@@ -43,13 +51,22 @@ import { formatWorkflowInspection, workflowInspectionSnapshot } from "./src/ui/w
43
51
  import { workflowUsageForPi, type WorkflowUsageSnapshot } from "./src/usage.ts";
44
52
  import { unknownErrorMessage } from "./src/unknown-error.ts";
45
53
  import { createHostToolBridge } from "./src/host-tools.ts";
54
+ import { appendSectionToForcedPrompt } from "./src/prompt-sections.ts";
55
+ import { WORKFLOWS_SECTION, WorkflowCatalogPrompt } from "./src/workflow-catalog.ts";
56
+ import { registerWorkflowSizeCommand, resolveWorkflowSize, workflowSettingsPaths } from "./src/workflow-settings.ts";
57
+ import { isRecord } from "./src/guards.ts";
58
+ import { INVALID_WORKFLOW_INVOCATION_MESSAGE, normalizeWorkflowToolRequest, workflowToolReach } from "./src/workflow-tool-request.ts";
59
+ import { workflowArgsText } from "./src/workflow-input.ts";
46
60
 
47
61
  /** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
48
62
  const EXTENSION_DIR = fileURLToPath(new URL(".", import.meta.url));
63
+ /** The package's authoring skill, named by path so the guideline still resolves when skills are filtered out. */
64
+ const WORKFLOW_AUTHORING_SKILL_PATH = fileURLToPath(new URL("../../../skills/workflow-authoring/SKILL.md", import.meta.url));
49
65
 
50
66
  type DiscoveryModule = typeof import("./src/discovery.ts");
51
67
  type EngineModule = typeof import("./src/engine.ts");
52
68
  type InlineWorkflowModule = typeof import("./src/inline-workflow.ts");
69
+ type WorkflowScriptFileModule = typeof import("./src/workflow-script-file.ts");
53
70
 
54
71
  async function loadDiscovery(): Promise<DiscoveryModule> {
55
72
  return await import("./src/discovery.ts");
@@ -63,18 +80,47 @@ async function loadInlineWorkflow(): Promise<InlineWorkflowModule> {
63
80
  return await import("./src/inline-workflow.ts");
64
81
  }
65
82
 
83
+ async function loadWorkflowScriptFile(): Promise<WorkflowScriptFileModule> {
84
+ return await import("./src/workflow-script-file.ts");
85
+ }
86
+
66
87
  async function createInvocationPerf(options: ResolvedWorkflowRunOptions): Promise<PerfSink | undefined> {
67
88
  if (!options.perf) return undefined;
68
89
  const { createPerfRecorder } = await import("./src/perf.ts");
69
90
  return createPerfRecorder(true);
70
91
  }
71
92
 
93
+ /** What workflow discovery reads from a session: its cwd, and whether its project's saved scripts may load. */
94
+ export type WorkflowDiscoveryContext = Pick<ExtensionContext, "cwd" | "isProjectTrusted">;
95
+
96
+ /** Project `.pi/workflows` scripts load only in a project pi trusts, like pi's own project resources. */
97
+ function projectWorkflowDir(ctx: WorkflowDiscoveryContext): string | undefined {
98
+ return ctx.isProjectTrusted() ? ctx.cwd : undefined;
99
+ }
100
+
101
+ /** Every workflow a session can run, with the project-trust gate applied in this one place. */
102
+ async function discoverForSession(
103
+ ctx: WorkflowDiscoveryContext,
104
+ options: Omit<DiscoverWorkflowsOptions, "projectDir"> = {},
105
+ ): Promise<Map<string, LoadedWorkflow>> {
106
+ const { discoverWorkflows } = await loadDiscovery();
107
+ return await discoverWorkflows(EXTENSION_DIR, { ...options, projectDir: projectWorkflowDir(ctx) });
108
+ }
109
+
72
110
  /**
73
- * Resolve an `api.workflow()` reference to a registered workflow module. Throws on an unknown name.
111
+ * Resolve an `api.workflow()` reference: a registered workflow by name, or an inline-format script file
112
+ * via `{ scriptPath }` relative to the cwd. Throws on an unknown name, an unreadable scriptPath, or a
113
+ * compile error.
74
114
  */
75
- export async function resolveWorkflowRef(ref: WorkflowRef, perf?: PerfSink): Promise<LoadedWorkflow> {
76
- const { discoverWorkflows } = await loadDiscovery();
77
- const workflows = await discoverWorkflows(EXTENSION_DIR, { perf });
115
+ export async function resolveWorkflowRef(ctx: WorkflowDiscoveryContext, ref: WorkflowRef, perf?: PerfSink): Promise<LoadedWorkflow> {
116
+ if (typeof ref !== "string") {
117
+ // Inline scripts are untyped, so check the shape the type promises.
118
+ const scriptPath: unknown = isRecord(ref) ? ref.scriptPath : undefined;
119
+ if (typeof scriptPath !== "string") throw new Error("workflow() expects a workflow name or { scriptPath }");
120
+ const { compileWorkflowScriptFile } = await loadWorkflowScriptFile();
121
+ return await compileWorkflowScriptFile(ctx.cwd, scriptPath);
122
+ }
123
+ const workflows = await discoverForSession(ctx, { perf });
78
124
  const mod = workflows.get(ref);
79
125
  if (!mod) {
80
126
  const available = [...workflows.keys()].join(", ") || "(none)";
@@ -89,7 +135,7 @@ const AUTHOR_TEMP_WORKFLOW_DESCRIPTION = "Ask the host agent to author and run a
89
135
 
90
136
  const WORKFLOW_OPTION_COMPLETIONS = [
91
137
  { value: "--inspect", description: "Open the live workflow inspector" },
92
- { value: "--refresh", description: "Refresh dynamic workflow discovery" },
138
+ { value: "--refresh", description: "Reload .ts drop-in workflows" },
93
139
  { value: "--perf", description: "Collect workflow performance metrics" },
94
140
  { value: "--result-viewer", description: "Open supported result viewers" },
95
141
  { value: "--no-result-viewer", description: "Skip supported result viewers" },
@@ -103,11 +149,12 @@ const WORKFLOW_OPTION_COMPLETIONS = [
103
149
  { value: "--resume=", description: "Resume from a retained workflow run ID" },
104
150
  ] as const;
105
151
 
106
- async function workflowArgumentCompletions(argumentPrefix: string): Promise<AutocompleteItem[] | null> {
152
+ /** Completions get no context, so the project's scripts come from the active session's tracked dir. */
153
+ async function workflowArgumentCompletions(argumentPrefix: string, projectDir: string | undefined): Promise<AutocompleteItem[] | null> {
107
154
  const parts = splitArgumentPrefix(argumentPrefix);
108
155
  if (parts.completed.length === 0) {
109
156
  const { discoverWorkflows } = await loadDiscovery();
110
- const workflows = await discoverWorkflows(EXTENSION_DIR);
157
+ const workflows = await discoverWorkflows(EXTENSION_DIR, { projectDir });
111
158
  return completeCurrentArgument(
112
159
  argumentPrefix,
113
160
  [...workflows.values()].map((workflow) => ({
@@ -119,6 +166,21 @@ async function workflowArgumentCompletions(argumentPrefix: string): Promise<Auto
119
166
  return completeCurrentArgument(argumentPrefix, WORKFLOW_OPTION_COMPLETIONS);
120
167
  }
121
168
 
169
+ /**
170
+ * The active session's trusted project dir, kept for surfaces pi calls without a context. Session
171
+ * replacement invalidates a context, so only this value is kept.
172
+ */
173
+ function trackProjectWorkflowDir(pi: ExtensionAPI): () => string | undefined {
174
+ let projectDir: string | undefined;
175
+ pi.on("session_start", (_event, ctx) => {
176
+ projectDir = projectWorkflowDir(ctx);
177
+ });
178
+ pi.on("session_shutdown", () => {
179
+ projectDir = undefined;
180
+ });
181
+ return () => projectDir;
182
+ }
183
+
122
184
  /** Registered workflows in discovery order (built-ins first), then the author option. */
123
185
  async function selectWorkflowValue(workflows: ReadonlyMap<string, WorkflowModule>, ctx: ExtensionCommandContext): Promise<string | undefined> {
124
186
  const workflowItems: SelectItem[] = [...workflows.values()].map((workflow) => ({
@@ -152,6 +214,7 @@ export interface ActiveWorkflowInspection {
152
214
  readonly startedAt: number;
153
215
  readonly snapshot: () => WorkflowProgressSnapshot;
154
216
  readonly state?: () => WorkflowRunState;
217
+ readonly skipAgent?: (agentId: number) => boolean;
155
218
  }
156
219
 
157
220
  interface SessionWorkflowInspections {
@@ -166,7 +229,9 @@ export async function openWorkflowInspector(ctx: ExtensionContext, inspection: L
166
229
  ctx.ui.notify(formatWorkflowInspection(inspection), "info");
167
230
  return;
168
231
  }
169
- await showWorkflowInspector(ctx.ui, () => workflowInspectionSnapshot(inspection), () => ({ state: inspection.state?.() }));
232
+ // Only a live inspection can skip agents; a finished one is a snapshot.
233
+ const controls = "skipAgent" in inspection ? { skipAgent: inspection.skipAgent } : undefined;
234
+ await showWorkflowInspector(ctx.ui, () => workflowInspectionSnapshot(inspection), () => ({ state: inspection.state?.() }), controls);
170
235
  }
171
236
 
172
237
  function workflowInspectionState(pi: ExtensionAPI, ctx: ExtensionContext): SessionWorkflowInspections {
@@ -189,7 +254,7 @@ async function openAvailableWorkflowInspector(pi: ExtensionAPI, ctx: ExtensionCo
189
254
  }
190
255
 
191
256
  function bindActiveWorkflowInspection(name: string, args: string, source: WorkflowProgressSource): ActiveWorkflowInspection {
192
- return { name, args, startedAt: Date.now(), snapshot: () => source.snapshot(), state: source.state };
257
+ return { name, args, startedAt: Date.now(), snapshot: () => source.snapshot(), state: source.state, skipAgent: source.skipAgent };
193
258
  }
194
259
 
195
260
  export function buildTemporaryWorkflowAuthorPrompt(brief: string): string {
@@ -198,22 +263,10 @@ export function buildTemporaryWorkflowAuthorPrompt(brief: string): string {
198
263
  User brief:
199
264
  ${brief.trim()}
200
265
 
201
- Use the workflow tool with a script argument, not a saved workflow name, and follow its guidelines for inline scripts.
266
+ Use the workflow tool with a script argument, not a saved workflow name, and read the workflow-authoring skill before writing the script.
202
267
  Do not edit files unless the user explicitly requested edits.`;
203
268
  }
204
269
 
205
- export interface WorkflowToolRequestParams {
206
- readonly name?: string;
207
- readonly script?: string;
208
- readonly resumeFromRunId?: string;
209
- readonly background?: boolean;
210
- }
211
-
212
- export type WorkflowToolRequest =
213
- | { readonly kind: "named"; readonly name: string }
214
- | { readonly kind: "inline"; readonly script: string }
215
- | { readonly kind: "error"; readonly error: "invalid_workflow_invocation"; readonly message: string };
216
-
217
270
  export interface WorkflowToolErrorResult<TDetails = WorkflowToolErrorDetails> {
218
271
  readonly content: Array<{ readonly type: "text"; readonly text: string }>;
219
272
  readonly details: TDetails;
@@ -221,23 +274,15 @@ export interface WorkflowToolErrorResult<TDetails = WorkflowToolErrorDetails> {
221
274
  readonly isError: true;
222
275
  }
223
276
 
224
- type WorkflowToolErrorDetails = { readonly error: "invalid_workflow_invocation" } | { readonly error: "inline_compile_error"; readonly message: string };
277
+ type WorkflowToolErrorDetails =
278
+ | { readonly error: "invalid_workflow_invocation" }
279
+ | { readonly error: "inline_compile_error"; readonly message: string }
280
+ | { readonly error: "invalid_script_path"; readonly message: string };
225
281
 
226
282
  function workflowToolError<TDetails>(text: string, details: TDetails): WorkflowToolErrorResult<TDetails> {
227
283
  return { content: [{ type: "text", text }], details, isError: true };
228
284
  }
229
285
 
230
- const INVALID_WORKFLOW_INVOCATION_MESSAGE = "Provide exactly one workflow name or inline workflow script.";
231
-
232
- export function normalizeWorkflowToolRequest(params: WorkflowToolRequestParams): WorkflowToolRequest {
233
- const name = params.name?.trim() ?? "";
234
- const script = params.script?.trim() ?? "";
235
- const hasName = name.length > 0;
236
- const hasScript = script.length > 0;
237
- if (hasName === hasScript) return { kind: "error", error: "invalid_workflow_invocation", message: INVALID_WORKFLOW_INVOCATION_MESSAGE };
238
- return hasName ? { kind: "named", name } : { kind: "inline", script };
239
- }
240
-
241
286
  export function invalidWorkflowInvocationResult(): WorkflowToolErrorResult {
242
287
  return workflowToolError(INVALID_WORKFLOW_INVOCATION_MESSAGE, { error: "invalid_workflow_invocation" });
243
288
  }
@@ -246,6 +291,27 @@ export function inlineCompileErrorResult(message: string): WorkflowToolErrorResu
246
291
  return workflowToolError(`Inline workflow did not compile: ${message}`, { error: "inline_compile_error", message });
247
292
  }
248
293
 
294
+ function invalidScriptPathResult(message: string): WorkflowToolErrorResult {
295
+ return workflowToolError(message, { error: "invalid_script_path", message });
296
+ }
297
+
298
+ /** Compile the tool's inline `script` or `scriptPath` file, shaping contract failures as tool errors. */
299
+ async function compileInlineToolWorkflow(
300
+ cwd: string,
301
+ request: { readonly kind: "inline"; readonly script: string } | { readonly kind: "script-path"; readonly scriptPath: string },
302
+ ): Promise<LoadedWorkflow | WorkflowToolErrorResult> {
303
+ const [inline, scriptFile] = await Promise.all([loadInlineWorkflow(), loadWorkflowScriptFile()]);
304
+ try {
305
+ return request.kind === "inline"
306
+ ? inline.compileInlineWorkflow(request.script)
307
+ : await scriptFile.compileWorkflowScriptFile(cwd, request.scriptPath);
308
+ } catch (error) {
309
+ if (error instanceof scriptFile.WorkflowScriptPathError) return invalidScriptPathResult(error.message);
310
+ if (error instanceof inline.InlineWorkflowCompileError) return inlineCompileErrorResult(error.message);
311
+ throw error;
312
+ }
313
+ }
314
+
249
315
  export type WorkflowPickerSelection =
250
316
  | { readonly kind: "run"; readonly name: string; readonly args: string }
251
317
  | { readonly kind: "author"; readonly brief: string };
@@ -288,27 +354,29 @@ export async function sendWorkflowResult(
288
354
  await reviewSessions.present(ctx, retained, options);
289
355
  }
290
356
 
357
+ /** `input` is a slash command's argument string, or a tool call's string or JSON value. */
291
358
  async function executeResolvedWorkflow(
292
359
  pi: ExtensionAPI,
293
360
  ctx: ExtensionContext,
294
361
  name: string,
295
362
  mod: LoadedWorkflow,
296
- args: string,
363
+ input: unknown,
297
364
  options: ResolvedWorkflowRunOptions,
298
365
  perfRecorder?: PerfSink,
299
366
  ): Promise<WorkflowResultEnvelope> {
300
367
  const { runResolvedWorkflow } = await loadEngine();
301
368
  let liveInspection: ActiveWorkflowInspection | undefined;
302
369
  const inspections = workflowInspectionState(pi, ctx);
370
+ const args = workflowArgsText(input);
303
371
  return await executeWorkflowInvocation({
304
372
  ctx,
305
373
  name,
306
374
  mod,
307
- args,
375
+ args: input,
308
376
  options,
309
377
  perfRecorder,
310
378
  runResolvedWorkflow,
311
- resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
379
+ resolveWorkflow: (ref) => resolveWorkflowRef(ctx, ref, perfRecorder),
312
380
  onProgressSource(source) {
313
381
  if (source) {
314
382
  liveInspection = bindActiveWorkflowInspection(name, args, source);
@@ -351,10 +419,10 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
351
419
  assertSupportedPiVersion(VERSION);
352
420
  const reviewSessions = createReviewSessionCoordinator(pi);
353
421
  const backgroundWorkflows = new BackgroundWorkflowCoordinator(pi);
422
+ const sessionProjectDir = trackProjectWorkflowDir(pi);
354
423
  const workflowRuns = new WorkflowRunController(backgroundWorkflows, {
355
- async resolveWorkflow(name) {
356
- const { discoverWorkflows } = await loadDiscovery();
357
- return (await discoverWorkflows(EXTENSION_DIR)).get(name);
424
+ async resolveWorkflow(name, ctx) {
425
+ return (await discoverForSession(ctx)).get(name);
358
426
  },
359
427
  async execute(ctx, name, workflow, options) {
360
428
  const perfRecorder = await createInvocationPerf(options);
@@ -363,6 +431,15 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
363
431
  },
364
432
  });
365
433
  backgroundWorkflows.onRunSettled((ctx, runId) => workflowRuns.runSettled(ctx, runId));
434
+ const workflowCatalog = new WorkflowCatalogPrompt({
435
+ async discover(ctx) {
436
+ return [...(await discoverForSession(ctx)).values()].map((workflow) => workflow.meta);
437
+ },
438
+ size: (ctx) => resolveWorkflowSize(workflowSettingsPaths(ctx.cwd)).size,
439
+ });
440
+ // Before Dynamax, so the stable catalog section precedes the per-turn opt-in section.
441
+ registerWorkflowCatalog(pi, workflowCatalog);
442
+ registerWorkflowSizeCommand(pi, (ctx) => workflowCatalog.forget(ctx));
366
443
  const dynamax = registerDynamax(pi, shortcuts, { openInspector: (ctx) => openAvailableWorkflowInspector(pi, ctx) });
367
444
  registerWorkflowModelProfileCommand(pi);
368
445
  registerWorkflowRunCommand(pi, workflowRuns);
@@ -423,7 +500,7 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
423
500
  // /workflow <name> [args] — user-invoked.
424
501
  pi.registerCommand("workflow", {
425
502
  description: "Run a multi-agent workflow: /workflow <name> [args]",
426
- getArgumentCompletions: workflowArgumentCompletions,
503
+ getArgumentCompletions: (argumentPrefix) => workflowArgumentCompletions(argumentPrefix, sessionProjectDir()),
427
504
  handler: async (args: string, ctx: ExtensionCommandContext) => {
428
505
  const invocation = parseWorkflowInvocation(args);
429
506
  if (invocation.optionErrors.length > 0) {
@@ -432,8 +509,7 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
432
509
  }
433
510
  const options = resolveWorkflowRunOptions(invocation.options);
434
511
  const perfRecorder = await createInvocationPerf(options);
435
- const { discoverWorkflows } = await loadDiscovery();
436
- const workflows = await discoverWorkflows(EXTENSION_DIR, { refresh: invocation.refreshDiscovery, perf: perfRecorder });
512
+ const workflows = await discoverForSession(ctx, { refresh: invocation.refreshDiscovery, perf: perfRecorder });
437
513
  const available = [...workflows.keys()].join(", ") || "(none)";
438
514
  if (!invocation.name && !ctx.hasUI) {
439
515
  ctx.ui.notify(`Usage: /workflow <name> [args]. Available: ${available}`, "warning");
@@ -463,7 +539,22 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
463
539
  },
464
540
  });
465
541
 
466
- registerWorkflowTool(pi, reviewSessions, backgroundWorkflows, shortcuts);
542
+ registerWorkflowTool(pi, reviewSessions, backgroundWorkflows, shortcuts, dynamax);
543
+ }
544
+
545
+ /** Add the `<workflows>` catalog to every run that can call the workflow tool. */
546
+ function registerWorkflowCatalog(pi: ExtensionAPI, catalog: WorkflowCatalogPrompt): void {
547
+ pi.on("session_start", (_event, ctx) => catalog.forget(ctx));
548
+ pi.on("session_shutdown", (_event, ctx) => catalog.forget(ctx));
549
+ pi.on("before_agent_start", async (event, ctx) => {
550
+ const options = event.systemPromptOptions;
551
+ // Subagent sessions withhold the workflow tool, so they skip the catalog as well.
552
+ if (!options.selectedTools.includes(WORKFLOW_TOOL_NAME)) return undefined;
553
+ const text = await catalog.section(ctx);
554
+ if (text === undefined) return undefined;
555
+ options.sections[WORKFLOWS_SECTION] = text;
556
+ return appendSectionToForcedPrompt(options, WORKFLOWS_SECTION);
557
+ });
467
558
  }
468
559
 
469
560
  /** Result footers advertise the configured triage and inspector shortcuts and pi's current expand key, read at render time. */
@@ -481,6 +572,7 @@ function registerWorkflowTool(
481
572
  reviewSessions: ReviewSessionCoordinator,
482
573
  backgroundWorkflows: BackgroundWorkflowCoordinator,
483
574
  shortcuts: DynamaxShortcuts,
575
+ dynamax: DynamaxHandle,
484
576
  ): void {
485
577
  pi.registerTool({
486
578
  name: WORKFLOW_TOOL_NAME,
@@ -492,31 +584,34 @@ function registerWorkflowTool(
492
584
  // run would lose its tool row, hit the script's output cap, and parallel runs would each get their own
493
585
  // concurrency cap. It also stays declared under `codemode.mode: "only"`, which hides direct tools.
494
586
  exposure: "model-only",
587
+ reachesTools: workflowToolReach,
495
588
  promptGuidelines: [
496
- "Use workflow only when the user opted into workflow orchestration via `dynamax`, `/workflow:dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.",
497
- "Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
498
- "Use workflow with `script` for a new one-off inline workflow; the script must start with `export const meta = { ... }` and default-export an async workflow function.",
499
- "Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
500
- "Inline scripts may compose registered workflows in-process via `api.workflow(\"<name>\", args)` (e.g. `await api.workflow(\"code-review\", \"HEAD~3\")`); it returns the sub-workflow's result and nests one level only.",
501
- "Subagents receive no skills by default. In inline workflows, pass `skills: [\"skill-name\"]` per `agent()` call when the user asks for a skill or a stage should use one; grant only the needed skills.",
589
+ "Use workflow only when the user opted into workflow orchestration via `dynamax`, sticky `/workflow:dynamax`, an explicit request to run/author a workflow, or a command/skill instruction.",
590
+ `Before writing or editing an inline workflow script, read the workflow-authoring skill (${WORKFLOW_AUTHORING_SKILL_PATH}); it is the script API and pattern reference.`,
591
+ "Every workflow tool call must provide exactly one of `name`, `script`, or `scriptPath`.",
592
+ "Inline workflow scripts must use the injected `Type` global for schemas and must not contain imports or dynamic import().",
502
593
  "Set `profile: \"small\"`, `\"medium\"`, or `\"big\"` on every `api.agent()` call so the user's central model routes apply; use `model`/`thinkingLevel` only for an intentional override.",
503
- "Always pass a plain string as the first `api.agent()` argument; build prompts with template strings before calling agent().",
504
594
  "When using `isolation: \"worktree\"`, `api.agent()` returns `{ result, patch, changed }`; use `.result` for the answer and `.patch` for the isolated diff.",
505
- "If an inline subagent needs grep/find/code-search helpers, use `tools: [\"read\", \"bash\", \"grep\", \"find\", \"ls\"]` plus `toolHints: [\"search\"]` so installed tools such as ast-grep, mgrep, ffgrep, or fffind are discovered dynamically.",
506
- "Add \"codemode\" to an inline agent's `tools` when it should batch or filter many tool calls in one script; its scripts can call only that agent's other allowed tools.",
507
- "`api.classify({ state, questions }, { label })` answers typed choice/score/bool questions with a classifier model and no chat session; use it for cheap gates such as dedup or triage, and handle its rejection when no classifier is configured.",
508
- "Host MCP tools reach subagents only in synchronous workflow tool runs: name them in `tools` (e.g. `mcp__server__tool`), or use `toolHints: [\"external-search\"]` to pick up MCP web research tools.",
509
- "`api.budget` exposes `{ total, spent(), remaining() }` (output tokens). When the run is budgeted, scale fleets from `budget.total` and guard loops with `while (budget.total && budget.remaining() > N) { await api.agent(...) }`; `api.agent()` throws once the ceiling is reached.",
510
- ADAPTIVE_WORKFLOW_GUIDANCE,
595
+ "Host MCP tools reach subagents only in synchronous workflow tool runs, never in background runs.",
511
596
  "Set background: true only when the user explicitly wants the workflow to continue after this tool call; the tool returns a durable run ID and completion is delivered later.",
512
597
  "Set autoResumeOnUsageLimit: true only for an explicitly backgrounded workflow when the user wants bounded automatic continuation after a recognized provider usage window.",
513
598
  "Set resumeEditedWorkflow: true only with resumeFromRunId when the user explicitly accepts reusing behaviorally identical calls after workflow source edits.",
514
- "Every workflow tool call must provide exactly one of `name` or `script`, never both.",
515
599
  ],
516
600
  parameters: Type.Object({
517
- name: Type.Optional(Type.String({ description: "Workflow name, e.g. code-review. Provide exactly one of name or script." })),
518
- script: Type.Optional(Type.String({ description: "Inline workflow script. Provide exactly one of script or name." })),
519
- args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
601
+ name: Type.Optional(Type.String({ description: "Workflow name, e.g. code-review. Provide exactly one of name, script, or scriptPath." })),
602
+ script: Type.Optional(Type.String({ description: "Inline workflow script. Provide exactly one of name, script, or scriptPath." })),
603
+ scriptPath: Type.Optional(
604
+ Type.String({
605
+ description:
606
+ "Path, relative to the working directory, of a file holding an inline workflow script. To iterate, edit in place the persisted .pi/.workflow-runs/<runId>.script.js a result names and rerun it here with resumeFromRunId, which accepts the edits as with resumeEditedWorkflow; editing any other copy in the repository, or a .pi/workflows script, changes repository state, so shared read-only agents rerun live. Provide exactly one of name, script, or scriptPath.",
607
+ }),
608
+ ),
609
+ args: Type.Optional(
610
+ Type.Unknown({
611
+ description:
612
+ "Arguments for the workflow: a string (e.g. target or focus) or any JSON value. The workflow reads the value as `api.input` and its text (JSON for non-strings) as `api.args`.",
613
+ }),
614
+ ),
520
615
  concurrency: Type.Optional(Type.Number({ description: "Optional per-run agent concurrency cap" })),
521
616
  parallelSubmissionLimit: Type.Optional(Type.Number({ description: "Optional limit for eagerly submitted parallel thunks" })),
522
617
  maxAgents: Type.Optional(
@@ -585,6 +680,14 @@ function registerWorkflowTool(
585
680
  const unavailable = backgroundUnavailableResult(ctx.mode);
586
681
  if (unavailable) return unavailable;
587
682
  }
683
+ const turnBudget = dynamax.turnBudget(ctx);
684
+ const budget = resolveTurnBudget(params.budget, turnBudget, { min: WORKFLOW_BUDGET_MIN, max: WORKFLOW_BUDGET_MAX });
685
+ if (budget.kind === "spent") {
686
+ return workflowToolError(
687
+ `The user's +${budget.total} output-token budget for this turn is spent by its earlier workflow runs. Do not start another workflow this turn unless the user raises the budget.`,
688
+ { error: "turn_budget_spent", total: budget.total },
689
+ );
690
+ }
588
691
 
589
692
  const runOptions = resolveWorkflowRunOptions({
590
693
  concurrency: params.concurrency,
@@ -595,10 +698,11 @@ function registerWorkflowTool(
595
698
  autoResumeOnUsageLimit: params.autoResumeOnUsageLimit,
596
699
  usageLimitMaxAttempts: params.usageLimitMaxAttempts,
597
700
  usageLimitMaxDelayMs: params.usageLimitMaxDelayMs,
598
- budget: params.budget,
701
+ budget: budget.budget,
599
702
  perf: params.perf,
600
703
  resumeFromRunId,
601
- resumeEditedWorkflow: params.resumeEditedWorkflow,
704
+ // Rerunning an edited script file is the point of scriptPath; each call's own replay identity still guards reuse.
705
+ resumeEditedWorkflow: params.resumeEditedWorkflow === true || request.kind === "script-path",
602
706
  signal,
603
707
  });
604
708
  const perfRecorder = await createInvocationPerf(runOptions);
@@ -606,8 +710,7 @@ function registerWorkflowTool(
606
710
  let resultName: string;
607
711
 
608
712
  if (request.kind === "named") {
609
- const { discoverWorkflows } = await loadDiscovery();
610
- const workflows = await discoverWorkflows(EXTENSION_DIR, { perf: perfRecorder });
713
+ const workflows = await discoverForSession(ctx, { perf: perfRecorder });
611
714
  const named = workflows.get(request.name);
612
715
  if (!named) {
613
716
  const available = [...workflows.keys()].join(", ") || "(none)";
@@ -616,23 +719,22 @@ function registerWorkflowTool(
616
719
  mod = named;
617
720
  resultName = request.name;
618
721
  } else {
619
- const inline = await loadInlineWorkflow();
620
- try {
621
- mod = inline.compileInlineWorkflow(request.script);
622
- } catch (error) {
623
- if (error instanceof inline.InlineWorkflowCompileError) return inlineCompileErrorResult(error.message);
624
- throw error;
625
- }
722
+ const compiled = await compileInlineToolWorkflow(ctx.cwd, request);
723
+ if ("isError" in compiled) return compiled;
724
+ mod = compiled;
626
725
  resultName = mod.meta.name;
627
726
  }
628
727
 
629
- const resultArgs = params.args ?? "";
728
+ // Omitted args match a slash command without arguments; any other value, null included, is kept verbatim.
729
+ const resultArgs: unknown = params.args === undefined ? "" : params.args;
630
730
  if (params.background) {
631
731
  return await startBackgroundWorkflowTool({
632
732
  coordinator: backgroundWorkflows,
633
733
  ctx,
634
734
  name: resultName,
635
735
  options: runOptions,
736
+ // Under Dynamax the agent is orchestrating, so its completion starts a turn to act on the result.
737
+ wakeOnComplete: dynamax.isActive(ctx),
636
738
  async execute(backgroundCtx, backgroundOptions) {
637
739
  const envelope = await executeResolvedWorkflow(
638
740
  pi,
@@ -657,15 +759,34 @@ function registerWorkflowTool(
657
759
  spent = snapshot;
658
760
  return runOptions.onUsageSnapshot?.(snapshot);
659
761
  };
762
+ // A failed run still names its run, script and journal, so the host can fix the script and resume.
763
+ let run: WorkflowRunMetadata | undefined;
764
+ const onRunMetadata = (metadata: WorkflowRunMetadata) => {
765
+ run = metadata;
766
+ return runOptions.onRunMetadata?.(metadata);
767
+ };
660
768
  let envelope: WorkflowResultEnvelope;
661
769
  try {
662
- envelope = await executeResolvedWorkflow(pi, ctx, resultName, mod, resultArgs, { ...runOptions, hostTools, onUsageSnapshot }, perfRecorder);
770
+ envelope = await executeResolvedWorkflow(
771
+ pi,
772
+ ctx,
773
+ resultName,
774
+ mod,
775
+ resultArgs,
776
+ { ...runOptions, hostTools, onUsageSnapshot, onRunMetadata },
777
+ perfRecorder,
778
+ );
663
779
  } catch (error) {
664
780
  const message = unknownErrorMessage(error);
781
+ const lines = [`Workflow ${resultName} failed: ${message}`];
782
+ if (run) lines.push("", formatWorkflowRunLine(run), ...formatWorkflowRunFileLines(run.runId, run.scriptPath !== undefined));
665
783
  return {
666
- ...workflowToolError(`Workflow ${resultName} failed: ${message}`, { error: "workflow_failed", name: resultName, message }),
784
+ ...workflowToolError(lines.join("\n"), { error: "workflow_failed", name: resultName, message }),
667
785
  usage: workflowUsageForPi(spent),
668
786
  };
787
+ } finally {
788
+ // Only synchronous runs draw the turn pool down; a background run's spend lands after the turn.
789
+ turnBudget?.spend(spent?.totals.output ?? 0);
669
790
  }
670
791
  reviewSessions.remember(ctx, envelope, runOptions);
671
792
  return {
@@ -38,10 +38,12 @@ export type CreateAgentSession = (options: CreateAgentSessionOptions) => Promise
38
38
  /** Every row update after `agentQueued` addresses that row by its id; labels are display text, not unique keys. */
39
39
  export interface AgentProgress {
40
40
  agentQueued(phase: string | undefined, label: string): number;
41
- agentStart(id: number): void;
41
+ /** `skip` aborts the row's live agent; the tracker offers it to the inspector only while the row runs. */
42
+ agentStart(id: number, skip?: () => void): void;
42
43
  agentTool(id: number, tool: string): void;
43
44
  agentDone(id: number): void;
44
45
  agentFailed(id: number, error: unknown): void;
46
+ agentSkipped(id: number): void;
45
47
  event(event: WorkflowProgressEvent): void;
46
48
  log(message: string): void;
47
49
  }
@@ -1,6 +1,6 @@
1
1
  import { assertWorkflowBudgetAvailable } from "./budget.ts";
2
2
  import { WorkflowAgentTimeoutError } from "./agent-limits.ts";
3
- import { abortReason, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
3
+ import { abortReason, linkAbortSignal, throwIfAborted, WorkflowAgentSkippedError } from "./cancellation.ts";
4
4
  import { executeAgentAttempt } from "./agent-attempt.ts";
5
5
  import {
6
6
  createAgentReplayPlan,
@@ -66,7 +66,7 @@ export async function runAgent(
66
66
  const agentRc: RunContext = { ...rc, signal: liveScope.signal };
67
67
  throwIfAborted(agentRc.signal);
68
68
  if (!isReplayEnabled(replay)) assertWorkflowBudgetAvailable(agentRc.budget);
69
- rc.progress.agentStart(rowId);
69
+ rc.progress.agentStart(rowId, liveScope.skip);
70
70
  if (replay.kind === "disabled") {
71
71
  rc.progress.log(`${label}: resume disabled for this call (${replay.reason})`);
72
72
  }
@@ -107,6 +107,8 @@ export async function runAgent(
107
107
  await agentRc.retryScheduler.sleep(delayMs, agentRc.signal);
108
108
  continue;
109
109
  }
110
+ // A skip that lands as the attempt finishes still wins, so its result is neither returned nor journaled.
111
+ liveScope.throwIfSkipped();
110
112
  const settlement = await settleAgentAttempt({ rc: agentRc, label, outcome });
111
113
  if (settlement.kind === "retry-live") {
112
114
  attemptPlan = { kind: "off" };
@@ -125,8 +127,13 @@ export async function runAgent(
125
127
  let failure = error;
126
128
  if (liveScope.signal.aborted) failure = abortReason(liveScope.signal);
127
129
  if (rc.signal?.aborted) failure = abortReason(rc.signal);
128
- rc.progress.agentFailed(rowId, failure);
129
- rc.progress.log(`${label} failed: ${unknownErrorMessage(failure)}`);
130
+ if (failure instanceof WorkflowAgentSkippedError) {
131
+ rc.progress.agentSkipped(rowId);
132
+ rc.progress.log(`${label} skipped`);
133
+ } else {
134
+ rc.progress.agentFailed(rowId, failure);
135
+ rc.progress.log(`${label} failed: ${unknownErrorMessage(failure)}`);
136
+ }
130
137
  throw failure;
131
138
  } finally {
132
139
  liveScope.dispose();
@@ -151,6 +158,13 @@ function createAgentLiveScope(rc: RunContext, label: string) {
151
158
  rc.agentTimeoutMs,
152
159
  );
153
160
  },
161
+ /** Aborts only this agent; the run and its siblings carry on. */
162
+ skip() {
163
+ controller.abort(new WorkflowAgentSkippedError(label));
164
+ },
165
+ throwIfSkipped() {
166
+ if (controller.signal.reason instanceof WorkflowAgentSkippedError) throw controller.signal.reason;
167
+ },
154
168
  dispose() {
155
169
  if (timer) clearTimeout(timer);
156
170
  unlink();