pi-workflow-engine 1.0.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 (59) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +236 -94
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
  3. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +10 -3
  4. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  5. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +71 -23
  6. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +12 -3
  7. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  8. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  9. package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
  10. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  11. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  12. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  13. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -40
  14. package/.pi/extensions/pi-workflow-engine/src/engine.ts +78 -18
  15. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
  16. package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
  17. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
  18. package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
  19. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
  20. package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
  21. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
  22. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
  23. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  24. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  25. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +8 -6
  26. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  27. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
  28. package/.pi/extensions/pi-workflow-engine/src/types.ts +68 -9
  29. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  30. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  31. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  32. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  33. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  34. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  35. package/.pi/extensions/pi-workflow-engine/src/usage.ts +20 -2
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +123 -9
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  44. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  47. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  48. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  49. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  50. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  51. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
  52. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
  53. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
  54. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
  55. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  56. package/README.md +12 -4
  57. package/USAGE.md +167 -23
  58. package/package.json +13 -4
  59. 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";
@@ -40,13 +48,25 @@ import { registerWorkflowRunCommand, WorkflowRunController } from "./src/workflo
40
48
  import { completeCurrentArgument, splitArgumentPrefix } from "./src/command-completions.ts";
41
49
  import { assertSupportedPiVersion } from "./src/pi-compat.ts";
42
50
  import { formatWorkflowInspection, workflowInspectionSnapshot } from "./src/ui/workflow-format.ts";
51
+ import { workflowUsageForPi, type WorkflowUsageSnapshot } from "./src/usage.ts";
52
+ import { unknownErrorMessage } from "./src/unknown-error.ts";
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";
43
60
 
44
61
  /** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
45
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));
46
65
 
47
66
  type DiscoveryModule = typeof import("./src/discovery.ts");
48
67
  type EngineModule = typeof import("./src/engine.ts");
49
68
  type InlineWorkflowModule = typeof import("./src/inline-workflow.ts");
69
+ type WorkflowScriptFileModule = typeof import("./src/workflow-script-file.ts");
50
70
 
51
71
  async function loadDiscovery(): Promise<DiscoveryModule> {
52
72
  return await import("./src/discovery.ts");
@@ -60,18 +80,47 @@ async function loadInlineWorkflow(): Promise<InlineWorkflowModule> {
60
80
  return await import("./src/inline-workflow.ts");
61
81
  }
62
82
 
83
+ async function loadWorkflowScriptFile(): Promise<WorkflowScriptFileModule> {
84
+ return await import("./src/workflow-script-file.ts");
85
+ }
86
+
63
87
  async function createInvocationPerf(options: ResolvedWorkflowRunOptions): Promise<PerfSink | undefined> {
64
88
  if (!options.perf) return undefined;
65
89
  const { createPerfRecorder } = await import("./src/perf.ts");
66
90
  return createPerfRecorder(true);
67
91
  }
68
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
+
69
110
  /**
70
- * 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.
71
114
  */
72
- export async function resolveWorkflowRef(ref: WorkflowRef, perf?: PerfSink): Promise<LoadedWorkflow> {
73
- const { discoverWorkflows } = await loadDiscovery();
74
- 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 });
75
124
  const mod = workflows.get(ref);
