pi-workflow-engine 1.1.0 → 1.3.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 (55) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -85
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +5 -2
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +8 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +4 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
  6. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  7. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
  8. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -3
  9. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  10. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  11. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  12. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  13. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  14. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +112 -31
  15. package/.pi/extensions/pi-workflow-engine/src/engine.ts +75 -18
  16. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -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/progress-types.ts +1 -1
  21. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  22. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  23. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -4
  24. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  25. package/.pi/extensions/pi-workflow-engine/src/types.ts +54 -9
  26. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  27. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  28. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  29. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  30. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  31. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  32. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +82 -23
  33. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  34. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  35. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  44. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  47. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +59 -22
  48. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +7 -4
  49. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +7 -4
  50. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +7 -4
  51. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  52. package/README.md +11 -3
  53. package/USAGE.md +124 -23
  54. package/package.json +13 -4
  55. package/skills/workflow-authoring/SKILL.md +162 -0
@@ -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,8 +471,10 @@ 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,
477
+ agents: { max: rc.agentLimiter.maxAgents, remaining: () => rc.agentLimiter.remaining() },
421
478
  signal: rc.signal,
422
479
  };
423
480
 
@@ -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 {
@@ -69,7 +69,9 @@ export class ProgressTracker {
69
69
  private readonly lanes = new Map<string, WorkflowLaneItem[]>();
70
70
  private readonly laneOverflow = new Map<string, number>();
71
71
  private readonly rowsById = new Map<number, AgentRow>();
72
- private readonly agentCounts: Record<AgentRowStatus, number> = { queued: 0, running: 0, done: 0, failed: 0 };
72
+ /** How to abort each running row's agent; never part of a snapshot, so it is never persisted. */
73
+ private readonly skipHandlers = new Map<number, () => void>();
74
+ private readonly agentCounts: Record<AgentRowStatus, number> = { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 };
73
75
  private readonly startedAt = Date.now();
74
76
  private readonly laneItemLimit = laneItemLimitFromEnv();
75
77
  private doneAt: number | undefined;
@@ -168,12 +170,13 @@ export class ProgressTracker {
168
170
  return id;
169
171
  }
170
172
 
171
- agentStart(id: number): void {
173
+ agentStart(id: number, skip?: () => void): void {
172
174
  const row = this.rowsById.get(id);
173
175
  if (row) {
174
176
  this.transitionAgentStatus(row, "running");
175
177
  row.startedAt = Date.now();
176
178
  row.error = undefined;
179
+ if (skip) this.skipHandlers.set(id, skip);
177
180
  }
178
181
  this.publish();
179
182
  }
@@ -188,8 +191,9 @@ export class ProgressTracker {
188
191
  }
189
192
 
190
193
  agentDone(id: number): void {
194
+ this.skipHandlers.delete(id);
191
195
  const row = this.rowsById.get(id);
192
- if (row && row.status !== "failed") {
196
+ if (row && row.status !== "failed" && row.status !== "skipped") {
193
197
  this.transitionAgentStatus(row, "done");
194
198
  row.doneAt = Date.now();
195
199
  }
@@ -197,6 +201,7 @@ export class ProgressTracker {
197
201
  }
198
202
 
199
203
  agentFailed(id: number, error: unknown): void {
204
+ this.skipHandlers.delete(id);
200
205
  const row = this.rowsById.get(id);
201
206
  if (row) {
202
207
  this.transitionAgentStatus(row, "failed");
@@ -206,6 +211,28 @@ export class ProgressTracker {
206
211
  this.publish();
207
212
  }
208
213
 
214
+ agentSkipped(id: number): void {
215
+ this.skipHandlers.delete(id);
216
+ const row = this.rowsById.get(id);
217
+ if (row) {
218
+ this.transitionAgentStatus(row, "skipped");
219
+ row.doneAt = Date.now();
220
+ }
221
+ this.publish();
222
+ }
223
+
224
+ /**
225
+ * Abort one running agent at the user's request (the inspector's skip key); false when the row is
226
+ * no longer running. The row turns `skipped` once the agent's session has wound down.
227
+ */
228
+ skipAgent(id: number): boolean {
229
+ const skip = this.skipHandlers.get(id);
230
+ if (!skip || this.rowsById.get(id)?.status !== "running") return false;
231
+ this.skipHandlers.delete(id);
232
+ skip();
233
+ return true;
234
+ }
235
+
209
236
  updateUsage(snapshot: WorkflowUsageSnapshot): void {
210
237
  this.usageSnapshot = snapshot;
211
238
  this.publish();
@@ -238,7 +265,8 @@ export class ProgressTracker {
238
265
  running: this.agentCounts.running,
239
266
  done: this.agentCounts.done,
240
267
  failed: this.agentCounts.failed,
241
- total: this.agentCounts.queued + this.agentCounts.running + this.agentCounts.done + this.agentCounts.failed,
268
+ skipped: this.agentCounts.skipped,
269
+ total: this.agentCounts.queued + this.agentCounts.running + this.agentCounts.done + this.agentCounts.failed + this.agentCounts.skipped,
242
270
  };
243
271
  }
244
272
 
@@ -0,0 +1,15 @@
1
+ import type { BeforeAgentStartEventResult, NormalizedBuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
2
+
3
+ type PromptSectionOptions = Pick<NormalizedBuildSystemPromptOptions, "sections" | "forceSystemPrompt">;
4
+
5
+ /**
6
+ * Finish adding the `name` section from a `before_agent_start` handler. Normally the section in
7
+ * `options.sections` is enough and pi appends it as a patch. An earlier handler that forced the whole
8
+ * prompt makes pi render no sections, so the section is appended to that text instead; pi hands the
9
+ * returned prompt to later handlers as their `forceSystemPrompt`, so several sections chain.
10
+ */
11
+ export function appendSectionToForcedPrompt(options: PromptSectionOptions, name: string): BeforeAgentStartEventResult | undefined {
12
+ const text = options.sections[name];
13
+ if (options.forceSystemPrompt === undefined || text === undefined) return undefined;
14
+ return { systemPrompt: `${options.forceSystemPrompt}\n\n<${name}>\n${text}\n</${name}>` };
15
+ }
@@ -29,7 +29,8 @@ export function normalizeResearchLanes(plan: ResearchPlan): ResearchLane[] {
29
29
  return lanes;
30
30
  }
31
31
 
32
- export function buildClaimCandidates(results: readonly ResearchLaneResult[]): ResearchClaimCandidate[] {
32
+ /** Distinct claims, most important first, bounded to {@link MAX_VERIFICATION_CLAIMS}; `overCap` counts the claims cut. */
33
+ export function buildClaimCandidates(results: readonly ResearchLaneResult[]): { candidates: ResearchClaimCandidate[]; overCap: number } {
33
34
  const byClaim = new Map<string, { claim: string; importance: ResearchEvidence["importance"]; evidence: ResearchEvidence[] }>();
34
35
  for (const item of results.flatMap((result) => result.evidence.map(sanitizeEvidence).filter(isDefined))) {
35
36
  const key = item.claim.toLowerCase();
@@ -41,9 +42,8 @@ export function buildClaimCandidates(results: readonly ResearchLaneResult[]): Re
41
42
  if (IMPORTANCE_RANK[item.importance] < IMPORTANCE_RANK[current.importance]) current.importance = item.importance;
42
43
  current.evidence = dedupeEvidence([...current.evidence, item]);
43
44
  }
44
- return [...byClaim.values()]
45
- .sort((left, right) => IMPORTANCE_RANK[left.importance] - IMPORTANCE_RANK[right.importance])
46
- .slice(0, MAX_VERIFICATION_CLAIMS);
45
+ const ranked = [...byClaim.values()].sort((left, right) => IMPORTANCE_RANK[left.importance] - IMPORTANCE_RANK[right.importance]);
46
+ return { candidates: ranked.slice(0, MAX_VERIFICATION_CLAIMS), overCap: Math.max(0, ranked.length - MAX_VERIFICATION_CLAIMS) };
47
47
  }
48
48
 
49
49
  export function sanitizeVerification(
@@ -438,7 +438,7 @@ export async function captureWorkflowResumeContext(
438
438
  if (mod.source.kind === "unverifiable") {
439
439
  return unverifiableWorkflowResumeContext(mod.meta.name, mod.source.reason);
440
440
  }
441
- if (mod.source.kind === "fingerprint") {
441
+ if (mod.source.kind === "fingerprint" || mod.source.selfContained) {
442
442
  return mod.source.fingerprint.length > 0
443
443
  ? { kind: "verified", name: mod.meta.name, sourceFingerprint: mod.source.fingerprint }
444
444
  : unverifiableWorkflowResumeContext(mod.meta.name, "workflow source fingerprint is empty");