pi-workflow-engine 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +236 -94
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
  3. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +10 -3
  4. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  5. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +71 -23
  6. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +12 -3
  7. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  8. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  9. package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
  10. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  11. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  12. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  13. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -40
  14. package/.pi/extensions/pi-workflow-engine/src/engine.ts +78 -18
  15. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
  16. package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
  17. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
  18. package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
  19. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
  20. package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
  21. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
  22. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
  23. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  24. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  25. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +8 -6
  26. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  27. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
  28. package/.pi/extensions/pi-workflow-engine/src/types.ts +68 -9
  29. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  30. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  31. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  32. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  33. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  34. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  35. package/.pi/extensions/pi-workflow-engine/src/usage.ts +20 -2
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +123 -9
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  44. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  47. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  48. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  49. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  50. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  51. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
  52. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
  53. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
  54. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
  55. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  56. package/README.md +12 -4
  57. package/USAGE.md +167 -23
  58. package/package.json +13 -4
  59. package/skills/workflow-authoring/SKILL.md +160 -0
@@ -7,6 +7,7 @@ import { resolveWorkflowModelProfiles, type ResolvedWorkflowModelProfiles } from
7
7
  import { abortReason, isWorkflowPauseError, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
8
8
  import { createBudget } from "./budget.ts";
9
9
  import { runAgent, type AgentExecutionOptions, type RunContext } from "./agent-runner.ts";
10
+ import { runClassifier } from "./classify.ts";
10
11
  import { ProgressTracker } from "./progress.ts";
11
12
  import { createPerfRecorder, formatPerfSummary, type PerfSink } from "./perf.ts";
12
13
  import { createWorkflowUsageRecorder, type WorkflowUsageSink } from "./usage.ts";
@@ -14,11 +15,12 @@ import {
14
15
  resolveWorkflowRunOptions,
15
16
  type ResolvedWorkflowRunOptions,
16
17
  } from "./options.ts";
17
- 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";
18
19
  import { showWorkflowInspector } from "./ui/workflow-inspector.ts";
19
20
  import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
20
21
  import { WorktreeRegistry } from "./worktree.ts";
21
22
  import { runFinalizers } from "./finalizers.ts";
23
+ import { workflowArgsText, workflowInputPresent } from "./workflow-input.ts";
22
24
  import { unknownErrorMessage } from "./unknown-error.ts";
23
25
  import {
24
26
  captureWorkflowResumeContext,
@@ -26,11 +28,14 @@ import {
26
28
  } from "./resume-context.ts";
27
29
  import {
28
30
  DurableWorkflowRun,
31
+ persistWorkflowRunScript,
29
32
  ProjectWorkflowRunStore,
30
33
  workflowRunRecordPath,
31
34
  type WorkflowRunStore,
32
35
  } from "./workflow-run-store.ts";
33
36
  import { createWorkflowRunRecord } from "./workflow-run-record.ts";
37
+ import { canRelaunchWorkflowRun } from "./workflow-run-history.ts";
38
+ import { sealWorkflowRunScript } from "./workflow-run-seal.ts";
34
39
  import {
35
40
  createProviderUsageLimitPauseRecord,
36
41
  WorkflowProviderUsageLimitError,
@@ -75,22 +80,23 @@ export interface WorkflowEngineDependencies {
75
80
  /**
76
81
  * Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
77
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.
78
84
  */
79
85
  export async function runWorkflow(
80
86
  ctx: ExtensionContext,
81
87
  mod: LoadedWorkflow,
82
- args: string,
88
+ input: unknown,
83
89
  options: WorkflowRunOptions = {},
84
90
  dependencies: WorkflowEngineDependencies = {},
85
91
  ): Promise<unknown> {
86
- return await runResolvedWorkflow(ctx, mod, args, resolveWorkflowRunOptions(options), dependencies);
92
+ return await runResolvedWorkflow(ctx, mod, input, resolveWorkflowRunOptions(options), dependencies);
87
93
  }
88
94
 
89
95
  /** Execute an options-normalized workflow without re-reading environment defaults. */
90
96
  export async function runResolvedWorkflow(
91
97
  ctx: ExtensionContext,
92
98
  mod: LoadedWorkflow,
93
- args: string,
99
+ input: unknown,
94
100
  resolvedOptions: ResolvedWorkflowRunOptions,
95
101
  dependencies: WorkflowEngineDependencies = {},
96
102
  ): Promise<unknown> {
@@ -106,16 +112,20 @@ export async function runResolvedWorkflow(
106
112
  const journalPath = workflowJournalPath(ctx.cwd, runId);
107
113
  const resumePath = resolvedOptions.resumeFromRunId ? workflowJournalPath(ctx.cwd, resolvedOptions.resumeFromRunId) : undefined;
108
114
  const runStore = new ProjectWorkflowRunStore(ctx.cwd);
115
+ const script = await sealRunScript(ctx.cwd, runId, mod);
109
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
+ });
110
126
  const durableRun = new DurableWorkflowRun(
111
127
  runStore,
112
- createWorkflowRunRecord({
113
- runId,
114
- workflow: mod,
115
- options: resolvedOptions,
116
- progress: progress.snapshot(),
117
- argumentsPresent: args.length > 0,
118
- }),
128
+ initialRecord,
119
129
  (error) => {
120
130
  if (persistenceFailureReported) return;
121
131
  persistenceFailureReported = true;
@@ -123,7 +133,11 @@ export async function runResolvedWorkflow(
123
133
  },
124
134
  );
125
135
  durableProgress = durableRun;
126
- 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
+ };
127
141
  const worktrees = dependencies.worktrees ?? new WorktreeRegistry(ctx.cwd);
128
142
  const runAbortController = new AbortController();
129
143
  const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
@@ -131,7 +145,7 @@ export async function runResolvedWorkflow(
131
145
  const workflowOutcome = await captureOutcome(async () => {
132
146
  await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
133
147
  if (resolvedOptions.inspect && ctx.hasUI && ctx.mode === "tui") {
134
- void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }))
148
+ void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }), progressSource)
135
149
  .catch((error: unknown) => {
136
150
  try {
137
151
  progress.log(`inspector failed: ${unknownErrorMessage(error)}`);
@@ -150,6 +164,7 @@ export async function runResolvedWorkflow(
150
164
  resumedFromRunId: resolvedOptions.resumeFromRunId,
151
165
  journalPath,
152
166
  recordPath: workflowRunRecordPath(ctx.cwd, runId),
167
+ ...(script.path === undefined ? {} : { scriptPath: script.path }),
153
168
  }),
154
169
  );
155
170
  progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
@@ -177,11 +192,13 @@ export async function runResolvedWorkflow(
177
192
  budget,
178
193
  journal,
179
194
  worktrees,
195
+ hostTools: resolvedOptions.hostTools,
196
+ classifierRoutes: new Map(),
180
197
  };
181
198
 
182
199
  // perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
183
200
  return perf.time("workflow.total_ms", () =>
184
- runWorkflowWithContext(rc, progress, mod, args, {
201
+ runWorkflowWithContext(rc, progress, mod, input, {
185
202
  abortController: runAbortController,
186
203
  submissionLimit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
187
204
  resolveWorkflow: resolvedOptions.resolveWorkflow,
@@ -222,7 +239,7 @@ export async function runResolvedWorkflow(
222
239
  durableRun.transition({
223
240
  state: "paused",
224
241
  progress: progress.snapshot(),
225
- ...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions, mod.source.kind === "file" && args.length === 0),
242
+ ...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions, canRelaunchWorkflowRun(initialRecord)),
226
243
  });
227
244
  } else if (pauseError) {
228
245
  durableRun.transition({
@@ -244,6 +261,33 @@ export async function runResolvedWorkflow(
244
261
  throw outcome.error;
245
262
  }
246
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
+
247
291
  function backgroundPauseError(error: unknown, ...signals: Array<AbortSignal | undefined>): unknown {
248
292
  if (isWorkflowPauseError(error)) return error;
249
293
  for (const signal of signals) {
@@ -342,7 +386,7 @@ function createWorkflowAgent(
342
386
  function agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
343
387
  function agent(prompt: string, opts?: AgentOptions): Promise<string>;
344
388
  function agent(prompt: string, agentOpts?: AgentOptions): Promise<unknown> {
345
- const scopedOptions = scope.agentOptions(agentOpts);
389
+ const scopedOptions = scope.agentOptions(withPhaseProfile(mod.meta, agentOpts));
346
390
  const executionOptions: AgentExecutionOptions =
347
391
  scopedOptions.isolation === "worktree" && mod.isolatedWorktreeBaseline !== undefined
348
392
  ? { ...scopedOptions, worktreeBaseline: mod.isolatedWorktreeBaseline }
@@ -352,6 +396,17 @@ function createWorkflowAgent(
352
396
  return agent;
353
397
  }
354
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
+
355
410
  /**
356
411
  * Build the `WorkflowApi` from an existing run context and invoke the module. Reused for both
357
412
  * the top-level run and every `api.workflow()` sub-step, so children share the parent's semaphore,
@@ -361,10 +416,12 @@ export async function runWorkflowWithContext(
361
416
  rc: WorkflowRunContext,
362
417
  progress: WorkflowProgress,
363
418
  mod: LoadedWorkflow,
364
- args: string,
419
+ input: unknown,
365
420
  opts: WorkflowContextOptions,
366
421
  ): Promise<unknown> {
367
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);
368
425
  const scope = createWorkflowScope(progress, opts.progressPrefix ?? "", opts.progressNamespace);
369
426
  const resumeContext: AgentResumeBaseContext = {
370
427
  workflow: await captureWorkflowResumeContext(mod, rc.signal),
@@ -385,7 +442,8 @@ export async function runWorkflowWithContext(
385
442
  const childController = new AbortController();
386
443
  const unlink = linkAbortSignal(rc.signal, childController);
387
444
  try {
388
- 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, {
389
447
  abortController: childController,
390
448
  submissionLimit: opts.submissionLimit,
391
449
  resolveWorkflow: opts.resolveWorkflow,
@@ -401,6 +459,7 @@ export async function runWorkflowWithContext(
401
459
 
402
460
  const api: WorkflowApi = {
403
461
  agent,
462
+ classify: (context, classifyOpts) => runClassifier(rc, context, { ...classifyOpts, phase: scope.agentOptions({ phase: classifyOpts?.phase }).phase }),
404
463
  workflow,
405
464
  parallel: bindParallel({
406
465
  signal: rc.signal,
@@ -412,6 +471,7 @@ export async function runWorkflowWithContext(
412
471
  log: scope.log,
413
472
  progress: scope.event,
414
473
  args,
474
+ input,
415
475
  cwd: rc.cwd,
416
476
  budget: rc.budget,
417
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
+ }
@@ -0,0 +1,76 @@
1
+ import type { AgentToolCallOutcome } from "@earendil-works/pi-agent-core";
2
+ import { defineTool, type ExtensionToolContext, type ToolDefinition, type ToolInfo } from "@earendil-works/pi-coding-agent";
3
+
4
+ /** pi names MCP tools `mcp__<server>__<tool>`; its MCP extension also registers these resource tools. */
5
+ const MCP_TOOL_PREFIX = "mcp__";
6
+ const MCP_RESOURCE_TOOLS = new Set(["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]);
7
+
8
+ /** Mirrors pi's own `isMcpToolName`, which the package does not export. */
9
+ export function isMcpToolName(name: string): boolean {
10
+ return name.startsWith(MCP_TOOL_PREFIX) || MCP_RESOURCE_TOOLS.has(name);
11
+ }
12
+
13
+ /** An agent named MCP tools it cannot reach. Raised before prompting so it never runs without them. */
14
+ export class WorkflowHostToolUnavailableError extends Error {
15
+ readonly code = "WORKFLOW_HOST_TOOL_UNAVAILABLE";
16
+ readonly tools: readonly string[];
17
+
18
+ constructor(tools: readonly string[], bridged: boolean) {
19
+ super(
20
+ bridged
21
+ ? `Host MCP tools not available: ${tools.join(", ")}`
22
+ : `MCP tools reach subagents only in runs started by the workflow tool; unavailable: ${tools.join(", ")}`,
23
+ );
24
+ this.name = "WorkflowHostToolUnavailableError";
25
+ this.tools = [...tools];
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Host-only tools a subagent can reach through the host session. Subagents are SDK sessions, which
31
+ * load none of pi's built-in extensions, so they connect no MCP servers of their own; proxies call the
32
+ * host's tools over its existing connections and through its `tool_call` permission handlers. They act
33
+ * in the host's context, so a local MCP tool sees the main checkout even for an isolated worktree agent.
34
+ */
35
+ export interface HostToolBridge {
36
+ /** Callable host MCP tools, read as each agent starts so servers that connected mid-run are included. */
37
+ tools(): readonly ToolInfo[];
38
+ execute(name: string, args: unknown, signal: AbortSignal | undefined): Promise<AgentToolCallOutcome>;
39
+ }
40
+
41
+ /**
42
+ * `executeTool` exists only while the host's tool call runs, so only a synchronous `workflow` tool run
43
+ * gets a bridge; `/workflow` commands and background runs that outlive the call do not.
44
+ */
45
+ export function createHostToolBridge(
46
+ registeredTools: () => readonly ToolInfo[],
47
+ ctx: Pick<ExtensionToolContext, "tools" | "executeTool">,
48
+ ): HostToolBridge {
49
+ return {
50
+ tools() {
51
+ const callable = new Set(ctx.tools.map((tool) => tool.name));
52
+ return registeredTools().filter((tool) => isMcpToolName(tool.name) && callable.has(tool.name));
53
+ },
54
+ execute: (name, args, signal) => ctx.executeTool(name, args, { signal }),
55
+ };
56
+ }
57
+
58
+ /**
59
+ * A subagent tool that runs the host tool. It reports no usage: pi already adds nested calls' usage to
60
+ * the host's `workflow` tool result.
61
+ */
62
+ export function hostToolProxy(tool: ToolInfo, bridge: HostToolBridge): ToolDefinition {
63
+ return defineTool({
64
+ name: tool.name,
65
+ label: tool.name,
66
+ description: tool.description,
67
+ parameters: tool.parameters,
68
+ promptGuidelines: tool.promptGuidelines,
69
+ namespace: tool.namespace,
70
+ annotations: tool.annotations,
71
+ async execute(_toolCallId, params, signal) {
72
+ const outcome = await bridge.execute(tool.name, params, signal);
73
+ return { content: outcome.result.content, details: undefined, ...(outcome.isError ? { isError: true } : {}) };
74
+ },
75
+ });
76
+ }
@@ -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 {
@@ -0,0 +1,19 @@
1
+ /** Parse a model ref: a bare id is Anthropic shorthand, otherwise "provider/id". Malformed refs throw. */
2
+ export function parseAgentModelRef(modelRef: string): { readonly provider: string; readonly id: string } {
3
+ const normalized = modelRef.trim();
4
+ if (normalized.length === 0) {
5
+ throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".');
6
+ }
7
+ if (normalized !== modelRef) {
8
+ throw new Error(`Invalid agent model ref "${modelRef}": remove leading or trailing whitespace.`);
9
+ }
10
+
11
+ const slash = modelRef.indexOf("/");
12
+ if (slash === -1) return { provider: "anthropic", id: modelRef };
13
+ const provider = modelRef.slice(0, slash);
14
+ const id = modelRef.slice(slash + 1);
15
+ if (provider.length === 0 || id.length === 0 || id.startsWith("/")) {
16
+ throw new Error(`Invalid agent model ref "${modelRef}": expected "provider/id".`);
17
+ }
18
+ return { provider, id };
19
+ }
@@ -1,6 +1,6 @@
1
- const MINIMUM_PI_VERSION = "0.80.10";
1
+ const MINIMUM_PI_VERSION = "1.0.0";
2
2
 
3
- const MINIMUM_PI_CORE = [0, 80, 10] as const;
3
+ const MINIMUM_PI_CORE = [1, 0, 0] as const;
4
4
 
5
5
  /** Fail during extension registration instead of failing later in a workflow. */
6
6
  export function assertSupportedPiVersion(version: string): void {
@@ -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
+ }