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
@@ -1,8 +1,10 @@
1
- import { CustomEditor, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
1
+ import { CustomEditor, type BeforeAgentStartEvent, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { EditorComponent } from "@earendil-works/pi-tui";
3
3
  import { completeCurrentArgument } from "./command-completions.ts";
4
4
  import type { DynamaxShortcuts } from "./dynamax-shortcuts.ts";
5
+ import { appendSectionToForcedPrompt } from "./prompt-sections.ts";
5
6
  import { sessionKey } from "./session-identity.ts";
7
+ import { WORKFLOW_TOOL_NAME } from "./types.ts";
6
8
  import { unknownErrorMessage } from "./unknown-error.ts";
7
9
  import { dot, GLYPH } from "./ui/kit.ts";
8
10
  import { setWorkflowInspectorShortcut } from "./ui/workflow-widget.ts";
@@ -20,8 +22,19 @@ export interface DynamaxState {
20
22
  turnActive: boolean;
21
23
  }
22
24
 
25
+ /** The output-token pool a `+500k`-style directive in the user's prompt sets for that turn's workflow runs. */
26
+ export interface DynamaxTurnBudget {
27
+ readonly total: number;
28
+ /** Tokens not yet spent by this turn's synchronous runs; never negative. */
29
+ remaining(): number;
30
+ /** Count a finished synchronous run's output tokens against the pool. */
31
+ spend(outputTokens: number): void;
32
+ }
33
+
23
34
  export interface DynamaxRuntime {
24
35
  state: DynamaxState;
36
+ /** Set by a budget directive in a user prompt, cleared when that run settles. */
37
+ turnBudget?: DynamaxTurnBudget;
25
38
  }
26
39
 
27
40
  export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
@@ -35,9 +48,18 @@ export interface DynamaxRegistrationOptions {
35
48
  export interface DynamaxHandle {
36
49
  /** Opt the next agent run in. The `input` hook ignores prompts this extension sends (pi tags them `source: "extension"`). */
37
50
  markOneShot(ctx: ExtensionContext): void;
51
+ /** The current turn's output-token pool, when the user's prompt set one. */
52
+ turnBudget(ctx: ExtensionContext): DynamaxTurnBudget | undefined;
53
+ /** Whether Dynamax covers this session now: sticky, a pending one-shot, or a one-shot turn in progress. */
54
+ isActive(ctx: ExtensionContext): boolean;
38
55
  }
39
56
 
40
57
  export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
58
+ /** `+500k`, `+1.5m`, `+2M`, `+250000`: after the start or whitespace, before the end, whitespace, or punctuation. */
59
+ export const BUDGET_DIRECTIVE_PATTERN = /(?:^|\s)\+(\d+(?:\.\d+)?)([km]?)(?=$|\s|[.,;:!?)\]}"'])/i;
60
+ /** Smaller values are ignored, so a `+1` or `+2` in ordinary prose never caps a run at a few tokens. */
61
+ export const BUDGET_DIRECTIVE_MIN_TOKENS = 1_000;
62
+ const BUDGET_DIRECTIVE_MULTIPLIERS: Readonly<Record<string, number>> = { "": 1, k: 1_000, m: 1_000_000 };
41
63
  export const DYNAMAX_STATUS_KEY = "dynamax";
42
64
 
43
65
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
@@ -53,29 +75,31 @@ function disposeDynamaxDecorations(decorations: Set<DynamaxEditorDecoration>): v
53
75
  decorations.clear();
54
76
  }
55
77
 
78
+ /** With no argument the command toggles sticky mode, so these name only the explicit forms. */
56
79
  const DYNAMAX_ACTION_COMPLETIONS = [
57
- { value: "on", description: "Keep Dynamax enabled for this session" },
80
+ { value: "on", description: "Make workflow orchestration the default for this session" },
58
81
  { value: "off", description: "Disable sticky and pending Dynamax modes" },
59
82
  { value: "status", description: "Show the current Dynamax state" },
60
83
  ] as const;
61
84
 
62
- export const ADAPTIVE_WORKFLOW_GUIDANCE = `
63
- Adaptive multi-pass workflows are optional. Use a simple single-pass fan-out when it is sufficient. When the first pass may expose gaps, conflicts, weak claims, or missing evidence:
64
- - run the bounded first-pass agents;
65
- - give their surviving results to a structured gap-analysis agent so the LLM decides what needs follow-up;
66
- - put a hard maxItems bound on LLM-authored task arrays and defensively slice them before fan-out;
67
- - use ordinary TypeScript conditionals or bounded loops to launch follow-up agents only when gaps exist;
68
- - synthesize the first-pass and follow-up results together.
69
- Do not generate a second pass when the first pass is sufficient, and do not invent iteration, quorum, graph, reduction, or retry primitives for this pattern.`;
70
-
71
85
  /** System prompt section pi wraps as `<dynamax>`; adding or dropping it appends a section patch instead of rewriting the prompt. */
72
86
  export const DYNAMAX_SECTION = "dynamax";
73
87
 
74
- /** Only the opt-in itself: the authoring rules already reach the model as the workflow tool's guidelines. */
88
+ /**
89
+ * Only the opt-in itself: the workflow tool's guidelines send the model to the workflow-authoring
90
+ * skill, which carries the authoring rules and patterns.
91
+ */
75
92
  export const DYNAMAX_REMINDER =
76
93
  "The user opted into dynamax multi-agent orchestration for this request, so the workflow tool is permitted. " +
77
94
  "Run a registered workflow by name, or author an inline workflow script when none fits, following the workflow tool's guidelines.";
78
95
 
96
+ /** Sticky mode is a standing default for the session, not a one-request permission. */
97
+ export const DYNAMAX_STICKY_REMINDER =
98
+ "Dynamax is on for this session. Orchestrate every substantive task with the workflow tool by default: " +
99
+ "scout inline first to find the work list, then run one single-phase workflow per phase (understand, design, implement, review) " +
100
+ "and read each result before choosing the next. Lean toward adversarial verification of findings. " +
101
+ "Work without a workflow only for conversational turns and trivial or already-verified edits.";
102
+
79
103
  export function createDynamaxState(): DynamaxState {
80
104
  return { sticky: false, oneShotPending: false, turnActive: false };
81
105
  }
@@ -97,6 +121,48 @@ export function hasDynamaxToken(text: string): boolean {
97
121
  return DYNAMAX_TOKEN_PATTERN.test(text);
98
122
  }
99
123
 
124
+ /** The output tokens the first budget directive in `text` asks for, if any. */
125
+ export function parseBudgetDirective(text: string): number | undefined {
126
+ const match = BUDGET_DIRECTIVE_PATTERN.exec(text);
127
+ if (!match) return undefined;
128
+ const multiplier = BUDGET_DIRECTIVE_MULTIPLIERS[(match[2] ?? "").toLowerCase()] ?? 1;
129
+ // Round, not floor: 2.3 * 1000 is 2299.999... in floating point.
130
+ const tokens = Math.round(Number(match[1]) * multiplier);
131
+ return Number.isSafeInteger(tokens) && tokens >= BUDGET_DIRECTIVE_MIN_TOKENS ? tokens : undefined;
132
+ }
133
+
134
+ export function createDynamaxTurnBudget(total: number): DynamaxTurnBudget {
135
+ let spent = 0;
136
+ return {
137
+ total,
138
+ remaining: () => Math.max(0, total - spent),
139
+ spend(outputTokens) {
140
+ if (Number.isFinite(outputTokens) && outputTokens > 0) spent += outputTokens;
141
+ },
142
+ };
143
+ }
144
+
145
+ export type DynamaxTurnBudgetResolution =
146
+ | { readonly kind: "budget"; readonly budget: number | undefined }
147
+ | { readonly kind: "spent"; readonly total: number };
148
+
149
+ /**
150
+ * A run's budget. Without a turn pool it is the explicit one, if any. The user's pool caps every run in
151
+ * its turn: the remainder, clamped to the run budget range, is the budget, and an explicit one can only
152
+ * lower it. A pool with less than `min` left is spent.
153
+ */
154
+ export function resolveTurnBudget(
155
+ explicit: number | undefined,
156
+ pool: DynamaxTurnBudget | undefined,
157
+ range: { readonly min: number; readonly max: number },
158
+ ): DynamaxTurnBudgetResolution {
159
+ if (pool === undefined) return { kind: "budget", budget: explicit };
160
+ const remaining = pool.remaining();
161
+ if (remaining < range.min) return { kind: "spent", total: pool.total };
162
+ const cap = Math.min(range.max, Math.floor(remaining));
163
+ return { kind: "budget", budget: explicit === undefined ? cap : Math.min(explicit, cap) };
164
+ }
165
+
100
166
  export function markDynamaxOneShot(state: DynamaxState): void {
101
167
  state.oneShotPending = true;
102
168
  }
@@ -136,12 +202,21 @@ export function dynamaxStatusText(state: DynamaxState, theme: Theme): string {
136
202
  /**
137
203
  * Add the opt-in section for this run and consume a pending one-shot. pi rebuilds `sections` for every
138
204
  * run, so an inactive run simply leaves it out and pi records the removal as a patch.
205
+ *
206
+ * Sticky mode covers every run, including one a custom message started (`sendMessage` with
207
+ * `triggerTurn`, such as a background completion). Only a user prompt consumes the one-shot: the
208
+ * opt-in belongs to the request the user typed it in.
139
209
  */
140
- export function applyDynamaxPromptSection(sections: Record<string, string>, state: DynamaxState): boolean {
141
- if (!state.sticky && !state.oneShotPending) return false;
142
- state.oneShotPending = false;
210
+ export function applyDynamaxPromptSection(
211
+ sections: Record<string, string>,
212
+ state: DynamaxState,
213
+ messageType: BeforeAgentStartEvent["messageType"] = "user",
214
+ ): boolean {
215
+ const oneShot = messageType === "user" && state.oneShotPending;
216
+ if (!state.sticky && !oneShot) return false;
217
+ if (oneShot) state.oneShotPending = false;
143
218
  state.turnActive = true;
144
- sections[DYNAMAX_SECTION] = DYNAMAX_REMINDER;
219
+ sections[DYNAMAX_SECTION] = state.sticky ? DYNAMAX_STICKY_REMINDER : DYNAMAX_REMINDER;
145
220
  return true;
146
221
  }
147
222
 
@@ -230,23 +305,30 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
230
305
  });
231
306
 
232
307
  pi.on("input", (event, ctx) => {
233
- if (event.source !== "extension" && hasDynamaxToken(event.text)) markOneShot(ctx);
308
+ if (event.source === "extension") return { action: "continue" };
309
+ if (hasDynamaxToken(event.text)) markOneShot(ctx);
310
+ const budget = parseBudgetDirective(event.text);
311
+ if (budget !== undefined) getDynamaxRuntime(runtimes, ctx).turnBudget = createDynamaxTurnBudget(budget);
234
312
  return { action: "continue" };
235
313
  });
236
314
 
237
315
  pi.on("before_agent_start", (event, ctx) => {
238
- const runtime = getDynamaxRuntime(runtimes, ctx);
239
316
  const options = event.systemPromptOptions;
240
- if (!applyDynamaxPromptSection(options.sections, runtime.state)) return undefined;
317
+ // Subagent sessions load this extension but withhold the workflow tool, so a `dynamax` in a
318
+ // subagent prompt must neither permit that tool nor consume the one-shot.
319
+ if (!options.selectedTools.includes(WORKFLOW_TOOL_NAME)) return undefined;
320
+ const runtime = getDynamaxRuntime(runtimes, ctx);
321
+ if (!applyDynamaxPromptSection(options.sections, runtime.state, event.messageType)) return undefined;
241
322
  updateDynamaxSurfaces(ctx, runtime);
242
- // An earlier extension forced the whole prompt, and pi renders no sections then; append to its text instead.
243
- if (options.forceSystemPrompt === undefined) return undefined;
244
- return { systemPrompt: `${options.forceSystemPrompt}\n\n<${DYNAMAX_SECTION}>\n${DYNAMAX_REMINDER}\n</${DYNAMAX_SECTION}>` };
323
+ return appendSectionToForcedPrompt(options, DYNAMAX_SECTION);
245
324
  });
246
325
 
247
- pi.on("agent_end", (_event, ctx) => {
326
+ // Not agent_end: pi emits that per agent loop, and one run continues in fresh loops after a
327
+ // provider retry, overflow compaction, or queued follow-up. agent_settled fires once per run.
328
+ pi.on("agent_settled", (_event, ctx) => {
248
329
  const runtime = getDynamaxRuntime(runtimes, ctx);
249
330
  if (!runtime.state.sticky) runtime.state.turnActive = false;
331
+ runtime.turnBudget = undefined;
250
332
  updateDynamaxSurfaces(ctx, runtime);
251
333
  });
252
334
 
@@ -260,15 +342,12 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
260
342
  }
261
343
 
262
344
  pi.registerCommand("workflow:dynamax", {
263
- description: "Toggle Dynamax workflow orchestration: /workflow:dynamax [on|off|status]",
345
+ description: "Toggle sticky Dynamax orchestration for this session: /workflow:dynamax [on|off|status]",
264
346
  getArgumentCompletions: (argumentPrefix) => completeCurrentArgument(argumentPrefix, DYNAMAX_ACTION_COMPLETIONS),
265
347
  handler: async (args, ctx) => {
266
348
  const runtime = getDynamaxRuntime(runtimes, ctx);
267
- const action = args.trim().toLowerCase();
268
- if (action === "") {
269
- ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}. Usage: /workflow:dynamax [on|off|status]`, "info");
270
- return;
271
- }
349
+ const typed = args.trim().toLowerCase();
350
+ const action = typed === "" ? (runtime.state.sticky ? "off" : "on") : typed;
272
351
  if (action === "on") {
273
352
  enableDynamaxSticky(runtime.state);
274
353
  updateDynamaxSurfaces(ctx, runtime);
@@ -285,11 +364,15 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
285
364
  ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}`, "info");
286
365
  return;
287
366
  }
288
- ctx.ui.notify("Usage: /workflow:dynamax [on|off|status]", "warning");
367
+ ctx.ui.notify("Usage: /workflow:dynamax [on|off|status] (no argument toggles sticky mode)", "warning");
289
368
  },
290
369
  });
