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
@@ -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
+ }
@@ -0,0 +1,61 @@
1
+ export interface WorkflowToolRequestParams {
2
+ readonly name?: string;
3
+ readonly script?: string;
4
+ readonly scriptPath?: string;
5
+ readonly resumeFromRunId?: string;
6
+ readonly background?: boolean;
7
+ }
8
+
9
+ export type WorkflowToolRequest =
10
+ | { readonly kind: "named"; readonly name: string }
11
+ | { readonly kind: "inline"; readonly script: string }
12
+ | { readonly kind: "script-path"; readonly scriptPath: string }
13
+ | { readonly kind: "error"; readonly error: "invalid_workflow_invocation"; readonly message: string };
14
+
15
+ export const INVALID_WORKFLOW_INVOCATION_MESSAGE = "Provide exactly one of a workflow name, an inline workflow script, or a scriptPath.";
16
+
17
+ export function normalizeWorkflowToolRequest(params: WorkflowToolRequestParams): WorkflowToolRequest {
18
+ const name = params.name?.trim() ?? "";
19
+ const script = params.script?.trim() ?? "";
20
+ const scriptPath = params.scriptPath?.trim() ?? "";
21
+ const provided = [name, script, scriptPath].filter((value) => value.length > 0).length;
22
+ if (provided !== 1) return { kind: "error", error: "invalid_workflow_invocation", message: INVALID_WORKFLOW_INVOCATION_MESSAGE };
23
+ if (name) return { kind: "named", name };
24
+ return script ? { kind: "inline", script } : { kind: "script-path", scriptPath };
25
+ }
26
+
27
+ /** pi names MCP tools `mcp__<server>__<tool>`; such a word contains its server's `mcp__<server>` namespace. */
28
+ const MCP_TOOL_REFERENCE = /mcp__[A-Za-z0-9_-]+/g;
29
+ /** `api.workflow(...)` or a destructured `workflow(...)`; prose that happens to match only costs one wait. */
30
+ const SUB_WORKFLOW_CALL = /\bworkflow\s*\(/;
31
+
32
+ /**
33
+ * Which tools a `workflow` tool call may reach, for pi's `reachesTools`, read from the same request the
34
+ * call executes. Before the call runs, pi's MCP extension waits for every server whose namespace a
35
+ * returned word contains, or for all of them on `"all"`. Without that wait a synchronous run early in a
36
+ * session can start before MCP tools register, and agents that name them then fail at the host-tools bridge.
37
+ *
38
+ * - A background run never gets the bridge, so it reaches nothing.
39
+ * - A named workflow is opaque, so it reaches `"all"`. Each server's readiness settles once, so this
40
+ * costs at most one wait at session start.
41
+ * - An inline script reaches the MCP tools it names, or `"all"` when it asks for `external-search`
42
+ * tools or composes a sub-workflow, which is as opaque as a named one.
43
+ * - A script file reaches `"all"` unread. pi asks synchronously on its main thread, where reading a FIFO,
44
+ * a terminal or a device named as `scriptPath` would block or never end.
45
+ * - An invalid call fails before running anything, so it reaches nothing.
46
+ */
47
+ export function workflowToolReach(input: Record<string, unknown>): "all" | readonly string[] {
48
+ if (input.background === true) return [];
49
+ const request = normalizeWorkflowToolRequest({ name: text(input.name), script: text(input.script), scriptPath: text(input.scriptPath) });
50
+ if (request.kind === "inline") return scriptReach(request.script);
51
+ return request.kind === "error" ? [] : "all";
52
+ }
53
+
54
+ function scriptReach(script: string): "all" | readonly string[] {
55
+ if (script.includes("external-search") || SUB_WORKFLOW_CALL.test(script)) return "all";
56
+ return [...new Set(script.match(MCP_TOOL_REFERENCE) ?? [])];
57
+ }
58
+
59
+ function text(value: unknown): string | undefined {
60
+ return typeof value === "string" ? value : undefined;
61
+ }