pi-workflow-engine 0.11.0 → 0.12.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 (73) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +0 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  11. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  12. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +11 -3
  13. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  15. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  17. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  18. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +70 -4
  19. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  20. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  21. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  22. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  23. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  24. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  25. package/.pi/extensions/pi-workflow-engine/src/journal.ts +12 -6
  26. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  27. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  28. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  29. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  30. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  31. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  32. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  33. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  34. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  35. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +74 -38
  36. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  43. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  44. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  45. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  46. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  47. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  48. package/.pi/extensions/pi-workflow-engine/src/types.ts +49 -15
  49. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  50. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  51. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  52. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +2 -1
  53. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  54. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  55. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  56. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  57. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  58. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  59. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  60. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  61. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  64. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  65. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +40 -3
  66. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +18 -42
  67. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +10 -17
  68. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -15
  69. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -15
  70. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  71. package/README.md +72 -141
  72. package/USAGE.md +211 -17
  73. package/package.json +1 -1
@@ -1,14 +1,22 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Static, TSchema } from "typebox";
2
3
  import { bindParallel, bindPipeline, Semaphore } from "./concurrency.ts";
3
- import { linkAbortSignal, throwIfAborted } from "./cancellation.ts";
4
+ import { WorkflowAgentLimiter } from "./agent-limits.ts";
5
+ import { defaultAgentRetryScheduler, type AgentRetryScheduler } from "./agent-retry.ts";
6
+ import { resolveWorkflowModelProfiles, type ResolvedWorkflowModelProfiles } from "./model-profiles.ts";
7
+ import { abortReason, isWorkflowPauseError, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
4
8
  import { createBudget } from "./budget.ts";
5
9
  import { runAgent, type AgentExecutionOptions, type RunContext } from "./agent-runner.ts";
6
10
  import { ProgressTracker } from "./progress.ts";
7
11
  import { createPerfRecorder, type PerfSink, type PerfSnapshot } from "./perf.ts";
8
12
  import { createWorkflowUsageRecorder, type WorkflowUsageSink } from "./usage.ts";
9
- import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "./options.ts";
10
- import type { AgentOptions, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
13
+ import {
14
+ resolveWorkflowRunOptions,
15
+ type ResolvedWorkflowRunOptions,
16
+ } from "./options.ts";
17
+ import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
11
18
  import { WorkflowInspector } from "./ui/workflow-inspector.ts";
19
+ import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./ui/workflow-viewer-layout.ts";
12
20
  import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
13
21
  import { WorktreeRegistry } from "./worktree.ts";
14
22
  import { runFinalizers } from "./finalizers.ts";
@@ -17,6 +25,17 @@ import {
17
25
  captureWorkflowResumeContext,
18
26
  type AgentResumeBaseContext,
19
27
  } from "./resume-context.ts";
28
+ import {
29
+ DurableWorkflowRun,
30
+ ProjectWorkflowRunStore,
31
+ workflowRunRecordPath,
32
+ type WorkflowRunStore,
33
+ } from "./workflow-run-store.ts";
34
+ import { createWorkflowRunRecord } from "./workflow-run-record.ts";
35
+ import {
36
+ createProviderUsageLimitPauseRecord,
37
+ WorkflowProviderUsageLimitError,
38
+ } from "./provider-usage-limit.ts";
20
39
 
21
40
  /** The workflow-facing slice of the progress tracker (satisfied by `ProgressTracker`). */
22
41
  export interface WorkflowProgress {
@@ -50,6 +69,8 @@ type Outcome<T> =
50
69
 
51
70
  export interface WorkflowEngineDependencies {
52
71
  readonly worktrees?: WorktreeRegistry;
72
+ readonly retryScheduler?: AgentRetryScheduler;
73
+ readonly modelProfiles?: ResolvedWorkflowModelProfiles;
53
74
  }
54
75
 
55
76
  /**
@@ -74,25 +95,44 @@ export async function runResolvedWorkflow(
74
95
  resolvedOptions: ResolvedWorkflowRunOptions,
75
96
  dependencies: WorkflowEngineDependencies = {},
76
97
  ): Promise<unknown> {
77
- const progress = new ProgressTracker(ctx, mod.meta.name);
98
+ const runId = resolvedOptions.runId ?? createWorkflowRunId();
99
+ let durableProgress: DurableWorkflowRun | undefined;
100
+ const progress = new ProgressTracker(ctx, mod.meta.name, runId, (snapshot) => durableProgress?.updateProgress(snapshot));
78
101
  const progressSource = { snapshot: () => progress.snapshot() };
79
102
  const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
80
- const usage = createWorkflowUsageRecorder();
103
+ const usage = createWorkflowUsageRecorder((snapshot) => progress.updateUsage(snapshot));
81
104
  const budget = createBudget(resolvedOptions.budget, usage);
82
- const runId = resolvedOptions.runId ?? createWorkflowRunId();
83
105
  const journalPath = workflowJournalPath(ctx.cwd, runId);
84
106
  const resumePath = resolvedOptions.resumeFromRunId ? workflowJournalPath(ctx.cwd, resolvedOptions.resumeFromRunId) : undefined;
107
+ const runStore = new ProjectWorkflowRunStore(ctx.cwd);
108
+ let persistenceFailureReported = false;
109
+ const durableRun = new DurableWorkflowRun(
110
+ runStore,
111
+ createWorkflowRunRecord({
112
+ runId,
113
+ workflow: mod,
114
+ options: resolvedOptions,
115
+ progress: progress.snapshot(),
116
+ argumentsPresent: args.length > 0,
117
+ }),
118
+ (error) => {
119
+ if (persistenceFailureReported) return;
120
+ persistenceFailureReported = true;
121
+ process.stderr.write(`[${mod.meta.name}] run persistence failed: ${unknownErrorMessage(error)}\n`);
122
+ },
123
+ );
124
+ durableProgress = durableRun;
85
125
  const worktrees = dependencies.worktrees ?? new WorktreeRegistry(ctx.cwd);
86
126
  const runAbortController = new AbortController();
87
127
  const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
88
128
  const unlinkOptionAbortSignal = linkAbortSignal(resolvedOptions.signal, runAbortController);
89
129
  const workflowOutcome = await captureOutcome(async () => {
90
130
  await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
91
- if (resolvedOptions.inspect && ctx.hasUI) {
131
+ if (resolvedOptions.inspect && ctx.hasUI && ctx.mode === "tui") {
92
132
  void ctx.ui
93
133
  .custom<void>(
94
134
  (tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
95
- { overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
135
+ WORKFLOW_VIEWER_OVERLAY_OPTIONS,
96
136
  )
97
137
  .catch((error: unknown) => {
98
138
  try {
@@ -104,15 +144,34 @@ export async function runResolvedWorkflow(
104
144
  }
105
145
 
106
146
  const journal = await createWorkflowJournal({ resumePath, writePath: journalPath });
147
+ durableRun.transition({ state: "running", progress: progress.snapshot() });
148
+ await durableRun.flush().catch(() => undefined);
107
149
  await notifyLifecycleObserver(progress, "run metadata callback", () =>
108
- resolvedOptions.onRunMetadata?.({ runId, resumedFromRunId: resolvedOptions.resumeFromRunId, journalPath }),
150
+ resolvedOptions.onRunMetadata?.({
151
+ runId,
152
+ resumedFromRunId: resolvedOptions.resumeFromRunId,
153
+ journalPath,
154
+ recordPath: workflowRunRecordPath(ctx.cwd, runId),
155
+ }),
109
156
  );
110
157
  progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
158
+ const modelProfiles = dependencies.modelProfiles ?? resolveWorkflowModelProfiles({
159
+ cwd: ctx.cwd,
160
+ modelRegistry: ctx.modelRegistry,
161
+ hostModel: ctx.model,
162
+ });
111
163
  const rc: WorkflowRunContext = {
112
164
  cwd: ctx.cwd,
113
165
  hostModel: ctx.model,
114
166
  modelRegistry: ctx.modelRegistry,
115
167
  semaphore: new Semaphore(resolvedOptions.concurrency),
168
+ agentLimiter: new WorkflowAgentLimiter(resolvedOptions.maxAgents),
169
+ agentTimeoutMs: resolvedOptions.agentTimeoutMs,
170
+ agentRetries: resolvedOptions.agentRetries,
171
+ pauseOnProviderUsageLimit: resolvedOptions.background !== undefined,
172
+ resumeEditedWorkflow: resolvedOptions.resumeEditedWorkflow,
173
+ retryScheduler: dependencies.retryScheduler ?? defaultAgentRetryScheduler,
174
+ modelProfiles,
116
175
  progress,
117
176
  signal: runAbortController.signal,
118
177
  perf,
@@ -141,11 +200,69 @@ export async function runResolvedWorkflow(
141
200
  perf,
142
201
  usage,
143
202
  progress,
203
+ runStore,
144
204
  worktrees,
145
205
  unlinkSignals: [unlinkContextAbortSignal, unlinkOptionAbortSignal],
146
206
  }),
147
207
  );
148
208
 
209
+ if (workflowOutcome.ok && finalizationOutcome.ok) {
210
+ durableRun.transition({
211
+ state: "completed",
212
+ progress: progress.snapshot(),
213
+ usage: usage.snapshot(),
214
+ result: workflowOutcome.value,
215
+ });
216
+ } else if (!workflowOutcome.ok) {
217
+ const error = finalizationOutcome.ok
218
+ ? workflowOutcome.error
219
+ : combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
220
+ persistTerminalWorkflowError(error);
221
+ } else if (!finalizationOutcome.ok) {
222
+ persistTerminalWorkflowError(finalizationOutcome.error);
223
+ }
224
+ await durableRun.flush().catch(() => undefined);
225
+
226
+ function persistTerminalWorkflowError(error: unknown): void {
227
+ const pauseError = backgroundPauseError(error, ctx.signal, resolvedOptions.signal);
228
+ if (pauseError) {
229
+ if (pauseError instanceof WorkflowProviderUsageLimitError) {
230
+ if (resolvedOptions.background === undefined) {
231
+ durableRun.transition({
232
+ state: "failed",
233
+ progress: progress.snapshot(),
234
+ usage: usage.snapshot(),
235
+ error: pauseError,
236
+ });
237
+ return;
238
+ }
239
+ const providerPause = createProviderUsageLimitPauseRecord(
240
+ pauseError,
241
+ resolvedOptions,
242
+ mod.source.kind === "file" && args.length === 0,
243
+ );
244
+ durableRun.transition({
245
+ state: "paused",
246
+ progress: progress.snapshot(),
247
+ ...providerPause,
248
+ });
249
+ return;
250
+ }
251
+ durableRun.transition({
252
+ state: "paused",
253
+ progress: progress.snapshot(),
254
+ message: unknownErrorMessage(pauseError),
255
+ });
256
+ return;
257
+ }
258
+ durableRun.transition({
259
+ state: ctx.signal?.aborted || resolvedOptions.signal?.aborted ? "stopped" : "failed",
260
+ progress: progress.snapshot(),
261
+ usage: usage.snapshot(),
262
+ error,
263
+ });
264
+ }
265
+
149
266
  if (workflowOutcome.ok) {
150
267
  if (finalizationOutcome.ok) return workflowOutcome.value;
151
268
  throw finalizationOutcome.error;
@@ -154,6 +271,16 @@ export async function runResolvedWorkflow(
154
271
  throw combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
155
272
  }
156
273
 
274
+ function backgroundPauseError(error: unknown, ...signals: Array<AbortSignal | undefined>): unknown {
275
+ if (isWorkflowPauseError(error)) return error;
276
+ for (const signal of signals) {
277
+ if (!signal?.aborted) continue;
278
+ const reason = abortReason(signal);
279
+ if (isWorkflowPauseError(reason)) return reason;
280
+ }
281
+ return undefined;
282
+ }
283
+
157
284
  async function captureOutcome<T>(operation: () => Promise<T>): Promise<Outcome<T>> {
158
285
  try {
159
286
  return { ok: true, value: await operation() };
@@ -168,6 +295,7 @@ interface WorkflowFinalizationInput {
168
295
  readonly perf: PerfSink;
169
296
  readonly usage: WorkflowUsageSink;
170
297
  readonly progress: ProgressTracker;
298
+ readonly runStore: WorkflowRunStore;
171
299
  readonly worktrees: WorktreeRegistry;
172
300
  readonly unlinkSignals: readonly (() => void)[];
173
301
  }
@@ -201,6 +329,7 @@ async function finalizeWorkflowRun(input: WorkflowFinalizationInput): Promise<vo
201
329
  run: () => input.worktrees.removeAll(),
202
330
  },
203
331
  { name: "journal pruning", criticality: "best-effort", run: () => pruneWorkflowJournals(input.cwd) },
332
+ { name: "run record pruning", criticality: "best-effort", run: () => input.runStore.prune() },
204
333
  { name: "progress completion", criticality: "best-effort", run: () => input.progress.done() },
205
334
  {
206
335
  name: "progress snapshot callback",
@@ -232,6 +361,30 @@ function combinedWorkflowError(workflowError: unknown, finalizationError: unknow
232
361
  );
233
362
  }
234
363
 
364
+ function createWorkflowAgent(
365
+ rc: WorkflowRunContext,
366
+ scope: WorkflowScope,
367
+ mod: LoadedWorkflow,
368
+ resumeContext: AgentResumeBaseContext,
369
+ ): WorkflowApi["agent"] {
370
+ function agent<S extends TSchema>(
371
+ prompt: string,
372
+ opts: AgentOptions<S> & { schema: S; isolation: "worktree" },
373
+ ): Promise<IsolatedAgentResult<Static<S>>>;
374
+ function agent(prompt: string, opts: AgentOptions & { isolation: "worktree" }): Promise<IsolatedAgentResult<string>>;
375
+ function agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
376
+ function agent(prompt: string, opts?: AgentOptions): Promise<string>;
377
+ function agent(prompt: string, agentOpts?: AgentOptions): Promise<unknown> {
378
+ const scopedOptions = scope.agentOptions(agentOpts);
379
+ const executionOptions: AgentExecutionOptions =
380
+ scopedOptions.isolation === "worktree" && mod.isolatedWorktreeBaseline !== undefined
381
+ ? { ...scopedOptions, worktreeBaseline: mod.isolatedWorktreeBaseline }
382
+ : scopedOptions;
383
+ return runAgent(rc, prompt, executionOptions, resumeContext);
384
+ }
385
+ return agent;
386
+ }
387
+
235
388
  /**
236
389
  * Build the `WorkflowApi` from an existing run context and invoke the module. Reused for both
237
390
  * the top-level run and every `api.workflow()` sub-step, so children share the parent's semaphore,
@@ -250,14 +403,7 @@ export async function runWorkflowWithContext(
250
403
  workflow: await captureWorkflowResumeContext(mod, rc.signal),
251
404
  };
252
405
 
253
- const agent = ((prompt: string, agentOpts?: AgentOptions) => {
254
- const scopedOptions = scope.agentOptions(agentOpts);
255
- const executionOptions: AgentExecutionOptions =
256
- scopedOptions.isolation === "worktree" && mod.isolatedWorktreeBaseline !== undefined
257
- ? { ...scopedOptions, worktreeBaseline: mod.isolatedWorktreeBaseline }
258
- : scopedOptions;
259
- return runAgent(rc, prompt, executionOptions, resumeContext);
260
- }) as WorkflowApi["agent"];
406
+ const agent = createWorkflowAgent(rc, scope, mod, resumeContext);
261
407
 
262
408
  const workflow: WorkflowApi["workflow"] =
263
409
  depth >= 1
@@ -305,7 +451,9 @@ export async function runWorkflowWithContext(
305
451
  };
306
452
 
307
453
  throwIfAborted(rc.signal);
308
- return await mod.default(api);
454
+ const result = await mod.default(api);
455
+ throwIfAborted(rc.signal);
456
+ return result;
309
457
  }
310
458
 
311
459
  interface WorkflowScope {
@@ -0,0 +1,4 @@
1
+ /** Identify a missing filesystem path reported by Node's filesystem APIs. */
2
+ export function isMissingPathError(error: unknown): boolean {
3
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
4
+ }
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { createHash } from "node:crypto";
3
3
  import type { LoadedWorkflow, WorkflowApi } from "./types.ts";
4
+ import { unknownErrorMessage } from "./unknown-error.ts";
4
5
  import { parseWorkflowMeta } from "./workflow-module.ts";
5
6
 
6
7
  /**
@@ -138,8 +139,7 @@ function compileExecutor(defaultExpression: string): InlineWorkflowExecutor {
138
139
  try {
139
140
  return new AsyncFunction("api", "Type", `"use strict";\nconst workflow = ${defaultExpression};\nreturn await workflow(api);`);
140
141
  } catch (error) {
141
- const message = error instanceof Error ? error.message : String(error);
142
- throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${message}`);
142
+ throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${unknownErrorMessage(error)}`);
143
143
  }
144
144
  }
145
145
 
@@ -9,6 +9,7 @@ import {
9
9
  isAgentResumeContext,
10
10
  resumeContextMismatchReason,
11
11
  type AgentResumeContext,
12
+ type ResumeContextComparisonOptions,
12
13
  } from "./resume-context.ts";
13
14
 
14
15
  export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
@@ -47,7 +48,7 @@ export type AgentJournalKeyCapture =
47
48
  | { readonly kind: "unverifiable"; readonly reason: string };
48
49
 
49
50
  export interface WorkflowJournal {
50
- lookup(key: string, identity: AgentResumeContext): JournalLookup;
51
+ lookup(key: string, identity: AgentResumeContext, options?: ResumeContextComparisonOptions): JournalLookup;
51
52
  record(key: string, result: unknown, identity: AgentResumeContext): Promise<JournalRecordResult>;
52
53
  }
53
54
 
@@ -60,7 +61,7 @@ export function createWorkflowRunId(): string {
60
61
  }
61
62
 
62
63
  export function workflowJournalPath(cwd: string, runId: string): string {
63
- return join(cwd, WORKFLOW_RUNS_DIR, `${validateRunId(runId)}.jsonl`);
64
+ return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}.jsonl`);
64
65
  }
65
66
 
66
67
  export function agentJournalKey(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
@@ -110,6 +111,7 @@ function captureAgentCallHash(
110
111
  thinkingLevel: opts.thinkingLevel,
111
112
  tools: opts.tools,
112
113
  toolHints: opts.toolHints,
114
+ requireToolHints: opts.requireToolHints === true ? true : undefined,
113
115
  skills: opts.skills,
114
116
  schema: opts.schema,
115
117
  isolation: opts.isolation,
@@ -170,6 +172,8 @@ export async function createWorkflowJournal(options: {
170
172
  readonly writePath: string;
171
173
  }): Promise<WorkflowJournal> {
172
174
  const priorEntries = options.resumePath ? await loadJournalEntries(options.resumePath, { required: true }) : [];
175
+ await mkdir(dirname(options.writePath), { recursive: true });
176
+ await appendFile(options.writePath, "", "utf8");
173
177
  return createMemoryBackedJournal(priorEntries, options.writePath);
174
178
  }
175
179
 
@@ -182,7 +186,7 @@ export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[]
182
186
  }
183
187
 
184
188
  return {
185
- lookup(key, identity) {
189
+ lookup(key, identity, options) {
186
190
  const entries = priorByKey.get(key);
187
191
  if (!entries || entries.length === 0) return { hit: false };
188
192
 
@@ -197,10 +201,12 @@ export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[]
197
201
  : "journal entry predates replay contract v2",
198
202
  };
199
203
  }
200
- const matches = current.filter((entry) => resumeContextMismatchReason(entry.identity, identity) === undefined);
204
+ const matches = current.filter(
205
+ (entry) => resumeContextMismatchReason(entry.identity, identity, options) === undefined,
206
+ );
201
207
  if (matches.length === 1) return { hit: true, value: matches[0]!.result };
202
208
  if (matches.length > 1) return { hit: false, reason: "multiple cached entries match this agent call" };
203
- return { hit: false, reason: resumeContextMismatchReason(current[0]!.identity, identity) };
209
+ return { hit: false, reason: resumeContextMismatchReason(current[0]!.identity, identity, options) };
204
210
  },
205
211
  async record(key, result, identity) {
206
212
  const entry: JournalEntryV2 = { version: 2, key, result, identity };
@@ -248,7 +254,7 @@ export async function pruneWorkflowJournals(cwd: string, keep = WORKFLOW_JOURNAL
248
254
  await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
249
255
  }
250
256
 
251
- function validateRunId(runId: string): string {
257
+ export function validateWorkflowRunId(runId: string): string {
252
258
  const trimmed = runId.trim();
253
259
  if (!/^[A-Za-z0-9_.-]+$/.test(trimmed)) {
254
260
  throw new Error("Workflow run id must contain only letters, numbers, dots, underscores, or hyphens.");
@@ -0,0 +1,167 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+ import {
4
+ clearWorkflowModelProfile,
5
+ isWorkflowModelProfileName,
6
+ isWorkflowThinkingLevel,
7
+ resolveWorkflowModelProfiles,
8
+ setWorkflowModelProfile,
9
+ workflowModelProfilePaths,
10
+ WORKFLOW_MODEL_PROFILE_NAMES,
11
+ WORKFLOW_THINKING_LEVELS,
12
+ type ResolvedWorkflowModelProfiles,
13
+ type WorkflowModelProfilePaths,
14
+ type WorkflowModelProfileName,
15
+ } from "./model-profiles.ts";
16
+ import { unknownErrorMessage } from "./unknown-error.ts";
17
+ import { completeCurrentArgument, splitArgumentPrefix } from "./command-completions.ts";
18
+
19
+ type ConfigScope = "user" | "project";
20
+
21
+ type WorkflowModelsCommand =
22
+ | { readonly kind: "status" }
23
+ | {
24
+ readonly kind: "set";
25
+ readonly scope: ConfigScope;
26
+ readonly profile: WorkflowModelProfileName;
27
+ readonly model: string;
28
+ readonly thinkingLevel?: Parameters<typeof setWorkflowModelProfile>[0]["profile"]["thinkingLevel"];
29
+ }
30
+ | { readonly kind: "clear"; readonly scope: ConfigScope; readonly profile: WorkflowModelProfileName }
31
+ | { readonly kind: "error"; readonly message: string };
32
+
33
+ const USAGE =
34
+ "Usage: /workflow:models [status | set <small|medium|big> <provider/model> [thinkingLevel] [--user|--project] | clear <small|medium|big> [--user|--project]]";
35
+ const MODEL_COMMAND_ACTIONS = [
36
+ { value: "status", description: "Show resolved workflow model profiles" },
37
+ { value: "set", description: "Configure an exact model for a profile" },
38
+ { value: "clear", description: "Remove a configured profile" },
39
+ ] as const;
40
+ const MODEL_COMMAND_SCOPES = [
41
+ { value: "--user", description: "Write the user-level workflow model config" },
42
+ { value: "--project", description: "Write the project-level workflow model config" },
43
+ ] as const;
44
+
45
+ export function registerWorkflowModelProfileCommand(pi: ExtensionAPI): void {
46
+ pi.registerCommand("workflow:models", {
47
+ description: "Inspect or configure exact small, medium, and big workflow model profiles",
48
+ getArgumentCompletions: workflowModelProfileArgumentCompletions,
49
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
50
+ const command = parseWorkflowModelsCommand(args);
51
+ if (command.kind === "error") {
52
+ ctx.ui.notify(`${command.message}\n${USAGE}`, "warning");
53
+ return;
54
+ }
55
+
56
+ try {
57
+ const paths = workflowModelProfilePaths(ctx.cwd);
58
+ if (command.kind === "set") {
59
+ setWorkflowModelProfile({
60
+ configPath: paths[command.scope],
61
+ name: command.profile,
62
+ profile: {
63
+ model: command.model,
64
+ ...(command.thinkingLevel === undefined ? {} : { thinkingLevel: command.thinkingLevel }),
65
+ },
66
+ modelRegistry: ctx.modelRegistry,
67
+ });
68
+ } else if (command.kind === "clear") {
69
+ clearWorkflowModelProfile(paths[command.scope], command.profile);
70
+ }
71
+
72
+ const profiles = resolveWorkflowModelProfiles({
73
+ cwd: ctx.cwd,
74
+ modelRegistry: ctx.modelRegistry,
75
+ hostModel: ctx.model,
76
+ paths,
77
+ });
78
+ const prefix = command.kind === "status"
79
+ ? "Workflow model profiles"
80
+ : `${command.profile} ${command.kind === "set" ? "updated" : "cleared"} in ${command.scope} config`;
81
+ ctx.ui.notify(`${prefix}\n${formatWorkflowModelProfiles(profiles, paths)}`, "info");
82
+ } catch (error) {
83
+ ctx.ui.notify(unknownErrorMessage(error), "error");
84
+ }
85
+ },
86
+ });
87
+ }
88
+
89
+ export function workflowModelProfileArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
90
+ const parts = splitArgumentPrefix(argumentPrefix);
91
+ const completedWithoutScopes = parts.completed.filter((token) => token !== "--user" && token !== "--project");
92
+ const action = completedWithoutScopes[0];
93
+ const scopes = parts.completed.some((token) => token === "--user" || token === "--project")
94
+ ? []
95
+ : MODEL_COMMAND_SCOPES;
96
+
97
+ if (completedWithoutScopes.length === 0) {
98
+ return completeCurrentArgument(argumentPrefix, MODEL_COMMAND_ACTIONS);
99
+ }
100
+ if (action === "status") return null;
101
+ if (completedWithoutScopes.length === 1) {
102
+ return completeCurrentArgument(
103
+ argumentPrefix,
104
+ WORKFLOW_MODEL_PROFILE_NAMES.map((value) => ({ value, description: `${value} workflow model profile` })),
105
+ );
106
+ }
107
+ if (action === "clear") return completeCurrentArgument(argumentPrefix, scopes);
108
+ if (action !== "set") return null;
109
+
110
+ if (completedWithoutScopes.length === 2) {
111
+ return null;
112
+ }
113
+ if (completedWithoutScopes.length === 3) {
114
+ return completeCurrentArgument(argumentPrefix, [
115
+ ...WORKFLOW_THINKING_LEVELS.map((value) => ({ value, description: "Thinking level" })),
116
+ ...scopes,
117
+ ]);
118
+ }
119
+ return completeCurrentArgument(argumentPrefix, scopes);
120
+ }
121
+
122
+ export function parseWorkflowModelsCommand(args: string): WorkflowModelsCommand {
123
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
124
+ if (tokens.length === 0 || (tokens.length === 1 && tokens[0] === "status")) return { kind: "status" };
125
+
126
+ const userScope = tokens.includes("--user");
127
+ const projectScope = tokens.includes("--project");
128
+ if (userScope && projectScope) return { kind: "error", message: "Choose either --user or --project, not both." };
129
+ const scope: ConfigScope = projectScope ? "project" : "user";
130
+ const positional = tokens.filter((token) => token !== "--user" && token !== "--project");
131
+ const [action, profile, model, thinkingLevel, ...extra] = positional;
132
+ if (extra.length > 0) return { kind: "error", message: "Too many /workflow:models arguments." };
133
+ if (!profile || !isWorkflowModelProfileName(profile)) {
134
+ return { kind: "error", message: "Profile must be small, medium, or big." };
135
+ }
136
+
137
+ if (action === "clear") {
138
+ if (model !== undefined) return { kind: "error", message: "The clear action accepts only a profile and optional scope." };
139
+ return { kind: "clear", scope, profile };
140
+ }
141
+ if (action !== "set") return { kind: "error", message: `Unknown /workflow:models action "${action ?? ""}".` };
142
+ if (!model) return { kind: "error", message: "The set action requires an exact provider/model identity." };
143
+ if (thinkingLevel !== undefined && !isWorkflowThinkingLevel(thinkingLevel)) {
144
+ return { kind: "error", message: "Thinking level must be off, minimal, low, medium, high, xhigh, or max." };
145
+ }
146
+ return {
147
+ kind: "set",
148
+ scope,
149
+ profile,
150
+ model,
151
+ ...(thinkingLevel === undefined ? {} : { thinkingLevel }),
152
+ };
153
+ }
154
+
155
+ function formatWorkflowModelProfiles(
156
+ profiles: ResolvedWorkflowModelProfiles,
157
+ paths: WorkflowModelProfilePaths,
158
+ ): string {
159
+ const lines = WORKFLOW_MODEL_PROFILE_NAMES.map((name) => {
160
+ const profile = profiles[name];
161
+ const model = profile.model ? `${profile.model.provider}/${profile.model.id}` : "(no host model)";
162
+ const thinking = profile.thinkingLevel ?? "host";
163
+ const source = profile.source === "host" ? "host fallback" : `${profile.source}: ${profile.configPath}`;
164
+ return `${name}: ${model} · thinking ${thinking} · ${source}`;
165
+ });
166
+ return [...lines, `user: ${paths.user}`, `project: ${paths.project} (overrides user)`].join("\n");
167
+ }