291
370
 
292
- return { markOneShot };
371
+ return {
372
+ markOneShot,
373
+ turnBudget: (ctx) => getDynamaxRuntime(runtimes, ctx).turnBudget,
374
+ isActive: (ctx) => isDynamaxActive(getDynamaxRuntime(runtimes, ctx).state),
375
+ };
293
376
  }
294
377
 
295
378
  export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime): void {
@@ -15,11 +15,12 @@ import {
15
15
  resolveWorkflowRunOptions,
16
16
  type ResolvedWorkflowRunOptions,
17
17
  } from "./options.ts";
18
- import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
18
+ import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowMeta, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
19
19
  import { showWorkflowInspector } from "./ui/workflow-inspector.ts";
20
20
  import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
21
21
  import { WorktreeRegistry } from "./worktree.ts";
22
22
  import { runFinalizers } from "./finalizers.ts";
23
+ import { workflowArgsText, workflowInputPresent } from "./workflow-input.ts";
23
24
  import { unknownErrorMessage } from "./unknown-error.ts";
24
25
  import {
25
26
  captureWorkflowResumeContext,
@@ -27,11 +28,14 @@ import {
27
28
  } from "./resume-context.ts";
28
29
  import {
29
30
  DurableWorkflowRun,
31
+ persistWorkflowRunScript,
30
32
  ProjectWorkflowRunStore,
31
33
  workflowRunRecordPath,
32
34
  type WorkflowRunStore,
33
35
  } from "./workflow-run-store.ts";
34
36
  import { createWorkflowRunRecord } from "./workflow-run-record.ts";
37
+ import { canRelaunchWorkflowRun } from "./workflow-run-history.ts";
38
+ import { sealWorkflowRunScript } from "./workflow-run-seal.ts";
35
39
  import {
36
40
  createProviderUsageLimitPauseRecord,
37
41
  WorkflowProviderUsageLimitError,
@@ -76,22 +80,23 @@ export interface WorkflowEngineDependencies {
76
80
  /**
77
81
  * Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
78
82
  * to a shared semaphore + progress tracker), invoke the workflow, return its result.
83
+ * `input` is a slash command's argument string or a tool call's JSON value.
79
84
  */
80
85
  export async function runWorkflow(
81
86
  ctx: ExtensionContext,
82
87
  mod: LoadedWorkflow,
83
- args: string,
88
+ input: unknown,
84
89
  options: WorkflowRunOptions = {},
85
90
  dependencies: WorkflowEngineDependencies = {},
86
91
  ): Promise<unknown> {
87
- return await runResolvedWorkflow(ctx, mod, args, resolveWorkflowRunOptions(options), dependencies);
92
+ return await runResolvedWorkflow(ctx, mod, input, resolveWorkflowRunOptions(options), dependencies);
88
93
  }
89
94
 
90
95
  /** Execute an options-normalized workflow without re-reading environment defaults. */
91
96
  export async function runResolvedWorkflow(
92
97
  ctx: ExtensionContext,
93
98
  mod: LoadedWorkflow,
94
- args: string,
99
+ input: unknown,
95
100
  resolvedOptions: ResolvedWorkflowRunOptions,
96
101
  dependencies: WorkflowEngineDependencies = {},
97
102
  ): Promise<unknown> {
@@ -107,16 +112,20 @@ export async function runResolvedWorkflow(
107
112
  const journalPath = workflowJournalPath(ctx.cwd, runId);
108
113
  const resumePath = resolvedOptions.resumeFromRunId ? workflowJournalPath(ctx.cwd, resolvedOptions.resumeFromRunId) : undefined;
109
114
  const runStore = new ProjectWorkflowRunStore(ctx.cwd);
115
+ const script = await sealRunScript(ctx.cwd, runId, mod);
110
116
  let persistenceFailureReported = false;
117
+ const initialRecord = createWorkflowRunRecord({
118
+ runId,
119
+ workflow: mod,
120
+ options: resolvedOptions,
121
+ progress: progress.snapshot(),
122
+ argumentsPresent: workflowInputPresent(input),
123
+ scriptPersisted: script.path !== undefined,
124
+ scriptSeal: script.seal,
125
+ });
111
126
  const durableRun = new DurableWorkflowRun(
112
127
  runStore,
113
- createWorkflowRunRecord({
114
- runId,
115
- workflow: mod,
116
- options: resolvedOptions,
117
- progress: progress.snapshot(),
118
- argumentsPresent: args.length > 0,
119
- }),
128
+ initialRecord,
120
129
  (error) => {
121
130
  if (persistenceFailureReported) return;
122
131
  persistenceFailureReported = true;
@@ -124,7 +133,11 @@ export async function runResolvedWorkflow(
124
133
  },
125
134
  );
126
135
  durableProgress = durableRun;
127
- const progressSource = { snapshot: () => progress.snapshot(), state: () => durableRun.state };
136
+ const progressSource = {
137
+ snapshot: () => progress.snapshot(),
138
+ state: () => durableRun.state,
139
+ skipAgent: (agentId: number) => progress.skipAgent(agentId),
140
+ };
128
141
  const worktrees = dependencies.worktrees ?? new WorktreeRegistry(ctx.cwd);
129
142
  const runAbortController = new AbortController();
130
143
  const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
@@ -132,7 +145,7 @@ export async function runResolvedWorkflow(
132
145
  const workflowOutcome = await captureOutcome(async () => {
133
146
  await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
134
147
  if (resolvedOptions.inspect && ctx.hasUI && ctx.mode === "tui") {
135
- void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }))
148
+ void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }), progressSource)
136
149
  .catch((error: unknown) => {
137
150
  try {
138
151
  progress.log(`inspector failed: ${unknownErrorMessage(error)}`);
@@ -151,6 +164,7 @@ export async function runResolvedWorkflow(
151
164
  resumedFromRunId: resolvedOptions.resumeFromRunId,
152
165
  journalPath,
153
166
  recordPath: workflowRunRecordPath(ctx.cwd, runId),
167
+ ...(script.path === undefined ? {} : { scriptPath: script.path }),
154
168
  }),
155
169
  );
156
170
  progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
@@ -184,7 +198,7 @@ export async function runResolvedWorkflow(
184
198
 
185
199
  // perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
186
200
  return perf.time("workflow.total_ms", () =>
187
- runWorkflowWithContext(rc, progress, mod, args, {
201
+ runWorkflowWithContext(rc, progress, mod, input, {
188
202
  abortController: runAbortController,
189
203
  submissionLimit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
190
204
  resolveWorkflow: resolvedOptions.resolveWorkflow,
@@ -225,7 +239,7 @@ export async function runResolvedWorkflow(
225
239
  durableRun.transition({
226
240
  state: "paused",
227
241
  progress: progress.snapshot(),
228
- ...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions, mod.source.kind === "file" && args.length === 0),
242
+ ...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions, canRelaunchWorkflowRun(initialRecord)),
229
243
  });
230
244
  } else if (pauseError) {
231
245
  durableRun.transition({
@@ -247,6 +261,33 @@ export async function runResolvedWorkflow(
247
261
  throw outcome.error;
248
262
  }
249
263
 
264
+ /**
265
+ * Seal a run whose code came from a script for this install, so a repository cannot ship a run record
266
+ * whose relaunch runs its own code. An inline run's script is also written beside its record; a saved
267
+ * script resolves again by name, and the seal pins the content that ran. A failed write or seal only
268
+ * costs the run its relaunch from `/workflow:runs`.
269
+ */
270
+ async function sealRunScript(cwd: string, runId: string, mod: LoadedWorkflow): Promise<{ readonly path?: string; readonly seal?: string }> {
271
+ const { source } = mod;
272
+ const savedScript = source.kind === "file" && source.selfContained === true;
273
+ if (!savedScript && (source.kind !== "fingerprint" || mod.inlineScript === undefined)) return {};
274
+ let path: string | undefined;
275
+ if (mod.inlineScript !== undefined && source.kind === "fingerprint") {
276
+ try {
277
+ path = await persistWorkflowRunScript(cwd, runId, mod.inlineScript);
278
+ } catch (error) {
279
+ process.stderr.write(`[${mod.meta.name}] script persistence failed: ${unknownErrorMessage(error)}\n`);
280
+ return {};
281
+ }
282
+ }
283
+ try {
284
+ return { path, seal: await sealWorkflowRunScript(runId, source.fingerprint) };
285
+ } catch (error) {
286
+ process.stderr.write(`[${mod.meta.name}] script seal failed, so /workflow:runs cannot relaunch this run: ${unknownErrorMessage(error)}\n`);
287
+ return { path };
288
+ }
289
+ }
290
+
250
291
  function backgroundPauseError(error: unknown, ...signals: Array<AbortSignal | undefined>): unknown {
251
292
  if (isWorkflowPauseError(error)) return error;
252
293
  for (const signal of signals) {
@@ -345,7 +386,7 @@ function createWorkflowAgent(
345
386
  function agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
346
387
  function agent(prompt: string, opts?: AgentOptions): Promise<string>;
347
388
  function agent(prompt: string, agentOpts?: AgentOptions): Promise<unknown> {
348
- const scopedOptions = scope.agentOptions(agentOpts);
389
+ const scopedOptions = scope.agentOptions(withPhaseProfile(mod.meta, agentOpts));
349
390
  const executionOptions: AgentExecutionOptions =
350
391
  scopedOptions.isolation === "worktree" && mod.isolatedWorktreeBaseline !== undefined
351
392
  ? { ...scopedOptions, worktreeBaseline: mod.isolatedWorktreeBaseline }
@@ -355,6 +396,17 @@ function createWorkflowAgent(
355
396
  return agent;
356
397
  }
357
398
 
399
+ /**
400
+ * Routing order is the agent's `model`, then its `profile`, then its declared phase's `profile`, then
401
+ * the host. Only an explicit `opts.phase` selects a phase profile: the global `phase()` is shared
402
+ * state that concurrent `parallel`/`pipeline` branches race on.
403
+ */
404
+ function withPhaseProfile(meta: WorkflowMeta, opts: AgentOptions | undefined): AgentOptions | undefined {
405
+ if (opts?.phase === undefined || opts.model !== undefined || opts.profile !== undefined) return opts;
406
+ const profile = meta.phases?.find((phase) => phase.title === opts.phase)?.profile;
407
+ return profile === undefined ? opts : { ...opts, profile };
408
+ }
409
+
358
410
  /**
359
411
  * Build the `WorkflowApi` from an existing run context and invoke the module. Reused for both
360
412
  * the top-level run and every `api.workflow()` sub-step, so children share the parent's semaphore,
@@ -364,10 +416,12 @@ export async function runWorkflowWithContext(
364
416
  rc: WorkflowRunContext,
365
417
  progress: WorkflowProgress,
366
418
  mod: LoadedWorkflow,
367
- args: string,
419
+ input: unknown,
368
420
  opts: WorkflowContextOptions,
369
421
  ): Promise<unknown> {
370
422
  const depth = opts.depth ?? 0;
423
+ // Converted before any agent runs, so an argument with no JSON form rejects the call up front.
424
+ const args = workflowArgsText(input);
371
425
  const scope = createWorkflowScope(progress, opts.progressPrefix ?? "", opts.progressNamespace);
372
426
  const resumeContext: AgentResumeBaseContext = {
373
427
  workflow: await captureWorkflowResumeContext(mod, rc.signal),
@@ -388,7 +442,8 @@ export async function runWorkflowWithContext(
388
442
  const childController = new AbortController();
389
443
  const unlink = linkAbortSignal(rc.signal, childController);
390
444
  try {
391
- return await runWorkflowWithContext({ ...rc, signal: childController.signal }, progress, childMod, childArgs ?? "", {
445
+ const childInput = childArgs === undefined ? "" : childArgs;
446
+ return await runWorkflowWithContext({ ...rc, signal: childController.signal }, progress, childMod, childInput, {
392
447
  abortController: childController,
393
448
  submissionLimit: opts.submissionLimit,
394
449
  resolveWorkflow: opts.resolveWorkflow,
@@ -416,6 +471,7 @@ export async function runWorkflowWithContext(
416
471
  log: scope.log,
417
472
  progress: scope.event,
418
473
  args,
474
+ input,
419
475
  cwd: rc.cwd,
420
476
  budget: rc.budget,
421
477
  signal: rc.signal,
@@ -2,3 +2,8 @@
2
2
  export function isMissingPathError(error: unknown): boolean {
3
3
  return error instanceof Error && "code" in error && error.code === "ENOENT";
4
4
  }
5
+
6
+ /** Identify a path that already exists, as reported for an exclusive (`wx`) create. */
7
+ export function isExistingPathError(error: unknown): boolean {
8
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
9
+ }
@@ -36,10 +36,16 @@ export function compileInlineWorkflow(source: string): LoadedWorkflow {
36
36
  return {
37
37
  meta: parsedMeta.meta,
38
38
  default: (api) => executor(api, Type),
39
- source: { kind: "fingerprint", fingerprint: createHash("sha256").update(source).digest("hex") },
39
+ source: { kind: "fingerprint", fingerprint: inlineScriptFingerprint(source) },
40
+ inlineScript: source,
40
41
  };
41
42
  }
42
43
 
44
+ /** A compiled script can import nothing, so the hash of its source is its whole identity. */
45
+ export function inlineScriptFingerprint(source: string): string {
46
+ return createHash("sha256").update(source).digest("hex");
47
+ }
48
+
43
49
  /** The script's pure-literal meta, read without compiling or running anything; undefined when it does not parse. */
44
50
  export function readInlineWorkflowMeta(source: string): WorkflowMeta | undefined {
45
51
  try {
@@ -1,9 +1,10 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, readdir, readFile, rm, stat, appendFile } from "node:fs/promises";
2
+ import { mkdir, readdir, readFile, rm, stat, appendFile, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import type { AgentOptions } from "./types.ts";
5
5
  import type { WorktreeBaseline } from "./worktree.ts";
6
6
  import { canonicalizeIdentity } from "./identity-canonicalization.ts";
7
+ import { isExistingPathError } from "./filesystem-error.ts";
7
8
  import { unknownErrorMessage } from "./unknown-error.ts";
8
9
  import {
9
10
  isAgentResumeContext,
@@ -15,6 +16,9 @@ import {
15
16
  export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
16
17
  export const WORKFLOW_JOURNAL_KEEP = 50;
17
18
 
19
+ /** Ignores everything in the runs directory, including this file, so user projects never commit run data. */
20
+ const WORKFLOW_RUNS_GITIGNORE = "*\n";
21
+
18
22
  export interface JournalEntryV2 {
19
23
  readonly version: 2;
20
24
  readonly key: string;
@@ -56,6 +60,20 @@ export class WorkflowJournalLoadError extends Error {
56
60
  override readonly name = "WorkflowJournalLoadError";
57
61
  }
58
62
 
63
+ /**
64
+ * Create the directory that holds run files, with a `.gitignore` that ignores all of them: records,
65
+ * journals, and scripts can carry prompts and results, and user projects do not list this directory in
66
+ * their own ignore files. An existing `.gitignore` is never overwritten.
67
+ */
68
+ export async function ensureWorkflowRunsDir(dir: string): Promise<void> {
69
+ await mkdir(dir, { recursive: true });
70
+ try {
71
+ await writeFile(join(dir, ".gitignore"), WORKFLOW_RUNS_GITIGNORE, { encoding: "utf8", flag: "wx" });
72
+ } catch (error) {
73
+ if (!isExistingPathError(error)) throw error;
74
+ }
75
+ }
76
+
59
77
  export function createWorkflowRunId(): string {
60
78
  return randomUUID();
61
79
  }
@@ -161,7 +179,7 @@ export async function createWorkflowJournal(options: {
161
179
  readonly writePath: string;
162
180
  }): Promise<WorkflowJournal> {
163
181
  const priorEntries = options.resumePath ? await loadJournalEntries(options.resumePath, { required: true }) : [];
164
- await mkdir(dirname(options.writePath), { recursive: true });
182
+ await ensureWorkflowRunsDir(dirname(options.writePath));
165
183
  await appendFile(options.writePath, "", "utf8");
166
184
  return createMemoryBackedJournal(priorEntries, options.writePath);
167
185
  }
@@ -201,10 +219,10 @@ export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[]
201
219
  const entry: JournalEntryV2 = { version: 2, key, result, identity };
202
220
  if (writePath) {
203
221
  try {
204
- await mkdir(dirname(writePath), { recursive: true });
222
+ await ensureWorkflowRunsDir(dirname(writePath));
205
223
  await appendFile(writePath, `${JSON.stringify(entry)}\n`, "utf8");
206
224
  } catch (error) {
207
- return { ok: false, error: unknownErrorMessage(error) };
225
+ return { ok: false, error: unknownErrorMessage(error) };
208
226
  }
209
227
  }
210
228
  return { ok: true };
@@ -1,12 +1,8 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
1
  import type { Api, Model } from "@earendil-works/pi-ai";
5
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
6
- import { getAgentDir, type ModelRegistry } from "@earendil-works/pi-coding-agent";
7
- import { isMissingPathError } from "./filesystem-error.ts";
3
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
4
+ import { configFilePaths, readJsonConfigFile, writeJsonConfigFile, type ConfigFilePaths } from "./config-file.ts";
8
5
  import { isRecord } from "./guards.ts";
9
- import { unknownErrorMessage } from "./unknown-error.ts";
10
6
 
11
7
  export const WORKFLOW_MODEL_PROFILE_NAMES = ["small", "medium", "big"] as const;
12
8
  export type WorkflowModelProfileName = (typeof WORKFLOW_MODEL_PROFILE_NAMES)[number];
@@ -20,10 +16,7 @@ export interface WorkflowModelProfileFile {
20
16
  readonly profiles: Partial<Record<WorkflowModelProfileName, WorkflowModelProfileConfig>>;
21
17
  }
22
18
 
23
- export interface WorkflowModelProfilePaths {
24
- readonly user: string;
25
- readonly project: string;
26
- }
19
+ export type WorkflowModelProfilePaths = ConfigFilePaths;
27
20
 
28
21
  interface ResolvedWorkflowModelProfileBase {
29
22
  readonly name: WorkflowModelProfileName;
@@ -52,15 +45,10 @@ const HOST_FALLBACK_THINKING: Record<WorkflowModelProfileName, ThinkingLevel> =
52
45
  big: "high",
53
46
  };
54
47
  const CONFIG_FILE_NAME = "workflow-models.json";
48
+ const CONFIG_LABEL = "workflow model profiles";
55
49
 
56
- export function workflowModelProfilePaths(
57
- cwd: string,
58
- agentDir: string = getAgentDir(),
59
- ): WorkflowModelProfilePaths {
60
- return {
61
- user: join(agentDir, CONFIG_FILE_NAME),
62
- project: join(cwd, ".pi", CONFIG_FILE_NAME),
63
- };
50
+ export function workflowModelProfilePaths(cwd: string, agentDir?: string): WorkflowModelProfilePaths {
51
+ return configFilePaths(CONFIG_FILE_NAME, cwd, agentDir);
64
52
  }
65
53
 
66
54
  export function resolveWorkflowModelProfiles(input: {
@@ -123,22 +111,8 @@ export function resolveAgentModelProfile(
123
111
  }
124
112
 
125
113
  export function readWorkflowModelProfileFile(configPath: string): WorkflowModelProfileFile {
126
- let contents: string;
127
- try {
128
- contents = readFileSync(configPath, "utf8");
129
- } catch (error) {
130
- if (isMissingPathError(error)) return { profiles: {} };
131
- throw new Error(
132
- `Could not read workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix the file permissions or remove the file.`,
133
- );
134
- }
135
- let parsed: unknown;
136
- try {
137
- parsed = JSON.parse(contents);
138
- } catch (error) {
139
- throw new Error(`Invalid JSON in workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix or remove the file.`);
140
- }
141
- return parseWorkflowModelProfileFile(parsed, configPath);
114
+ const parsed = readJsonConfigFile(configPath, CONFIG_LABEL);
115
+ return parsed === undefined ? { profiles: {} } : parseWorkflowModelProfileFile(parsed, configPath);
142
116
  }
143
117
 
144
118
  export function setWorkflowModelProfile(input: {
@@ -239,19 +213,7 @@ function assertOnlyKeys(value: Record<string, unknown>, allowed: readonly string
239
213
  }
240
214
 
241
215
  function writeWorkflowModelProfileFile(configPath: string, config: WorkflowModelProfileFile): void {
242
- const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
243
- try {
244
- mkdirSync(dirname(configPath), { recursive: true });
245
- writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
246
- renameSync(temporaryPath, configPath);
247
- } catch (error) {
248
- try {
249
- unlinkSync(temporaryPath);
250
- } catch {
251
- // Best effort: preserve the original write error.
252
- }
253
- throw new Error(`Could not write workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}.`);
254
- }
216
+ writeJsonConfigFile(configPath, config, CONFIG_LABEL);
255
217
  }
256
218
 
257
219
  function invalidConfig(configPath: string, reason: string): Error {
@@ -1,6 +1,6 @@
1
1
  import type { WorkflowUsageSnapshot } from "./usage.ts";
2
2
 
3
- export type AgentRowStatus = "queued" | "running" | "done" | "failed";
3
+ export type AgentRowStatus = "queued" | "running" | "done" | "failed" | "skipped";
4
4
  export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
5
5
 
6
6
  export interface AgentRowSnapshot {