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
@@ -6,7 +6,7 @@ import type {
6
6
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
7
7
  import { BackgroundWorkflowCoordinator } from "./background-workflows.ts";
8
8
  import { backgroundUnavailableResult, startBackgroundWorkflowTool } from "./background-workflow-tool.ts";
9
- import { validateWorkflowRunId } from "./journal.ts";
9
+ import { validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
10
10
  import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "./options.ts";
11
11
  import type { LoadedWorkflow } from "./types.ts";
12
12
  import {
@@ -17,15 +17,19 @@ import {
17
17
  formatWorkflowRunSummary,
18
18
  isWorkflowRunLifecycleAction,
19
19
  parseWorkflowRunsCommand,
20
+ isInlineWorkflowRun,
20
21
  retainedWorkflowRunOutcome,
21
22
  supersedingRuns,
22
23
  WORKFLOW_RUN_ACTIONS,
23
24
  WORKFLOW_RUN_HISTORY_LIMIT,
25
+ workflowRunDisplayName,
24
26
  type WorkflowRunActionContext,
25
27
  type WorkflowRunLifecycleAction,
26
28
  } from "./workflow-run-history.ts";
27
- import { stopWorkflowRunRecord, type WorkflowRunRecord } from "./workflow-run-record.ts";
28
- import { ProjectWorkflowRunStore, type WorkflowRunStore } from "./workflow-run-store.ts";
29
+ import { stopWorkflowRunRecord, workflowRunScriptFile, type WorkflowRunRecord } from "./workflow-run-record.ts";
30
+ import { verifyWorkflowRunScriptSeal } from "./workflow-run-seal.ts";
31
+ import { ProjectWorkflowRunStore, readWorkflowRunScript, type WorkflowRunStore } from "./workflow-run-store.ts";
32
+ import { compileInlineWorkflow, inlineScriptFingerprint } from "./inline-workflow.ts";
29
33
  import { unknownErrorMessage } from "./unknown-error.ts";
30
34
  import {
31
35
  WorkflowUsageLimitScheduler,
@@ -39,7 +43,8 @@ import { completeCurrentArgument, splitArgumentPrefix } from "./command-completi
39
43
  type WorkflowRunCompletionContext = Pick<ExtensionContext, "cwd" | "sessionManager">;
40
44
 
41
45
  interface WorkflowRunControllerDependencies {
42
- readonly resolveWorkflow: (name: string) => Promise<LoadedWorkflow | undefined>;
46
+ /** The registered workflow a relaunch runs; `ctx` decides which project's saved workflows are visible. */
47
+ readonly resolveWorkflow: (name: string, ctx: ExtensionContext) => Promise<LoadedWorkflow | undefined>;
43
48
  readonly execute: (
44
49
  ctx: ExtensionContext,
45
50
  name: string,
@@ -160,7 +165,7 @@ export class WorkflowRunController {
160
165
  }
161
166
  const actions = availableWorkflowRunActions(record, runs);
162
167
  const selectedAction = await ctx.ui.select(
163
- `${record.workflow.name} · ${record.runId}`,
168
+ `${workflowRunDisplayName(record)} · ${record.runId}`,
164
169
  [...actions],
165
170
  );
166
171
  const action = actions.find((candidate) => candidate === selectedAction);
@@ -232,14 +237,10 @@ export class WorkflowRunController {
232
237
  ): Promise<string> {
233
238
  const unavailable = backgroundUnavailableResult(ctx.mode);
234
239
  if (unavailable) throw new Error(unavailable.content[0].text);
235
- const workflow = await this.dependencies.resolveWorkflow(record.workflow.name);
236
- if (!workflow) throw new Error(`registered workflow ${record.workflow.name} is unavailable`);
237
- if (workflow.source.kind !== "file") {
238
- throw new Error(`registered workflow ${record.workflow.name} no longer has verifiable file provenance`);
239
- }
240
+ const { workflow, fingerprint } = await this.relaunchWorkflow(ctx, record);
240
241
  if (
241
242
  action === "resume"
242
- && workflow.source.fingerprint !== record.workflow.sourceFingerprint
243
+ && fingerprint !== record.workflow.sourceFingerprint
243
244
  ) {
244
245
  throw new Error("workflow source changed, so journal replay cannot resume safely");
245
246
  }
@@ -264,6 +265,8 @@ export class WorkflowRunController {
264
265
  ctx,
265
266
  name: workflow.meta.name,
266
267
  options,
268
+ // A relaunch continues the original run, so it delivers the way that run would have.
269
+ wakeOnComplete: record.background?.origin.wakeOnComplete === true,
267
270
  execute: (backgroundCtx, backgroundOptions) =>
268
271
  this.dependencies.execute(backgroundCtx, workflow.meta.name, workflow, backgroundOptions),
269
272
  });
@@ -272,6 +275,35 @@ export class WorkflowRunController {
272
275
  return message;
273
276
  }
274
277
 
278
+ /** The module a relaunch runs: an inline run's persisted script, else the registered workflow of the same name. */
279
+ private async relaunchWorkflow(
280
+ ctx: ExtensionContext,
281
+ record: WorkflowRunRecord,
282
+ ): Promise<{ readonly workflow: LoadedWorkflow; readonly fingerprint: string }> {
283
+ if (record.workflow.sourceKind === "fingerprint") {
284
+ if (record.scriptFile === undefined) throw new Error(`inline workflow ${record.workflow.name} did not retain its script`);
285
+ const fingerprint = await sealedFingerprint(record);
286
+ let source: string;
287
+ try {
288
+ source = await readWorkflowRunScript(ctx.cwd, record.runId);
289
+ } catch (error) {
290
+ throw new Error(`inline workflow script ${record.scriptFile} is unavailable: ${unknownErrorMessage(error)}`);
291
+ }
292
+ if (inlineScriptFingerprint(source) !== fingerprint) throw new Error(unsealedRunMessage(record));
293
+ return { workflow: compileInlineWorkflow(source), fingerprint };
294
+ }
295
+ const workflow = await this.dependencies.resolveWorkflow(record.workflow.name, ctx);
296
+ if (!workflow) throw new Error(`registered workflow ${record.workflow.name} is unavailable`);
297
+ if (workflow.source.kind !== "file") {
298
+ throw new Error(`registered workflow ${record.workflow.name} no longer has verifiable file provenance`);
299
+ }
300
+ // A repository can also ship a saved script under the name a forged record claims.
301
+ if (workflow.source.selfContained === true && (await sealedFingerprint(record)) !== workflow.source.fingerprint) {
302
+ throw new Error(unsealedRunMessage(record));
303
+ }
304
+ return { workflow, fingerprint: workflow.source.fingerprint };
305
+ }
306
+
275
307
  private async autoResume(ctx: ExtensionContext, runId: string, attempt: number): Promise<void> {
276
308
  const record = await this.loadRecord(ctx.cwd, runId);
277
309
  if (
@@ -326,7 +358,7 @@ export class WorkflowRunController {
326
358
  .filter((record) => availableWorkflowRunActions(record, runs).includes(action))
327
359
  .map((record) => ({
328
360
  value: record.runId,
329
- description: `${record.state} · ${record.workflow.name}`,
361
+ description: `${record.state} · ${workflowRunDisplayName(record)}`,
330
362
  })),
331
363
  );
332
364
  }
@@ -340,12 +372,36 @@ export class WorkflowRunController {
340
372
  { value: "last", description: "Inspect the current or most recent in-session workflow" },
341
373
  ...records.map((record) => ({
342
374
  value: record.runId,
343
- description: `${record.state} · ${record.workflow.name}`,
375
+ description: `${record.state} · ${workflowRunDisplayName(record)}`,
344
376
  })),
345
377
  ]);
346
378
  }
347
379
  }
348
380
 
381
+ /**
382
+ * The fingerprint a script-sourced record's seal covers. A repository can ship a record of its own, so
383
+ * the seal is checked before any script is read; it is this install's, over the script as recorded.
384
+ */
385
+ async function sealedFingerprint(record: WorkflowRunRecord): Promise<string> {
386
+ const fingerprint = record.workflow.sourceFingerprint;
387
+ if (
388
+ fingerprint === undefined
389
+ || record.scriptSeal === undefined
390
+ || !(await verifyWorkflowRunScriptSeal(record.runId, fingerprint, record.scriptSeal))
391
+ ) {
392
+ throw new Error(unsealedRunMessage(record));
393
+ }
394
+ return fingerprint;
395
+ }
396
+
397
+ /** Why a script-sourced record will not run, and how to run the script anyway. */
398
+ function unsealedRunMessage(record: WorkflowRunRecord): string {
399
+ const rerun = isInlineWorkflowRun(record)
400
+ ? `rerun it with the workflow tool's scriptPath (${WORKFLOW_RUNS_DIR}/${workflowRunScriptFile(record.runId)})`
401
+ : `run ${record.workflow.name} again by name`;
402
+ return `${isInlineWorkflowRun(record) ? "inline run" : "run"} ${record.runId} was not recorded by this pi install, or its script changed since; ${rerun}`;
403
+ }
404
+
349
405
  /**
350
406
  * Runs that any stored run resumed, whatever that resume's outcome. A resumed run keeps its paused
351
407
  * record and pause attempt, so automatic resume treats the pause as spent: re-arming it after a
@@ -86,9 +86,28 @@ export function availableWorkflowRunActions(
86
86
  }
87
87
 
88
88
  export function canRelaunchWorkflowRun(record: WorkflowRunRecord): boolean {
89
+ return hasRelaunchableWorkflowSource(record) && record.options.argumentsPresent === false;
90
+ }
91
+
92
+ /**
93
+ * A registered workflow resolves again by name; an inline one relaunches from the script persisted beside
94
+ * its record, and only when the record carries a seal. This check is only the seal's presence: relaunch
95
+ * verifies it, and the script's fingerprint, before running anything.
96
+ */
97
+ export function hasRelaunchableWorkflowSource(record: WorkflowRunRecord): boolean {
98
+ if (typeof record.workflow.sourceFingerprint !== "string") return false;
89
99
  return record.workflow.sourceKind === "file"
90
- && typeof record.workflow.sourceFingerprint === "string"
91
- && record.options.argumentsPresent === false;
100
+ || (isInlineWorkflowRun(record) && record.scriptFile !== undefined && record.scriptSeal !== undefined);
101
+ }
102
+
103
+ /** A run of an inline script (a tool `script` or `scriptPath`), not of a registered workflow. */
104
+ export function isInlineWorkflowRun(record: WorkflowRunRecord): boolean {
105
+ return record.workflow.sourceKind === "fingerprint";
106
+ }
107
+
108
+ /** The run's workflow name, marked when it ran an inline script, so such a record cannot pose as a registered workflow's run. */
109
+ export function workflowRunDisplayName(record: WorkflowRunRecord): string {
110
+ return isInlineWorkflowRun(record) ? `${record.workflow.name} (inline)` : record.workflow.name;
92
111
  }
93
112
 
94
113
  export function formatWorkflowRunHistory(
@@ -112,7 +131,7 @@ export function formatWorkflowRunSummary(
112
131
  const usage = formatWorkflowUsageLine(record.usage);
113
132
  const resumedAs = runs.resumedAs.get(record.runId);
114
133
  const actions = availableWorkflowRunActions(record, runs).filter((action) => action !== "inspect");
115
- return `${record.state.toUpperCase()} ${record.workflow.name} · age ${formatDuration(Math.max(0, now - record.createdAt))} · duration ${formatWorkflowRunDuration(record, now)}${usage ? ` · ${usage}` : ""} · ${record.runId}${resumedAs ? ` · resumed as ${resumedAs}` : ""}${actions.length > 0 ? ` · actions ${actions.join(", ")}` : ""}`;
134
+ return `${record.state.toUpperCase()} ${workflowRunDisplayName(record)} · age ${formatDuration(Math.max(0, now - record.createdAt))} · duration ${formatWorkflowRunDuration(record, now)}${usage ? ` · ${usage}` : ""} · ${record.runId}${resumedAs ? ` · resumed as ${resumedAs}` : ""}${actions.length > 0 ? ` · actions ${actions.join(", ")}` : ""}`;
116
135
  }
117
136
 
118
137
  export function formatWorkflowRunDetails(
@@ -127,7 +146,7 @@ export function formatWorkflowRunDetails(
127
146
  const usage = formatWorkflowUsageLine(record.usage);
128
147
  const lines = [
129
148
  `Workflow run ${record.runId}`,
130
- `Workflow: ${record.workflow.name}`,
149
+ `Workflow: ${workflowRunDisplayName(record)}`,
131
150
  `State: ${record.state.toUpperCase()}`,
132
151
  `Age: ${formatDuration(Math.max(0, now - record.createdAt))}`,
133
152
  `Duration: ${formatWorkflowRunDuration(record, now)}`,
@@ -17,8 +17,10 @@ import {
17
17
  isPersistedWorkflowBackground,
18
18
  type PersistedWorkflowBackground,
19
19
  } from "./workflow-run-background.ts";
20
+ import { isWorkflowRunScriptSeal } from "./workflow-run-seal.ts";
20
21
 
21
22
  export const WORKFLOW_RUN_RECORD_VERSION = 1;
23
+ export const WORKFLOW_RUN_SCRIPT_SUFFIX = ".script.js";
22
24
 
23
25
  const MAX_RESULT_BYTES = 1 << 20;
24
26
  const MAX_RESULT_DEPTH = 64;
@@ -97,6 +99,14 @@ interface WorkflowRunRecordBase {
97
99
  readonly runId: string;
98
100
  readonly workflow: PersistedWorkflowIdentity;
99
101
  readonly journalFile: string;
102
+ /** An inline run's script, persisted beside the record; prompts in it never enter the record itself. */
103
+ readonly scriptFile?: string;
104
+ /**
105
+ * This install's HMAC over the run id and source fingerprint, proving the engine recorded the script
106
+ * rather than a repository shipping it. A run of an inline or saved script relaunches from
107
+ * `/workflow:runs` only with one.
108
+ */
109
+ readonly scriptSeal?: string;
100
110
  readonly options: PersistedWorkflowRunOptions;
101
111
  readonly createdAt: number;
102
112
  readonly updatedAt: number;
@@ -125,6 +135,10 @@ export function createWorkflowRunRecord(input: {
125
135
  readonly options: ResolvedWorkflowRunOptions;
126
136
  readonly progress: WorkflowProgressSnapshot;
127
137
  readonly argumentsPresent?: boolean;
138
+ /** Whether the run's inline script was persisted beside the record. */
139
+ readonly scriptPersisted?: boolean;
140
+ /** The run's script seal; see {@link WorkflowRunRecordBase.scriptSeal}. */
141
+ readonly scriptSeal?: string;
128
142
  }): WorkflowRunRecord {
129
143
  const runId = validateWorkflowRunId(input.runId);
130
144
  assertMatchingRun(runId, input.progress.runId);
@@ -134,6 +148,8 @@ export function createWorkflowRunRecord(input: {
134
148
  state: "queued",
135
149
  workflow: persistedWorkflowIdentity(input.workflow),
136
150
  journalFile: `${runId}.jsonl`,
151
+ scriptFile: input.scriptPersisted ? workflowRunScriptFile(runId) : undefined,
152
+ scriptSeal: input.scriptSeal,
137
153
  options: persistedWorkflowRunOptions(input.options, input.argumentsPresent ?? false),
138
154
  createdAt: input.progress.startedAt,
139
155
  updatedAt: input.progress.startedAt,
@@ -142,6 +158,11 @@ export function createWorkflowRunRecord(input: {
142
158
  };
143
159
  }
144
160
 
161
+ /** The persisted script's file name in the runs directory. */
162
+ export function workflowRunScriptFile(runId: string): string {
163
+ return `${runId}${WORKFLOW_RUN_SCRIPT_SUFFIX}`;
164
+ }
165
+
145
166
  export function updateWorkflowRunProgress(
146
167
  record: WorkflowRunRecord,
147
168
  progress: WorkflowProgressSnapshot,
@@ -169,6 +190,8 @@ export function transitionWorkflowRun(record: WorkflowRunRecord, transition: Wor
169
190
  runId: record.runId,
170
191
  workflow: record.workflow,
171
192
  journalFile: record.journalFile,
193
+ scriptFile: record.scriptFile,
194
+ scriptSeal: record.scriptSeal,
172
195
  options: record.options,
173
196
  createdAt: record.createdAt,
174
197
  updatedAt: at,
@@ -475,6 +498,9 @@ export function isWorkflowRunRecord(value: unknown): value is WorkflowRunRecord
475
498
  if (!isValidRunId(value.runId) || !isWorkflowRunState(value.state)) return false;
476
499
  if (!isPersistedWorkflowIdentity(value.workflow) || !isPersistedWorkflowRunOptions(value.options)) return false;
477
500
  if (value.journalFile !== `${value.runId}.jsonl`) return false;
501
+ if (value.scriptFile !== undefined && value.scriptFile !== workflowRunScriptFile(value.runId)) return false;
502
+ // A seal covers an inline run's persisted script or a saved script, which resolves again by name.
503
+ if (value.scriptSeal !== undefined && (!isWorkflowRunScriptSeal(value.scriptSeal) || (value.scriptFile === undefined && value.workflow.sourceKind !== "file"))) return false;
478
504
  if (!isFiniteNumber(value.createdAt) || !isFiniteNumber(value.updatedAt)) return false;
479
505
  if (value.startedAt !== undefined && !isFiniteNumber(value.startedAt)) return false;
480
506
  if (value.endedAt !== undefined && !isFiniteNumber(value.endedAt)) return false;
@@ -568,7 +594,7 @@ function isPhaseSnapshot(value: unknown): boolean {
568
594
 
569
595
  function isAgentSnapshot(value: unknown): boolean {
570
596
  if (!isRecord(value) || !isFiniteNumber(value.id) || typeof value.label !== "string") return false;
571
- if (value.status !== "queued" && value.status !== "running" && value.status !== "done" && value.status !== "failed") return false;
597
+ if (value.status !== "queued" && value.status !== "running" && value.status !== "done" && value.status !== "failed" && value.status !== "skipped") return false;
572
598
  if (!isFiniteNumber(value.toolUses)) return false;
573
599
  if (value.startedAt !== undefined && !isFiniteNumber(value.startedAt)) return false;
574
600
  if (value.doneAt !== undefined && !isFiniteNumber(value.doneAt)) return false;
@@ -0,0 +1,83 @@
1
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { isExistingPathError, isMissingPathError } from "./filesystem-error.ts";
6
+
7
+ /**
8
+ * The per-install key that seals inline run scripts, in pi's agent directory. It lives outside every
9
+ * project, so a repository can ship run records and scripts but never a seal this install accepts.
10
+ */
11
+ export const WORKFLOW_RUN_SEAL_KEY_FILE = "workflow-run-seal.key";
12
+
13
+ const KEY_BYTES = 32;
14
+ const HEX_256 = /^[0-9a-f]{64}$/;
15
+
16
+ /** Whether `value` has a seal's shape; only {@link verifyWorkflowRunScriptSeal} says whether it is genuine. */
17
+ export function isWorkflowRunScriptSeal(value: unknown): value is string {
18
+ return typeof value === "string" && HEX_256.test(value);
19
+ }
20
+
21
+ /** The seal a run record carries for its persisted inline script, creating this install's key on first use. */
22
+ export async function sealWorkflowRunScript(
23
+ runId: string,
24
+ sourceFingerprint: string,
25
+ agentDir: string = getAgentDir(),
26
+ ): Promise<string> {
27
+ const path = join(agentDir, WORKFLOW_RUN_SEAL_KEY_FILE);
28
+ return runSeal((await readSealKey(path)) ?? (await createSealKey(agentDir, path)), runId, sourceFingerprint);
29
+ }
30
+
31
+ /** Whether this install sealed `sourceFingerprint` for `runId`. Never creates a key: without one, nothing was sealed here. */
32
+ export async function verifyWorkflowRunScriptSeal(
33
+ runId: string,
34
+ sourceFingerprint: string,
35
+ seal: string,
36
+ agentDir: string = getAgentDir(),
37
+ ): Promise<boolean> {
38
+ if (!isWorkflowRunScriptSeal(seal)) return false;
39
+ const key = await readSealKey(join(agentDir, WORKFLOW_RUN_SEAL_KEY_FILE));
40
+ if (key === undefined) return false;
41
+ return timingSafeEqual(Buffer.from(runSeal(key, runId, sourceFingerprint), "hex"), Buffer.from(seal, "hex"));
42
+ }
43
+
44
+ function runSeal(key: Buffer, runId: string, sourceFingerprint: string): string {
45
+ return createHmac("sha256", key).update(`${runId}\0${sourceFingerprint}`).digest("hex");
46
+ }
47
+
48
+ async function readSealKey(path: string): Promise<Buffer | undefined> {
49
+ let content: string;
50
+ try {
51
+ content = await readFile(path, "utf8");
52
+ } catch (error) {
53
+ if (isMissingPathError(error)) return undefined;
54
+ throw error;
55
+ }
56
+ const hex = content.trim();
57
+ if (!HEX_256.test(hex)) {
58
+ throw new Error(`workflow run seal key ${path} is malformed; delete it to create a new one, which retires every existing seal`);
59
+ }
60
+ return Buffer.from(hex, "hex");
61
+ }
62
+
63
+ /**
64
+ * Publish a new key with an exclusive hard link from a complete temporary file, so a concurrent reader
65
+ * never sees a partial key. When another process published one first, its key wins and is read back.
66
+ */
67
+ async function createSealKey(agentDir: string, path: string): Promise<Buffer> {
68
+ await mkdir(agentDir, { recursive: true });
69
+ const key = randomBytes(KEY_BYTES);
70
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
71
+ try {
72
+ await writeFile(temporaryPath, `${key.toString("hex")}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
73
+ await link(temporaryPath, path);
74
+ return key;
75
+ } catch (error) {
76
+ if (!isExistingPathError(error)) throw error;
77
+ const existing = await readSealKey(path);
78
+ if (existing === undefined) throw error;
79
+ return existing;
80
+ } finally {
81
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
82
+ }
83
+ }
@@ -1,12 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, join } from "node:path";
4
- import { validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
4
+ import { ensureWorkflowRunsDir, validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
5
5
  import type { WorkflowProgressSnapshot } from "./progress-types.ts";
6
6
  import {
7
7
  isWorkflowRunRecord,
8
8
  transitionWorkflowRun,
9
9
  updateWorkflowRunProgress,
10
+ WORKFLOW_RUN_SCRIPT_SUFFIX,
11
+ workflowRunScriptFile,
10
12
  type WorkflowRunRecord,
11
13
  type WorkflowRunState,
12
14
  type WorkflowRunTransition,
@@ -34,7 +36,7 @@ export class ProjectWorkflowRunStore implements WorkflowRunStore {
34
36
  if (Buffer.byteLength(content) > MAX_RECORD_BYTES) {
35
37
  throw new Error(`Workflow run record exceeded ${MAX_RECORD_BYTES} bytes.`);
36
38
  }
37
- await mkdir(dirname(path), { recursive: true });
39
+ await ensureWorkflowRunsDir(dirname(path));
38
40
  try {
39
41
  await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
40
42
  await rename(temporaryPath, path);
@@ -70,7 +72,12 @@ export class ProjectWorkflowRunStore implements WorkflowRunStore {
70
72
  .filter((entry): entry is { readonly path: string; readonly mtimeMs: number } => entry !== undefined)
71
73
  .sort((left, right) => right.mtimeMs - left.mtimeMs)
72
74
  .slice(Math.max(0, keep));
73
- await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
75
+ // A pruned record takes its persisted script with it: scripts are only ever reached through their record.
76
+ const paths = stale.flatMap((entry) => [
77
+ entry.path,
78
+ `${entry.path.slice(0, -WORKFLOW_RUN_RECORD_SUFFIX.length)}${WORKFLOW_RUN_SCRIPT_SUFFIX}`,
79
+ ]);
80
+ await Promise.all(paths.map((path) => rm(path, { force: true }).catch(() => undefined)));
74
81
  }
75
82
 
76
83
  /** Record file paths in the runs directory; empty when the directory cannot be read. */
@@ -153,6 +160,25 @@ export function workflowRunRecordPath(cwd: string, runId: string): string {
153
160
  return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}${WORKFLOW_RUN_RECORD_SUFFIX}`);
154
161
  }
155
162
 
163
+ export function workflowRunScriptPath(cwd: string, runId: string): string {
164
+ return join(cwd, WORKFLOW_RUNS_DIR, workflowRunScriptFile(validateWorkflowRunId(runId)));
165
+ }
166
+
167
+ /**
168
+ * Persist an inline run's script beside its record, which redacts prompts and so cannot hold it. This
169
+ * adds no new exposure: pi's session transcript already holds the script as the tool call's arguments.
170
+ */
171
+ export async function persistWorkflowRunScript(cwd: string, runId: string, source: string): Promise<string> {
172
+ const path = workflowRunScriptPath(cwd, runId);
173
+ await ensureWorkflowRunsDir(dirname(path));
174
+ await writeFile(path, source, { encoding: "utf8", mode: 0o600 });
175
+ return path;
176
+ }
177
+
178
+ export async function readWorkflowRunScript(cwd: string, runId: string): Promise<string> {
179
+ return await readFile(workflowRunScriptPath(cwd, runId), "utf8");
180
+ }
181
+
156
182
  async function loadWorkflowRunRecord(path: string): Promise<WorkflowRunRecord | undefined> {
157
183
  try {
158
184
  const content = await readFile(path, "utf8");
@@ -0,0 +1,50 @@
1
+ import type { Stats } from "node:fs";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { compileInlineWorkflow } from "./inline-workflow.ts";
5
+ import type { LoadedWorkflow } from "./types.ts";
6
+ import { unknownErrorMessage } from "./unknown-error.ts";
7
+
8
+ /** The largest script file a `scriptPath` may name; real workflow scripts are a few kilobytes. */
9
+ export const MAX_WORKFLOW_SCRIPT_FILE_BYTES = 1 << 20;
10
+
11
+ /** A `scriptPath` that cannot be read. */
12
+ export class WorkflowScriptPathError extends Error {
13
+ override readonly name = "WorkflowScriptPathError";
14
+ }
15
+
16
+ /**
17
+ * Read an inline-format script named relative to `cwd`. No containment: the script runs with the
18
+ * extension's access either way, as an inline `script` does.
19
+ */
20
+ export async function readWorkflowScriptFile(cwd: string, scriptPath: string): Promise<string> {
21
+ return await readBoundedScriptFile(resolve(cwd, scriptPath), `scriptPath ${scriptPath}`);
22
+ }
23
+
24
+ /**
25
+ * Read a workflow script only if it is a regular file within the size limit: a FIFO or device would
26
+ * block the read or never end. `label` names the file in errors.
27
+ */
28
+ export async function readBoundedScriptFile(file: string, label: string): Promise<string> {
29
+ const unreadable = (error: unknown) => new WorkflowScriptPathError(`${label} could not be read: ${unknownErrorMessage(error)}`);
30
+ let info: Stats;
31
+ try {
32
+ info = await stat(file);
33
+ } catch (error) {
34
+ throw unreadable(error);
35
+ }
36
+ if (!info.isFile()) throw new WorkflowScriptPathError(`${label} is not a regular file`);
37
+ if (info.size > MAX_WORKFLOW_SCRIPT_FILE_BYTES) {
38
+ throw new WorkflowScriptPathError(`${label} is ${info.size} bytes, over the ${MAX_WORKFLOW_SCRIPT_FILE_BYTES}-byte limit`);
39
+ }
40
+ try {
41
+ return await readFile(file, "utf8");
42
+ } catch (error) {
43
+ throw unreadable(error);
44
+ }
45
+ }
46
+
47
+ /** Compile a script file exactly like an inline `script`: same contract, injected `Type`, content fingerprint. */
48
+ export async function compileWorkflowScriptFile(cwd: string, scriptPath: string): Promise<LoadedWorkflow> {
49
+ return compileInlineWorkflow(await readWorkflowScriptFile(cwd, scriptPath));
50
+ }
@@ -0,0 +1,147 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+ import { completeCurrentArgument, splitArgumentPrefix } from "./command-completions.ts";
4
+ import { configFilePaths, readJsonConfigFile, writeJsonConfigFile, type ConfigFilePaths } from "./config-file.ts";
5
+ import { isRecord } from "./guards.ts";
6
+ import { DEFAULT_WORKFLOW_MAX_AGENTS } from "./options.ts";
7
+ import { unknownErrorMessage } from "./unknown-error.ts";
8
+
9
+ /**
10
+ * How large the host should make each workflow it authors. A prompt guideline only: the run's
11
+ * `maxAgents` limit stays the separate hard cap.
12
+ */
13
+ export const WORKFLOW_SIZE_GUIDELINES = ["small", "medium", "large", "off"] as const;
14
+ export type WorkflowSizeGuideline = (typeof WORKFLOW_SIZE_GUIDELINES)[number];
15
+ export const DEFAULT_WORKFLOW_SIZE_GUIDELINE: WorkflowSizeGuideline = "medium";
16
+
17
+ const SIZE_AGENT_LIMITS: Readonly<Record<Exclude<WorkflowSizeGuideline, "off">, number>> = { small: 5, medium: 10, large: 25 };
18
+ const SETTINGS_FILE_NAME = "workflow-settings.json";
19
+ const SETTINGS_LABEL = "workflow settings";
20
+
21
+ export interface WorkflowSettingsFile {
22
+ readonly size?: WorkflowSizeGuideline;
23
+ }
24
+
25
+ export type ResolvedWorkflowSize =
26
+ | { readonly size: WorkflowSizeGuideline; readonly source: "default" }
27
+ | { readonly size: WorkflowSizeGuideline; readonly source: "user" | "project"; readonly configPath: string };
28
+
29
+ export function workflowSettingsPaths(cwd: string, agentDir?: string): ConfigFilePaths {
30
+ return configFilePaths(SETTINGS_FILE_NAME, cwd, agentDir);
31
+ }
32
+
33
+ export function isWorkflowSizeGuideline(value: string): value is WorkflowSizeGuideline {
34
+ return WORKFLOW_SIZE_GUIDELINES.includes(value as WorkflowSizeGuideline);
35
+ }
36
+
37
+ export function readWorkflowSettingsFile(configPath: string): WorkflowSettingsFile {
38
+ const parsed = readJsonConfigFile(configPath, SETTINGS_LABEL);
39
+ if (parsed === undefined) return {};
40
+ if (!isRecord(parsed)) throw invalidSettings(configPath, "the root must be an object");
41
+ const unexpected = Object.keys(parsed).find((key) => key !== "size");
42
+ if (unexpected) throw invalidSettings(configPath, `unexpected key "${unexpected}"`);
43
+ const { size } = parsed;
44
+ if (size === undefined) return {};
45
+ if (typeof size !== "string" || !isWorkflowSizeGuideline(size)) {
46
+ throw invalidSettings(configPath, '"size" must be small, medium, large, or off');
47
+ }
48
+ return { size };
49
+ }
50
+
51
+ /** The project file wins over the user file, which wins over the default. */
52
+ export function resolveWorkflowSize(paths: ConfigFilePaths): ResolvedWorkflowSize {
53
+ for (const source of ["project", "user"] as const) {
54
+ const { size } = readWorkflowSettingsFile(paths[source]);
55
+ if (size !== undefined) return { size, source, configPath: paths[source] };
56
+ }
57
+ return { size: DEFAULT_WORKFLOW_SIZE_GUIDELINE, source: "default" };
58
+ }
59
+
60
+ export function setWorkflowSize(configPath: string, size: WorkflowSizeGuideline): void {
61
+ const current = readWorkflowSettingsFile(configPath);
62
+ writeJsonConfigFile(configPath, { ...current, size }, SETTINGS_LABEL);
63
+ }
64
+
65
+ /** The catalog line for a size, or undefined when the guideline is off. */
66
+ export function workflowSizeGuidelineLine(size: WorkflowSizeGuideline): string | undefined {
67
+ if (size === "off") return undefined;
68
+ return `Workflow size guideline: ${size} — keep each workflow under ${SIZE_AGENT_LIMITS[size]} agents unless the user asks for more; the user changes this with /workflow:size.`;
69
+ }
70
+
71
+ type WorkflowSizeCommand =
72
+ | { readonly kind: "status" }
73
+ | { readonly kind: "set"; readonly size: WorkflowSizeGuideline; readonly scope: keyof ConfigFilePaths }
74
+ | { readonly kind: "error"; readonly message: string };
75
+
76
+ const USAGE = "Usage: /workflow:size [small|medium|large|off] [--user|--project]";
77
+ const SIZE_COMPLETIONS = WORKFLOW_SIZE_GUIDELINES.map((value) => ({
78
+ value,
79
+ description: value === "off" ? "No size guideline in the prompt" : `Keep each workflow under ${SIZE_AGENT_LIMITS[value]} agents`,
80
+ }));
81
+ const SCOPE_COMPLETIONS = [
82
+ { value: "--user", description: "Write the user-level workflow settings" },
83
+ { value: "--project", description: "Write the project-level workflow settings" },
84
+ ] as const;
85
+
86
+ export function parseWorkflowSizeCommand(args: string): WorkflowSizeCommand {
87
+ const tokens = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
88
+ const userScope = tokens.includes("--user");
89
+ const projectScope = tokens.includes("--project");
90
+ if (userScope && projectScope) return { kind: "error", message: "Choose either --user or --project, not both." };
91
+ const positional = tokens.filter((token) => token !== "--user" && token !== "--project");
92
+ if (positional.length === 0) {
93
+ return userScope || projectScope ? { kind: "error", message: "Name a size to write." } : { kind: "status" };
94
+ }
95
+ const [size, ...extra] = positional;
96
+ if (extra.length > 0 || !size || !isWorkflowSizeGuideline(size)) {
97
+ return { kind: "error", message: "Size must be small, medium, large, or off." };
98
+ }
99
+ return { kind: "set", size, scope: projectScope ? "project" : "user" };
100
+ }
101
+
102
+ export function workflowSizeArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
103
+ const { completed } = splitArgumentPrefix(argumentPrefix);
104
+ if (completed.length === 0) return completeCurrentArgument(argumentPrefix, SIZE_COMPLETIONS);
105
+ if (completed.length === 1 && isWorkflowSizeGuideline(completed[0] ?? "")) return completeCurrentArgument(argumentPrefix, SCOPE_COMPLETIONS);
106
+ return null;
107
+ }
108
+
109
+ export function formatWorkflowSize(resolved: ResolvedWorkflowSize): string {
110
+ const source = resolved.source === "default" ? "default" : `${resolved.source} config ${resolved.configPath}`;
111
+ const rule = resolved.size === "off"
112
+ ? "no size guideline is added to the prompt"
113
+ : `keep each workflow under ${SIZE_AGENT_LIMITS[resolved.size]} agents unless the user asks for more`;
114
+ return [
115
+ `Workflow size guideline: ${resolved.size} (${source}) — ${rule}.`,
116
+ `The maxAgents run limit (default ${DEFAULT_WORKFLOW_MAX_AGENTS}) stays the hard cap on live agents per run.`,
117
+ ].join("\n");
118
+ }
119
+
120
+ /** Register `/workflow:size`; `onChange` lets the session's prompt pick the new guideline up. */
121
+ export function registerWorkflowSizeCommand(pi: Pick<ExtensionAPI, "registerCommand">, onChange: (ctx: ExtensionCommandContext) => void): void {
122
+ pi.registerCommand("workflow:size", {
123
+ description: "Show or set the workflow size guideline: /workflow:size [small|medium|large|off] [--user|--project]",
124
+ getArgumentCompletions: workflowSizeArgumentCompletions,
125
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
126
+ const command = parseWorkflowSizeCommand(args);
127
+ if (command.kind === "error") {
128
+ ctx.ui.notify(`${command.message}\n${USAGE}`, "warning");
129
+ return;
130
+ }
131
+ try {
132
+ const paths = workflowSettingsPaths(ctx.cwd);
133
+ if (command.kind === "set") {
134
+ setWorkflowSize(paths[command.scope], command.size);
135
+ onChange(ctx);
136
+ }
137
+ ctx.ui.notify(formatWorkflowSize(resolveWorkflowSize(paths)), "info");
138
+ } catch (error) {
139
+ ctx.ui.notify(unknownErrorMessage(error), "error");
140
+ }
141
+ },
142
+ });
143
+ }
144
+
145
+ function invalidSettings(configPath: string, reason: string): Error {
146
+ return new Error(`Invalid workflow settings at ${configPath}: ${reason}. Fix or remove the file.`);
147
+ }