pi-workflow-engine 0.10.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +418 -213
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
  13. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  16. package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
  18. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
  20. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
  21. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  27. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  33. package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
  34. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  35. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  36. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  37. package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
  38. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  39. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  40. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  41. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  42. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
  43. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
  55. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  56. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  57. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  58. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
  60. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
  61. package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
  62. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  68. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  69. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  86. package/README.md +82 -207
  87. package/USAGE.md +295 -21
  88. package/package.json +9 -9
@@ -0,0 +1,401 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants, type BigIntStats } from "node:fs";
3
+ import { lstat, open, readdir, type FileHandle } from "node:fs/promises";
4
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { throwIfAborted } from "./cancellation.ts";
6
+ import { isMissingPathError } from "./filesystem-error.ts";
7
+ import { unknownErrorMessage } from "./unknown-error.ts";
8
+
9
+ export type FingerprintCapture =
10
+ | { readonly kind: "verified"; readonly fingerprint: string }
11
+ | { readonly kind: "unverifiable"; readonly reason: string };
12
+
13
+ export type TreeFileValidation =
14
+ | { readonly kind: "verified" }
15
+ | { readonly kind: "unverifiable"; readonly reason: string };
16
+
17
+ export interface TreeFingerprintOptions {
18
+ readonly root: string;
19
+ /** Root-relative directory paths to exclude, never bare names at every depth. */
20
+ readonly excludedRelativePaths?: ReadonlySet<string>;
21
+ readonly maxBytes: number;
22
+ readonly maxFiles: number;
23
+ readonly signal?: AbortSignal;
24
+ }
25
+
26
+ export interface TreeFileValidationOptions {
27
+ readonly root: string;
28
+ readonly path: string;
29
+ readonly excludedRelativePaths?: ReadonlySet<string>;
30
+ readonly signal?: AbortSignal;
31
+ }
32
+
33
+ export interface DeclaredInputFingerprintOptions {
34
+ readonly root: string;
35
+ /** Explicit paths relative to `root`. Files and directories are both supported. */
36
+ readonly inputs: readonly string[];
37
+ /** Paths with these segment sequences are never valid declared inputs. */
38
+ readonly excludedRelativePaths?: ReadonlySet<string>;
39
+ readonly maxBytes: number;
40
+ readonly maxEntries: number;
41
+ /** Record direct directory identity without recursively walking descendants. */
42
+ readonly shallowDirectories?: boolean;
43
+ readonly signal?: AbortSignal;
44
+ }
45
+
46
+ export type DeclaredInputPathResolution =
47
+ | { readonly kind: "verified"; readonly paths: readonly string[] }
48
+ | { readonly kind: "unverifiable"; readonly reason: string };
49
+
50
+ export interface FingerprintFileOperations {
51
+ readonly open: (path: string, flags: number) => Promise<FileHandle>;
52
+ readonly lstat: (path: string) => Promise<BigIntStats>;
53
+ }
54
+
55
+ const NODE_FINGERPRINT_FILE_OPERATIONS: FingerprintFileOperations = {
56
+ open: async (path, flags) => await open(path, flags),
57
+ lstat: async (path) => await lstat(path, { bigint: true }),
58
+ };
59
+
60
+ /** A deterministic hash sink that enforces one shared byte budget across file reads. */
61
+ export class BoundedFingerprint {
62
+ readonly #hash = createHash("sha256");
63
+ #bytes = 0;
64
+
65
+ constructor(private readonly maxBytes: number) {}
66
+
67
+ add(label: string, value: string | Buffer): void {
68
+ this.#hash.update(label);
69
+ this.#hash.update("\0");
70
+ this.#hash.update(value);
71
+ this.#hash.update("\0");
72
+ }
73
+
74
+ async addFileHandle(label: string, handle: FileHandle, signal?: AbortSignal): Promise<void> {
75
+ this.#hash.update(label);
76
+ this.#hash.update("\0");
77
+ const stream = handle.createReadStream({ autoClose: false, highWaterMark: 64 << 10, signal });
78
+ try {
79
+ for await (const chunk of stream) {
80
+ throwIfAborted(signal);
81
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
82
+ this.#bytes += buffer.length;
83
+ if (this.#bytes > this.maxBytes) {
84
+ stream.destroy();
85
+ throw new Error(`content fingerprint exceeded ${this.maxBytes} bytes`);
86
+ }
87
+ this.#hash.update(buffer);
88
+ }
89
+ } catch (error) {
90
+ throwIfAborted(signal);
91
+ throw error;
92
+ }
93
+ this.#hash.update("\0");
94
+ }
95
+
96
+ digest(): string {
97
+ return this.#hash.digest("hex");
98
+ }
99
+ }
100
+
101
+ /** Open, validate, and use one regular file without reopening its pathname. */
102
+ export async function withValidatedFingerprintFile<T>(
103
+ path: string,
104
+ signal: AbortSignal | undefined,
105
+ use: (handle: FileHandle, info: BigIntStats) => Promise<T>,
106
+ operations: FingerprintFileOperations = NODE_FINGERPRINT_FILE_OPERATIONS,
107
+ ): Promise<T> {
108
+ throwIfAborted(signal);
109
+ const flags = process.platform === "win32"
110
+ ? constants.O_RDONLY
111
+ : constants.O_RDONLY | constants.O_NOFOLLOW;
112
+ const handle = await operations.open(path, flags);
113
+ try {
114
+ const [openedInfo, pathInfo] = await Promise.all([
115
+ handle.stat({ bigint: true }),
116
+ operations.lstat(path),
117
+ ]);
118
+ throwIfAborted(signal);
119
+ if (!openedInfo.isFile()) throw new Error(`content fingerprint opened an unsupported entry: ${path}`);
120
+ if (pathInfo.isSymbolicLink()) throw new Error(`content fingerprint encountered a symbolic link: ${path}`);
121
+ if (!pathInfo.isFile()) throw new Error(`content fingerprint encountered an unsupported entry: ${path}`);
122
+ if (openedInfo.dev !== pathInfo.dev || openedInfo.ino !== pathInfo.ino) {
123
+ throw new Error(`content fingerprint path changed while it was being opened: ${path}`);
124
+ }
125
+ return await use(handle, openedInfo);
126
+ } finally {
127
+ await handle.close();
128
+ }
129
+ }
130
+
131
+ /** Fingerprint a bounded directory tree with stable path ordering and entry handling. */
132
+ export async function captureTreeFingerprint(options: TreeFingerprintOptions): Promise<FingerprintCapture> {
133
+ try {
134
+ throwIfAborted(options.signal);
135
+ const root = resolve(options.root);
136
+ const rootInfo = await lstat(root);
137
+ if (!rootInfo.isDirectory()) throw new Error(`fingerprint root is not a directory: ${root}`);
138
+
139
+ const fingerprint = new BoundedFingerprint(options.maxBytes);
140
+ const directories = [root];
141
+ let fileCount = 0;
142
+
143
+ for (let index = 0; index < directories.length; index++) {
144
+ throwIfAborted(options.signal);
145
+ const directory = directories[index]!;
146
+ const entries = await readdir(directory, { withFileTypes: true });
147
+ entries.sort((left, right) => left.name.localeCompare(right.name));
148
+
149
+ for (const entry of entries) {
150
+ throwIfAborted(options.signal);
151
+ const path = join(directory, entry.name);
152
+ const relativePath = relative(root, path);
153
+ if (entry.isDirectory()) {
154
+ if (!isExcludedRelativePath(relativePath, options.excludedRelativePaths)) directories.push(path);
155
+ continue;
156
+ }
157
+
158
+ fileCount += 1;
159
+ if (fileCount > options.maxFiles) throw new Error(`content fingerprint exceeded ${options.maxFiles} files`);
160
+ fingerprint.add("path", relativePath);
161
+
162
+ if (entry.isSymbolicLink()) {
163
+ throw new Error(`content fingerprint encountered a symbolic link: ${path}`);
164
+ }
165
+ if (!entry.isFile()) throw new Error(`content fingerprint encountered an unsupported entry: ${path}`);
166
+
167
+ await withValidatedFingerprintFile(path, options.signal, async (handle, info) => {
168
+ fingerprint.add("mode", String(info.mode));
169
+ await fingerprint.addFileHandle("file", handle, options.signal);
170
+ });
171
+ }
172
+ }
173
+
174
+ return { kind: "verified", fingerprint: fingerprint.digest() };
175
+ } catch (error) {
176
+ throwIfAborted(options.signal);
177
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
178
+ }
179
+ }
180
+
181
+ /** Fingerprint only explicitly declared files/directories under one shared bound. */
182
+ export async function captureDeclaredInputFingerprint(
183
+ options: DeclaredInputFingerprintOptions,
184
+ ): Promise<FingerprintCapture> {
185
+ try {
186
+ throwIfAborted(options.signal);
187
+
188
+ const root = resolve(options.root);
189
+ const rootInfo = await lstat(root);
190
+ if (!rootInfo.isDirectory()) throw new Error(`declared-input root is not a directory: ${root}`);
191
+
192
+ const resolvedInputs = resolveDeclaredInputPaths(root, options.inputs, options.excludedRelativePaths);
193
+ if (resolvedInputs.kind === "unverifiable") return resolvedInputs;
194
+ const inputs = resolvedInputs.paths;
195
+ const fingerprint = new BoundedFingerprint(options.maxBytes);
196
+ let entryCount = 0;
197
+
198
+ const recordEntry = (relativePath: string, kind: "directory" | "file" | "missing", mode?: number): void => {
199
+ entryCount += 1;
200
+ if (entryCount > options.maxEntries) {
201
+ throw new Error(`declared-input fingerprint exceeded ${options.maxEntries} entries`);
202
+ }
203
+ fingerprint.add("path", relativePath);
204
+ fingerprint.add("kind", kind);
205
+ if (mode !== undefined) fingerprint.add("mode", String(mode));
206
+ };
207
+
208
+ const visit = async (path: string, relativePath: string, directInput = false): Promise<boolean> => {
209
+ throwIfAborted(options.signal);
210
+
211
+ let info;
212
+ try {
213
+ info = await lstat(path);
214
+ } catch (error) {
215
+ if (!isMissingPathError(error)) throw error;
216
+ recordEntry(relativePath, "missing");
217
+ return true;
218
+ }
219
+
220
+ if (info.isSymbolicLink()) throw new Error(`declared input contains a symbolic link: ${relativePath}`);
221
+ if (info.isFile()) {
222
+ await withValidatedFingerprintFile(path, options.signal, async (handle, openedInfo) => {
223
+ recordEntry(relativePath, "file", Number(openedInfo.mode));
224
+ await fingerprint.addFileHandle("file", handle, options.signal);
225
+ });
226
+ return true;
227
+ }
228
+ if (!info.isDirectory()) throw new Error(`declared input contains an unsupported entry: ${relativePath}`);
229
+
230
+ if (options.shallowDirectories === true) {
231
+ recordEntry(relativePath, "directory", info.mode);
232
+ return true;
233
+ }
234
+
235
+ const entries = await readdir(path, { withFileTypes: true });
236
+ entries.sort((left, right) => left.name.localeCompare(right.name));
237
+ let hasObservableChild = false;
238
+ for (const entry of entries) {
239
+ const childPath = join(path, entry.name);
240
+ const childRelativePath = relativePath === "." ? entry.name : `${relativePath}/${entry.name}`;
241
+ if (isExcludedDeclaredInput(childRelativePath, options.excludedRelativePaths)) continue;
242
+ hasObservableChild = (await visit(childPath, childRelativePath)) || hasObservableChild;
243
+ }
244
+ const observableEmptyDirectory = entries.length === 0 && !isExcludedAncestor(relativePath, options.excludedRelativePaths);
245
+ if (!directInput && !hasObservableChild && !observableEmptyDirectory) return false;
246
+ recordEntry(relativePath, "directory", info.mode);
247
+ return true;
248
+ };
249
+
250
+ for (const relativePath of inputs) {
251
+ throwIfAborted(options.signal);
252
+ fingerprint.add("input", relativePath);
253
+ const segments = relativePath === "." ? [] : relativePath.split("/");
254
+ let path = root;
255
+ let missing = false;
256
+ for (let index = 0; index < Math.max(0, segments.length - 1); index++) {
257
+ path = join(path, segments[index]!);
258
+ let info;
259
+ try {
260
+ info = await lstat(path);
261
+ } catch (error) {
262
+ if (!isMissingPathError(error)) throw error;
263
+ recordEntry(relativePath, "missing");
264
+ missing = true;
265
+ break;
266
+ }
267
+ if (info.isSymbolicLink()) throw new Error(`declared input traverses a symbolic link: ${relativePath}`);
268
+ if (!info.isDirectory()) {
269
+ recordEntry(relativePath, "missing");
270
+ missing = true;
271
+ break;
272
+ }
273
+ }
274
+ if (!missing) await visit(relativePath === "." ? root : join(root, ...segments), relativePath, true);
275
+ }
276
+
277
+ return { kind: "verified", fingerprint: fingerprint.digest() };
278
+ } catch (error) {
279
+ throwIfAborted(options.signal);
280
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
281
+ }
282
+ }
283
+
284
+ /** Validate and canonicalize caller-authored paths before passing them to Git or the filesystem. */
285
+ export function resolveDeclaredInputPaths(
286
+ root: string,
287
+ inputs: readonly string[],
288
+ excludedRelativePaths?: ReadonlySet<string>,
289
+ base = root,
290
+ ): DeclaredInputPathResolution {
291
+ try {
292
+ if (!Array.isArray(inputs)) throw new Error("declared inputs must be an array");
293
+ const paths = [...new Set(inputs.map((input) => declaredInputPath(root, base, input)))].sort();
294
+ for (const path of paths) rejectExcludedDeclaredInput(path, excludedRelativePaths);
295
+ return { kind: "verified", paths };
296
+ } catch (error) {
297
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
298
+ }
299
+ }
300
+
301
+ /** Verify that a regular file is reached only through directories included in the tree hash. */
302
+ export async function validateTreeFile(options: TreeFileValidationOptions): Promise<TreeFileValidation> {
303
+ try {
304
+ throwIfAborted(options.signal);
305
+ const root = resolve(options.root);
306
+ const path = resolve(options.path);
307
+ if (!isPathWithin(root, path)) return { kind: "unverifiable", reason: "file is outside its fingerprint root" };
308
+
309
+ const rootInfo = await lstat(root);
310
+ if (!rootInfo.isDirectory()) throw new Error(`fingerprint root is not a directory: ${root}`);
311
+ const pathFromRoot = relative(root, path);
312
+ if (pathFromRoot === "") return { kind: "unverifiable", reason: "fingerprint source resolves to its root directory" };
313
+
314
+ const segments = pathFromRoot.split(/[\\/]/).filter(Boolean);
315
+ let current = root;
316
+ for (let index = 0; index < segments.length; index++) {
317
+ throwIfAborted(options.signal);
318
+ const segment = segments[index]!;
319
+ const isFile = index === segments.length - 1;
320
+ if (!isFile && isExcludedRelativePath(relative(root, join(current, segment)), options.excludedRelativePaths)) {
321
+ return { kind: "unverifiable", reason: "file is inside an excluded fingerprint directory" };
322
+ }
323
+ current = join(current, segment);
324
+ const info = await lstat(current);
325
+ if (isFile ? !info.isFile() : !info.isDirectory()) {
326
+ return {
327
+ kind: "unverifiable",
328
+ reason: isFile ? "fingerprint source is not a regular file" : "fingerprint source path traverses a non-directory entry",
329
+ };
330
+ }
331
+ }
332
+ return { kind: "verified" };
333
+ } catch (error) {
334
+ throwIfAborted(options.signal);
335
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
336
+ }
337
+ }
338
+
339
+ export function isPathWithin(root: string, path: string): boolean {
340
+ const pathFromRoot = relative(resolve(root), resolve(path));
341
+ return pathFromRoot === "" || (!isAbsolute(pathFromRoot) && pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`));
342
+ }
343
+
344
+ function isExcludedRelativePath(path: string, excluded: ReadonlySet<string> | undefined): boolean {
345
+ if (!excluded || excluded.size === 0) return false;
346
+ const normalized = normalizeRelativePath(path);
347
+ for (const candidate of excluded) {
348
+ const normalizedCandidate = normalizeRelativePath(candidate);
349
+ if (normalized === normalizedCandidate || normalized.startsWith(`${normalizedCandidate}/`)) return true;
350
+ }
351
+ return false;
352
+ }
353
+
354
+ function normalizeRelativePath(path: string): string {
355
+ return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
356
+ }
357
+
358
+ function declaredInputPath(root: string, base: string, input: string): string {
359
+ if (typeof input !== "string" || input.length === 0) throw new Error("declared input paths must be non-empty strings");
360
+ if (isAbsolute(input)) throw new Error(`declared input must be relative to the workflow cwd: ${input}`);
361
+ const path = resolve(base, input);
362
+ if (!isPathWithin(root, path)) throw new Error(`declared input escapes the repository root: ${input}`);
363
+ if (!isPathWithin(base, path)) throw new Error(`declared input escapes the workflow cwd: ${input}`);
364
+ const relativePath = relative(root, path).split(sep).join("/");
365
+ return relativePath.length === 0 ? "." : relativePath;
366
+ }
367
+
368
+ function rejectExcludedDeclaredInput(path: string, excluded: ReadonlySet<string> | undefined): void {
369
+ if (!isExcludedDeclaredInput(path, excluded)) return;
370
+ throw new Error(`declared input enters excluded path: ${path}`);
371
+ }
372
+
373
+ function isExcludedDeclaredInput(path: string, excluded: ReadonlySet<string> | undefined): boolean {
374
+ if (!excluded) return false;
375
+ const normalized = normalizeRelativePath(path);
376
+ for (const candidate of excluded) {
377
+ const normalizedCandidate = normalizeRelativePath(candidate);
378
+ if (
379
+ normalized === normalizedCandidate ||
380
+ normalized.startsWith(`${normalizedCandidate}/`) ||
381
+ normalized.endsWith(`/${normalizedCandidate}`) ||
382
+ normalized.includes(`/${normalizedCandidate}/`)
383
+ ) {
384
+ return true;
385
+ }
386
+ }
387
+ return false;
388
+ }
389
+
390
+ function isExcludedAncestor(path: string, excluded: ReadonlySet<string> | undefined): boolean {
391
+ if (!excluded) return false;
392
+ const segments = normalizeRelativePath(path).split("/").filter((segment) => segment !== ".");
393
+ for (const candidate of excluded) {
394
+ const normalizedCandidate = normalizeRelativePath(candidate);
395
+ for (let index = 0; index < segments.length; index++) {
396
+ const suffix = segments.slice(index).join("/");
397
+ if (normalizedCandidate.startsWith(`${suffix}/`)) return true;
398
+ }
399
+ }
400
+ return false;
401
+ }
@@ -1,10 +1,14 @@
1
1
  import type { Static, TSchema } from "typebox";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
3
  import type { WorkflowBudget } from "./budget.ts";
4
- import type { Pipeline } from "./concurrency.ts";
4
+ import type { Pipeline, WorkflowParallel } from "./concurrency.ts";
5
5
  import type { PerfSink, PerfSnapshot } from "./perf.ts";
6
- import type { WorkflowProgressSnapshot } from "./progress.ts";
6
+ import type { WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
7
7
  import type { WorkflowUsageSnapshot } from "./usage.ts";
8
+ import type { WorktreeBaseline } from "./worktree.ts";
9
+ import type { WorkflowModelProfileName } from "./model-profiles.ts";
10
+
11
+ export type { WorkflowLaneItemStatus } from "./progress-types.ts";
8
12
 
9
13
  /** A reference to a registered workflow by name. */
10
14
  export type WorkflowRef = string;
@@ -28,6 +32,13 @@ export interface WorkflowRunMetadata {
28
32
  readonly runId: string;
29
33
  readonly resumedFromRunId?: string;
30
34
  readonly journalPath: string;
35
+ readonly recordPath: string;
36
+ }
37
+
38
+ export interface WorkflowBackgroundOrigin {
39
+ /** Stable pi session id used to route and deduplicate completion delivery. */
40
+ readonly sessionId: string;
41
+ readonly requestedAt: number;
31
42
  }
32
43
 
33
44
  export interface WorkflowRunOptions {
@@ -35,29 +46,47 @@ export interface WorkflowRunOptions {
35
46
  perf?: boolean;
36
47
  concurrency?: number;
37
48
  parallelSubmissionLimit?: number;
38
- /** Output-token ceiling for the whole run. Omit for no limit (budget.total === null). */
49
+ /** Maximum number of agent calls that may reach a live model session during this run. */
50
+ maxAgents?: number;
51
+ /** Maximum live duration of one agent call in milliseconds. */
52
+ agentTimeoutMs?: number;
53
+ /** Retry count for transient provider/session failures. Defaults to zero. */
54
+ agentRetries?: number;
55
+ /** Explicitly schedule resumable background runs after recognized provider usage windows. */
56
+ autoResumeOnUsageLimit?: boolean;
57
+ /** Maximum recognized usage-limit pauses before automatic scheduling stops. */
58
+ usageLimitMaxAttempts?: number;
59
+ /** Maximum delay accepted from a provider reset hint. */
60
+ usageLimitMaxDelayMs?: number;
61
+ /** Internal count carried into a scheduler-created successor run. */
62
+ usageLimitAttempt?: number;
63
+ /** Output-token ceiling across recorded subagent attempts. Omit for no limit (budget.total === null). */
39
64
  budget?: number;
40
65
  /** Internal/test override for the generated run id. Omit to generate a new id. */
41
66
  runId?: string;
42
- /** Replay completed agent results from this prior run id when prompt/options hashes still match. */
67
+ /** Internal origin metadata for an explicitly backgrounded tool invocation. */
68
+ background?: WorkflowBackgroundOrigin;
69
+ /** Replay completed agent results from this prior run id when call and execution context still match. */
43
70
  resumeFromRunId?: string;
71
+ /** Explicitly allow resume to ignore only a workflow-source fingerprint mismatch. */
72
+ resumeEditedWorkflow?: boolean;
44
73
  resultViewer?: "open" | "skip";
45
74
  /** Additional abort signal to compose with the host context signal. */
46
75
  signal?: AbortSignal;
47
76
  /** Internal recorder override for command/tool invocation timing. */
48
77
  perfRecorder?: PerfSink;
49
78
  /** Resolve a sub-workflow reference to a module, enabling `api.workflow()`. When omitted, `api.workflow()` throws. */
50
- resolveWorkflow?: (ref: WorkflowRef) => Promise<WorkflowModule>;
79
+ resolveWorkflow?: (ref: WorkflowRef) => Promise<LoadedWorkflow>;
51
80
  /** Called with the final performance snapshot when perf is enabled. */
52
- onPerfSnapshot?: (snapshot: PerfSnapshot) => void;
81
+ onPerfSnapshot?: (snapshot: PerfSnapshot) => void | Promise<void>;
53
82
  /** Called with the final workflow subagent usage snapshot. */
54
- onUsageSnapshot?: (snapshot: WorkflowUsageSnapshot) => void;
83
+ onUsageSnapshot?: (snapshot: WorkflowUsageSnapshot) => void | Promise<void>;
55
84
  /** Called once run identity and journal paths are known. */
56
- onRunMetadata?: (metadata: WorkflowRunMetadata) => void;
85
+ onRunMetadata?: (metadata: WorkflowRunMetadata) => void | Promise<void>;
57
86
  /** Called with the live progress source while a workflow is running, then undefined when it ends. */
58
- onProgressSource?: (source: WorkflowProgressSource | undefined) => void;
87
+ onProgressSource?: (source: WorkflowProgressSource | undefined) => void | Promise<void>;
59
88
  /** Called with the final completed progress snapshot after live workflow UI teardown. */
60
- onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void;
89
+ onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void | Promise<void>;
61
90
  }
62
91
 
63
92
  export interface WorkflowProgressSource {
@@ -65,9 +94,8 @@ export interface WorkflowProgressSource {
65
94
  }
66
95
 
67
96
  /** Options for a single `agent()` call. */
68
- export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
69
-
70
- export type AgentToolHint = "search";
97
+ export type AgentToolHint = "search" | "external-search";
98
+ export type AgentResumePolicy = "read-only" | "off";
71
99
 
72
100
  export interface IsolatedAgentResult<T> {
73
101
  readonly result: T;
@@ -94,27 +122,46 @@ export interface AgentOptions<S extends TSchema = TSchema> {
94
122
  /** Phase to group this agent under in the progress tree. */
95
123
  phase?: string;
96
124
  /**
97
- * Optional model id. Omit to inherit the host model. Explicit refs are strict:
98
- * bare ids resolve as Anthropic shorthand; use "provider/id" for other providers.
125
+ * Optional model id. Overrides profile routing; when both model and profile are
126
+ * omitted, inherit the host model. Explicit refs are strict: bare ids resolve as
127
+ * Anthropic shorthand; use "provider/id" for other providers.
99
128
  */
100
129
  model?: string;
101
- /** Reasoning effort for this agent. */
130
+ /** Reasoning effort for this agent. Overrides the profile's configured effort. */
102
131
  thinkingLevel?: ThinkingLevel;
132
+ /** Exact configured model route to use when model/thinkingLevel do not override it. */
133
+ profile?: WorkflowModelProfileName;
103
134
  /**
104
135
  * Stable identity hint for resume replay. Use this for repeated logical calls
105
136
  * with identical prompts/options, e.g. `${stage}:${item.id}`.
106
137
  */
107
138
  cacheKey?: string;
139
+ /**
140
+ * Resume policy for this call. Shared-workspace agents run live unless they
141
+ * explicitly declare themselves read-only. Those calls bind replay to the full
142
+ * Git-visible workspace; isolated agents bind to their disposable baseline.
143
+ * Use "off" to disable both journal reads and writes.
144
+ */
145
+ resume?: AgentResumePolicy;
146
+ /**
147
+ * Additional ignored/generated inputs under the workflow cwd that this
148
+ * read-only agent may observe. Git-visible files across the repository are
149
+ * captured automatically; list cwd-relative ignored paths here or use
150
+ * `resume: "off"` when their bounded contents cannot be captured.
151
+ */
152
+ resumeInputs?: readonly string[];
108
153
  /** Run this agent in a disposable git worktree and return its patch with the result. */
109
154
  isolation?: "worktree";
110
155
  /** Allowlist of concrete tool names the agent may use (e.g. ["read", "bash"]). */
111
156
  tools?: string[];
112
157
  /**
113
- * Dynamically include installed tools matching semantic categories. Currently
114
- * "search" matches grep/find/code-search style tools such as ast-grep,
115
- * mgrep, ffgrep, fffind, ripgrep wrappers, and pi's built-in grep/find/ls.
158
+ * Dynamically include installed tools matching semantic categories. "search"
159
+ * matches local grep/find/code-search tools; "external-search" matches installed
160
+ * web search, browsing, and URL extraction tools.
116
161
  */
117
162
  toolHints?: readonly AgentToolHint[];
163
+ /** Fail before prompting unless every semantic tool hint matched an installed tool. */
164
+ requireToolHints?: boolean;
118
165
  /**
119
166
  * Skill names to expose to this subagent. Subagents receive no skills by default.
120
167
  * When omitted, clear prompt text such as `/skill:name`, `include skill name`, or
@@ -122,7 +169,10 @@ export interface AgentOptions<S extends TSchema = TSchema> {
122
169
  * prompt inference for prompts that mention skills only as subject matter.
123
170
  */
124
171
  skills?: readonly string[];
125
- /** typebox schema. When set, the agent is forced to return matching structured data. */
172
+ /**
173
+ * typebox schema. The agent must call the validated `final_answer` tool; bounded
174
+ * repair exhaustion throws `WorkflowStructuredOutputError` instead of returning null.
175
+ */
126
176
  schema?: S;
127
177
  }
128
178
 
@@ -135,11 +185,11 @@ export interface WorkflowApi {
135
185
  agent<S extends TSchema>(
136
186
  prompt: string,
137
187
  opts: AgentOptions<S> & { schema: S; isolation: "worktree" },
138
- ): Promise<IsolatedAgentResult<Static<S> | null>>;
188
+ ): Promise<IsolatedAgentResult<Static<S>>>;
139
189
  /** Run a text subagent in a disposable worktree and return its final text plus patch. */
140
190
  agent(prompt: string, opts: AgentOptions & { isolation: "worktree" }): Promise<IsolatedAgentResult<string>>;
141
- /** Run a subagent and return its validated structured output (null if it never produced one). */
142
- agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S> | null>;
191
+ /** Run a subagent and return validated structured output; rejects with a recoverable typed error on repair exhaustion. */
192
+ agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
143
193
  /** Run a subagent and return its final assistant text. */
144
194
  agent(prompt: string, opts?: AgentOptions): Promise<string>;
145
195
  /**
@@ -151,9 +201,9 @@ export interface WorkflowApi {
151
201
  workflow(ref: WorkflowRef, args?: string): Promise<unknown>;
152
202
  /**
153
203
  * Run every thunk concurrently and wait for all (a barrier). Recoverable thunk
154
- * failures become null slots; filter nulls before consuming survivors.
204
+ * failures become null slots by default; settled mode retains serialisable errors.
155
205
  */
156
- parallel<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
206
+ parallel: WorkflowParallel;
157
207
  /**
158
208
  * Run each item through all stages independently — no barrier between stages.
159
209
  * A recoverable stage failure drops that item to null and skips its later stages.
@@ -181,7 +231,19 @@ export interface WorkflowApi {
181
231
 
182
232
  export type WorkflowRun = (api: WorkflowApi) => Promise<unknown>;
183
233
 
234
+ export type WorkflowSourceIdentity =
235
+ | { readonly kind: "file"; readonly path: string; readonly root: string; readonly fingerprint: string }
236
+ | { readonly kind: "fingerprint"; readonly fingerprint: string }
237
+ | { readonly kind: "unverifiable"; readonly reason: string };
238
+
239
+ /** The authored exports of a workflow module. */
184
240
  export interface WorkflowModule {
185
241
  meta: WorkflowMeta;
186
242
  default: WorkflowRun;
187
243
  }
244
+
245
+ /** A validated workflow plus engine-owned provenance used for safe resume replay. */
246
+ export interface LoadedWorkflow extends WorkflowModule {
247
+ readonly source: WorkflowSourceIdentity;
248
+ readonly isolatedWorktreeBaseline?: WorktreeBaseline;
249
+ }