76
125
  if (!mod) {
77
126
  const available = [...workflows.keys()].join(", ") || "(none)";
@@ -86,7 +135,7 @@ const AUTHOR_TEMP_WORKFLOW_DESCRIPTION = "Ask the host agent to author and run a
86
135
 
87
136
  const WORKFLOW_OPTION_COMPLETIONS = [
88
137
  { value: "--inspect", description: "Open the live workflow inspector" },
89
- { value: "--refresh", description: "Refresh dynamic workflow discovery" },
138
+ { value: "--refresh", description: "Reload .ts drop-in workflows" },
90
139
  { value: "--perf", description: "Collect workflow performance metrics" },
91
140
  { value: "--result-viewer", description: "Open supported result viewers" },
92
141
  { value: "--no-result-viewer", description: "Skip supported result viewers" },
@@ -100,11 +149,12 @@ const WORKFLOW_OPTION_COMPLETIONS = [
100
149
  { value: "--resume=", description: "Resume from a retained workflow run ID" },
101
150
  ] as const;
102
151
 
103
- 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> {
104
154
  const parts = splitArgumentPrefix(argumentPrefix);
105
155
  if (parts.completed.length === 0) {
106
156
  const { discoverWorkflows } = await loadDiscovery();
107
- const workflows = await discoverWorkflows(EXTENSION_DIR);
157
+ const workflows = await discoverWorkflows(EXTENSION_DIR, { projectDir });
108
158
  return completeCurrentArgument(
109
159
  argumentPrefix,
110
160
  [...workflows.values()].map((workflow) => ({
@@ -116,6 +166,21 @@ async function workflowArgumentCompletions(argumentPrefix: string): Promise<Auto
116
166
  return completeCurrentArgument(argumentPrefix, WORKFLOW_OPTION_COMPLETIONS);
117
167
  }
118
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
+
119
184
  /** Registered workflows in discovery order (built-ins first), then the author option. */
120
185
  async function selectWorkflowValue(workflows: ReadonlyMap<string, WorkflowModule>, ctx: ExtensionCommandContext): Promise<string | undefined> {
121
186
  const workflowItems: SelectItem[] = [...workflows.values()].map((workflow) => ({
@@ -149,6 +214,7 @@ export interface ActiveWorkflowInspection {
149
214
  readonly startedAt: number;
150
215
  readonly snapshot: () => WorkflowProgressSnapshot;
151
216
  readonly state?: () => WorkflowRunState;
217
+ readonly skipAgent?: (agentId: number) => boolean;
152
218
  }
153
219
 
154
220
  interface SessionWorkflowInspections {
@@ -163,7 +229,9 @@ export async function openWorkflowInspector(ctx: ExtensionContext, inspection: L
163
229
  ctx.ui.notify(formatWorkflowInspection(inspection), "info");
164
230
  return;
165
231
  }
166
- 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);
167
235
  }
168
236
 
169
237
  function workflowInspectionState(pi: ExtensionAPI, ctx: ExtensionContext): SessionWorkflowInspections {
@@ -186,7 +254,7 @@ async function openAvailableWorkflowInspector(pi: ExtensionAPI, ctx: ExtensionCo
186
254
  }
187
255
 
188
256
  function bindActiveWorkflowInspection(name: string, args: string, source: WorkflowProgressSource): ActiveWorkflowInspection {
189
- 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 };
190
258
  }
191
259
 
192
260
  export function buildTemporaryWorkflowAuthorPrompt(brief: string): string {
@@ -195,52 +263,53 @@ export function buildTemporaryWorkflowAuthorPrompt(brief: string): string {
195
263
  User brief:
196
264
  ${brief.trim()}
197
265
 
198
- Use the workflow tool with a script argument, not a saved workflow name.
199
- The script must start with export const meta = { ... } and default-export an async workflow function.
200
- Use the injected Type object for schemas. Do not import anything or use dynamic import().
201
- Set profile to "small", "medium", or "big" on each agent() call; use explicit model/thinkingLevel only for an intentional override.
202
- Always pass a plain string as the first api.agent() argument; build prompts with template strings before calling agent().
203
- If using \`isolation: "worktree"\`, remember api.agent() returns \`{ result, patch, changed }\`; read \`.result\` for the agent answer and \`.patch\` for the diff.
204
- When the run is budgeted, guard expensive loops with \`while (api.budget.total && api.budget.remaining() > N) { ... }\`; api.agent() throws once the budget is spent.
205
- Subagents receive no skills by default. When the brief asks for a skill or a stage clearly benefits from one, pass \`skills: ["skill-name"]\` on that agent call only.
206
- ${ADAPTIVE_WORKFLOW_GUIDANCE}
266
+ Use the workflow tool with a script argument, not a saved workflow name, and read the workflow-authoring skill before writing the script.
207
267
  Do not edit files unless the user explicitly requested edits.`;
208
268
  }
209
269
 
210
- export interface WorkflowToolRequestParams {
211
- readonly name?: string;
212
- readonly script?: string;
213
- readonly resumeFromRunId?: string;
214
- readonly background?: boolean;
215
- }
216
-
217
- export type WorkflowToolRequest =
218
- | { readonly kind: "named"; readonly name: string }
219
- | { readonly kind: "inline"; readonly script: string }
220
- | { readonly kind: "error"; readonly error: "invalid_workflow_invocation"; readonly message: string };
221
-
222
- export interface WorkflowToolErrorResult {
270
+ export interface WorkflowToolErrorResult<TDetails = WorkflowToolErrorDetails> {
223
271
  readonly content: Array<{ readonly type: "text"; readonly text: string }>;
224
- readonly details: { readonly error: "invalid_workflow_invocation" } | { readonly error: "inline_compile_error"; readonly message: string };
272
+ readonly details: TDetails;
273
+ /** The model sees a failed call while the renderer still reads `details`. */
274
+ readonly isError: true;
225
275
  }
226
276
 
227
- const INVALID_WORKFLOW_INVOCATION_MESSAGE = "Provide exactly one workflow name or inline workflow script.";
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 };
228
281
 
229
- export function normalizeWorkflowToolRequest(params: WorkflowToolRequestParams): WorkflowToolRequest {
230
- const name = params.name?.trim() ?? "";
231
- const script = params.script?.trim() ?? "";
232
- const hasName = name.length > 0;
233
- const hasScript = script.length > 0;
234
- if (hasName === hasScript) return { kind: "error", error: "invalid_workflow_invocation", message: INVALID_WORKFLOW_INVOCATION_MESSAGE };
235
- return hasName ? { kind: "named", name } : { kind: "inline", script };
282
+ function workflowToolError<TDetails>(text: string, details: TDetails): WorkflowToolErrorResult<TDetails> {
283
+ return { content: [{ type: "text", text }], details, isError: true };
236
284
  }
237
285
 
238
286
  export function invalidWorkflowInvocationResult(): WorkflowToolErrorResult {
239
- return { content: [{ type: "text", text: INVALID_WORKFLOW_INVOCATION_MESSAGE }], details: { error: "invalid_workflow_invocation" } };
287
+ return workflowToolError(INVALID_WORKFLOW_INVOCATION_MESSAGE, { error: "invalid_workflow_invocation" });
240
288
  }
241
289
 
242
290
  export function inlineCompileErrorResult(message: string): WorkflowToolErrorResult {
243
- return { content: [{ type: "text", text: `Inline workflow did not compile: ${message}` }], details: { error: "inline_compile_error", message } };
291
+ return workflowToolError(`Inline workflow did not compile: ${message}`, { error: "inline_compile_error", message });
292
+ }
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
+ }
244
313
  }
245
314
 
246
315
  export type WorkflowPickerSelection =
@@ -285,27 +354,29 @@ export async function sendWorkflowResult(
285
354
  await reviewSessions.present(ctx, retained, options);
286
355
  }
287
356
 
357
+ /** `input` is a slash command's argument string, or a tool call's string or JSON value. */
288
358
  async function executeResolvedWorkflow(
289
359
  pi: ExtensionAPI,
290
360
  ctx: ExtensionContext,
291
361
  name: string,
292
362
  mod: LoadedWorkflow,
293
- args: string,
363
+ input: unknown,
294
364
  options: ResolvedWorkflowRunOptions,
295
365
  perfRecorder?: PerfSink,
296
366
  ): Promise<WorkflowResultEnvelope> {
297
367
  const { runResolvedWorkflow } = await loadEngine();
298
368
  let liveInspection: ActiveWorkflowInspection | undefined;
299
369
  const inspections = workflowInspectionState(pi, ctx);
370
+ const args = workflowArgsText(input);
300
371
  return await executeWorkflowInvocation({
301
372
  ctx,
302
373
  name,
303
374
  mod,
304
- args,
375
+ args: input,
305
376
  options,
306
377
  perfRecorder,
307
378
  runResolvedWorkflow,
308
- resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
379
+ resolveWorkflow: (ref) => resolveWorkflowRef(ctx, ref, perfRecorder),
309
380
  onProgressSource(source) {
310
381
  if (source) {
311
382
  liveInspection = bindActiveWorkflowInspection(name, args, source);
@@ -348,10 +419,10 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
348
419
  assertSupportedPiVersion(VERSION);
349
420
  const reviewSessions = createReviewSessionCoordinator(pi);
350
421
  const backgroundWorkflows = new BackgroundWorkflowCoordinator(pi);
422
+ const sessionProjectDir = trackProjectWorkflowDir(pi);
351
423
  const workflowRuns = new WorkflowRunController(backgroundWorkflows, {
352
- async resolveWorkflow(name) {
353
- const { discoverWorkflows } = await loadDiscovery();
354
- return (await discoverWorkflows(EXTENSION_DIR)).get(name);
424
+ async resolveWorkflow(name, ctx) {
425
+ return (await discoverForSession(ctx)).get(name);
355
426
  },
356
427
  async execute(ctx, name, workflow, options) {
357
428
  const perfRecorder = await createInvocationPerf(options);
@@ -360,6 +431,15 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
360
431
  },
361
432
  });
362
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));
363
443
  const dynamax = registerDynamax(pi, shortcuts, { openInspector: (ctx) => openAvailableWorkflowInspector(pi, ctx) });
364
444
  registerWorkflowModelProfileCommand(pi);
365
445
  registerWorkflowRunCommand(pi, workflowRuns);
@@ -420,7 +500,7 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
420
500
  // /workflow <name> [args] — user-invoked.
421
501
  pi.registerCommand("workflow", {
422
502
  description: "Run a multi-agent workflow: /workflow <name> [args]",
423
- getArgumentCompletions: workflowArgumentCompletions,
503
+ getArgumentCompletions: (argumentPrefix) => workflowArgumentCompletions(argumentPrefix, sessionProjectDir()),
424
504
  handler: async (args: string, ctx: ExtensionCommandContext) => {
425
505
  const invocation = parseWorkflowInvocation(args);
426
506
  if (invocation.optionErrors.length > 0) {
@@ -429,8 +509,7 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
429
509
  }
430
510
  const options = resolveWorkflowRunOptions(invocation.options);
431
511
  const perfRecorder = await createInvocationPerf(options);
432
- const { discoverWorkflows } = await loadDiscovery();
433
- const workflows = await discoverWorkflows(EXTENSION_DIR, { refresh: invocation.refreshDiscovery, perf: perfRecorder });
512
+ const workflows = await discoverForSession(ctx, { refresh: invocation.refreshDiscovery, perf: perfRecorder });
434
513
  const available = [...workflows.keys()].join(", ") || "(none)";
435
514
  if (!invocation.name && !ctx.hasUI) {
436
515
  ctx.ui.notify(`Usage: /workflow <name> [args]. Available: ${available}`, "warning");
@@ -460,7 +539,22 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
460
539
  },
461
540
  });
462
541
 
463
- 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
+ });
464
558
  }
465
559
 
466
560
  /** Result footers advertise the configured triage and inspector shortcuts and pi's current expand key, read at render time. */
@@ -478,6 +572,7 @@ function registerWorkflowTool(
478
572
  reviewSessions: ReviewSessionCoordinator,
479
573
  backgroundWorkflows: BackgroundWorkflowCoordinator,
480
574
  shortcuts: DynamaxShortcuts,
575
+ dynamax: DynamaxHandle,
481
576
  ): void {
482
577
  pi.registerTool({
483
578
  name: WORKFLOW_TOOL_NAME,
@@ -485,27 +580,38 @@ function registerWorkflowTool(
485
580
  description:
486
581
  "ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/workflow:dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize), synchronously by default or explicitly in the background.",
487
582
  promptSnippet: "Run an existing named workflow or an inline one-off workflow script",
583
+ // Like codemode itself, an orchestrating tool stays a top-level model action: from a codemode script a
584
+ // run would lose its tool row, hit the script's output cap, and parallel runs would each get their own
585
+ // concurrency cap. It also stays declared under `codemode.mode: "only"`, which hides direct tools.
586
+ exposure: "model-only",
587
+ reachesTools: workflowToolReach,
488
588
  promptGuidelines: [
489
- "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.",
490
- "Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
491
- "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.",
492
- "Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
493
- "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.",
494
- "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.",
495
- "Always pass a plain string as the first `api.agent()` argument; build prompts with template strings before calling agent().",
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().",
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.",
496
594
  "When using `isolation: \"worktree\"`, `api.agent()` returns `{ result, patch, changed }`; use `.result` for the answer and `.patch` for the isolated diff.",
497
- "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.",
498
- "`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.",
499
- ADAPTIVE_WORKFLOW_GUIDANCE,
595
+ "Host MCP tools reach subagents only in synchronous workflow tool runs, never in background runs.",
500
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.",
501
597
  "Set autoResumeOnUsageLimit: true only for an explicitly backgrounded workflow when the user wants bounded automatic continuation after a recognized provider usage window.",
502
598
  "Set resumeEditedWorkflow: true only with resumeFromRunId when the user explicitly accepts reusing behaviorally identical calls after workflow source edits.",
503
- "Every workflow tool call must provide exactly one of `name` or `script`, never both.",
504
599
  ],
505
600
  parameters: Type.Object({
506
- name: Type.Optional(Type.String({ description: "Workflow name, e.g. code-review. Provide exactly one of name or script." })),
507
- script: Type.Optional(Type.String({ description: "Inline workflow script. Provide exactly one of script or name." })),
508
- 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
+ ),
509
615
  concurrency: Type.Optional(Type.Number({ description: "Optional per-run agent concurrency cap" })),
510
616
  parallelSubmissionLimit: Type.Optional(Type.Number({ description: "Optional limit for eagerly submitted parallel thunks" })),
511
617
  maxAgents: Type.Optional(
@@ -565,21 +671,23 @@ function registerWorkflowTool(
565
671
  if (request.kind === "error") return invalidWorkflowInvocationResult();
566
672
  const resumeFromRunId = params.resumeFromRunId?.trim();
567
673
  if (params.resumeFromRunId !== undefined && resumeFromRunId === "") {
568
- return {
569
- content: [{ type: "text", text: "resumeFromRunId must be non-empty." }],
570
- details: { error: "invalid_resume_from_run_id" },
571
- };
674
+ return workflowToolError("resumeFromRunId must be non-empty.", { error: "invalid_resume_from_run_id" });
572
675
  }
573
676
  if (params.resumeEditedWorkflow && !resumeFromRunId) {
574
- return {
575
- content: [{ type: "text", text: "resumeEditedWorkflow requires resumeFromRunId." }],
576
- details: { error: "invalid_edited_workflow_resume" },
577
- };
677
+ return workflowToolError("resumeEditedWorkflow requires resumeFromRunId.", { error: "invalid_edited_workflow_resume" });
578
678
  }
579
679
  if (params.background) {
580
680
  const unavailable = backgroundUnavailableResult(ctx.mode);
581
681
  if (unavailable) return unavailable;
582
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
+ }
583
691
 
584
692
  const runOptions = resolveWorkflowRunOptions({
585
693
  concurrency: params.concurrency,
@@ -590,10 +698,11 @@ function registerWorkflowTool(
590
698
  autoResumeOnUsageLimit: params.autoResumeOnUsageLimit,
591
699
  usageLimitMaxAttempts: params.usageLimitMaxAttempts,
592
700
  usageLimitMaxDelayMs: params.usageLimitMaxDelayMs,
593
- budget: params.budget,
701
+ budget: budget.budget,
594
702
  perf: params.perf,
595
703
  resumeFromRunId,
596
- 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",
597
706
  signal,
598
707
  });
599
708
  const perfRecorder = await createInvocationPerf(runOptions);
@@ -601,36 +710,31 @@ function registerWorkflowTool(
601
710
  let resultName: string;
602
711
 
603
712
  if (request.kind === "named") {
604
- const { discoverWorkflows } = await loadDiscovery();
605
- const workflows = await discoverWorkflows(EXTENSION_DIR, { perf: perfRecorder });
713
+ const workflows = await discoverForSession(ctx, { perf: perfRecorder });
606
714
  const named = workflows.get(request.name);
607
715
  if (!named) {
608
716
  const available = [...workflows.keys()].join(", ") || "(none)";
609
- return {
610
- content: [{ type: "text", text: `Unknown workflow "${request.name}". Available: ${available}` }],
611
- details: { error: "unknown_workflow", available },
612
- };
717
+ return workflowToolError(`Unknown workflow "${request.name}". Available: ${available}`, { error: "unknown_workflow", available });
613
718
  }
614
719
  mod = named;
615
720
  resultName = request.name;
616
721
  } else {
617
- const inline = await loadInlineWorkflow();
618
- try {
619
- mod = inline.compileInlineWorkflow(request.script);
620
- } catch (error) {
621
- if (error instanceof inline.InlineWorkflowCompileError) return inlineCompileErrorResult(error.message);
622
- throw error;
623
- }
722
+ const compiled = await compileInlineToolWorkflow(ctx.cwd, request);
723
+ if ("isError" in compiled) return compiled;
724
+ mod = compiled;
624
725
  resultName = mod.meta.name;
625
726
  }
626
727
 
627
- 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;
628
730
  if (params.background) {
629
731
  return await startBackgroundWorkflowTool({
630
732
  coordinator: backgroundWorkflows,
631
733
  ctx,
632
734
  name: resultName,
633
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),
634
738
  async execute(backgroundCtx, backgroundOptions) {
635
739
  const envelope = await executeResolvedWorkflow(
636
740
  pi,
@@ -647,11 +751,49 @@ function registerWorkflowTool(
647
751
  }
648
752
  // Marks the tool row as running (rendered `● running <name>`) until the envelope replaces it.
649
753
  onUpdate?.({ content: [{ type: "text", text: `Running workflow ${resultName}.` }], details: { state: "running", name: resultName } });
650
- const envelope = await executeResolvedWorkflow(pi, ctx, resultName, mod, resultArgs, runOptions, perfRecorder);
754
+ // Only this synchronous path may bridge host MCP tools: executeTool ends with the tool call.
755
+ const hostTools = createHostToolBridge(() => pi.getAllTools(), ctx);
756
+ // Keep the latest usage: a failed run still reports what its agents spent instead of dropping it with the error.
757
+ let spent: WorkflowUsageSnapshot | undefined;
758
+ const onUsageSnapshot = (snapshot: WorkflowUsageSnapshot) => {
759
+ spent = snapshot;
760
+ return runOptions.onUsageSnapshot?.(snapshot);
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
+ };
768
+ let envelope: WorkflowResultEnvelope;
769
+ try {
770
+ envelope = await executeResolvedWorkflow(
771
+ pi,
772
+ ctx,
773
+ resultName,
774
+ mod,
775
+ resultArgs,
776
+ { ...runOptions, hostTools, onUsageSnapshot, onRunMetadata },
777
+ perfRecorder,
778
+ );
779
+ } catch (error) {
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));
783
+ return {
784
+ ...workflowToolError(lines.join("\n"), { error: "workflow_failed", name: resultName, message }),
785
+ usage: workflowUsageForPi(spent),
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);
790
+ }
651
791
  reviewSessions.remember(ctx, envelope, runOptions);
652
792
  return {
653
793
  content: [{ type: "text", text: formatWorkflowResultForContext(envelope) }],
654
794
  details: envelope,
795
+ // pi counts tool-result usage toward the session cost; subagent spend was invisible there before.
796
+ usage: workflowUsageForPi(envelope.usage),
655
797
  };
656
798
  },
657
799
  });
@@ -49,16 +49,19 @@ export function dedupeCandidates<T extends IdentifiedAdvisoryCandidate>(candidat
49
49
  const key = candidateDedupKey(candidate);
50
50
  const previous = seen.get(key);
51
51
  if (!previous) seen.set(key, { ...candidate });
52
- else {
53
- previous.sourceCandidateIds = [...new Set([...previous.sourceCandidateIds, ...candidate.sourceCandidateIds])];
54
- previous.locations = uniqueLocations([...previous.locations, ...candidate.locations]);
55
- previous.impact = [...new Set([previous.impact, candidate.impact])].join("\n");
56
- previous.discoveryEvidence = [...new Set([...(previous.discoveryEvidence ?? []), ...(candidate.discoveryEvidence ?? [])])];
57
- }
52
+ else mergeCandidateInto(previous, candidate);
58
53
  }
59
54
  return [...seen.values()];
60
55
  }
61
56
 
57
+ /** Fold a duplicate into the kept candidate, keeping every source id, location, impact, and piece of evidence. */
58
+ export function mergeCandidateInto<T extends IdentifiedAdvisoryCandidate>(kept: T, duplicate: T): void {
59
+ kept.sourceCandidateIds = [...new Set([...kept.sourceCandidateIds, ...duplicate.sourceCandidateIds])];
60
+ kept.locations = uniqueLocations([...kept.locations, ...duplicate.locations]);
61
+ kept.impact = [...new Set([kept.impact, duplicate.impact])].join("\n");
62
+ kept.discoveryEvidence = [...new Set([...(kept.discoveryEvidence ?? []), ...(duplicate.discoveryEvidence ?? [])])];
63
+ }
64
+
62
65
  export function uniqueLocations(locations: AdvisoryCandidate["locations"]): AdvisoryCandidate["locations"] {
63
66
  return [...new Map(locations.map((location) => [JSON.stringify(location), location])).values()];
64
67
  }