@jameslovespancakes/pi-plus 1.0.11 → 1.0.13

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 (133) hide show
  1. package/README.md +67 -22
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +9 -4
  4. package/src/core/accounts/oauth-pool.ts +137 -0
  5. package/src/core/accounts/registry.ts +12 -25
  6. package/src/core/accounts/routing.ts +144 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/identity.ts +54 -0
  9. package/src/core/anthropic/oauth.ts +3 -0
  10. package/src/core/anthropic/quota.ts +232 -78
  11. package/src/core/anthropic/routing.ts +40 -115
  12. package/src/core/anthropic/store.ts +11 -10
  13. package/src/core/catalog/quality.ts +33 -15
  14. package/src/core/codex/quota.ts +46 -8
  15. package/src/core/codex/store.ts +29 -16
  16. package/src/core/config.ts +16 -17
  17. package/src/core/store.ts +13 -12
  18. package/src/domains/agents/format.ts +97 -0
  19. package/src/domains/agents/index.ts +58 -47
  20. package/src/domains/compact/archive.ts +140 -0
  21. package/src/domains/compact/chunking.ts +166 -0
  22. package/src/domains/compact/index.ts +452 -0
  23. package/src/domains/compact/jev.ts +242 -0
  24. package/src/domains/compact/policy.ts +255 -0
  25. package/src/domains/compact/types.ts +79 -0
  26. package/src/domains/models/provider-picker.ts +1 -1
  27. package/src/domains/setup/index.ts +23 -23
  28. package/src/domains/subscriptions/accounts-picker.ts +61 -40
  29. package/src/domains/subscriptions/accounts.ts +53 -36
  30. package/src/domains/subscriptions/footer.ts +48 -29
  31. package/src/domains/subscriptions/index.ts +11 -27
  32. package/src/domains/subscriptions/provider.ts +79 -103
  33. package/src/domains/subscriptions/providers/anthropic.ts +42 -22
  34. package/src/domains/subscriptions/providers/codex.ts +146 -113
  35. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  36. package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
  37. package/src/domains/subscriptions/routing.ts +19 -11
  38. package/src/domains/workflows/LICENSE.md +21 -0
  39. package/src/domains/workflows/index.ts +836 -0
  40. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  41. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  42. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  43. package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
  44. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  45. package/src/domains/workflows/runtime/agent-limits.ts +66 -0
  46. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  47. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  48. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  49. package/src/domains/workflows/runtime/agent-runner.ts +225 -0
  50. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  51. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  52. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  53. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  54. package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
  55. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  56. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  57. package/src/domains/workflows/runtime/budget.ts +53 -0
  58. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  59. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  60. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  61. package/src/domains/workflows/runtime/debug.ts +3 -0
  62. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  63. package/src/domains/workflows/runtime/discovery.ts +137 -0
  64. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  65. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  66. package/src/domains/workflows/runtime/engine.ts +579 -0
  67. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  68. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  69. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  70. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  71. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  72. package/src/domains/workflows/runtime/journal.ts +313 -0
  73. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  74. package/src/domains/workflows/runtime/options.ts +157 -0
  75. package/src/domains/workflows/runtime/perf.ts +146 -0
  76. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  77. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  78. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  79. package/src/domains/workflows/runtime/progress.ts +400 -0
  80. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  81. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  82. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  83. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  84. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  85. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  86. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  87. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  88. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  89. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  90. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  91. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  92. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  93. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  94. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  95. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  96. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  97. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  98. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  99. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  100. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  101. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  102. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  103. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  104. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  105. package/src/domains/workflows/runtime/types.ts +255 -0
  106. package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
  107. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  108. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  109. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
  110. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  111. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  112. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  113. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  114. package/src/domains/workflows/runtime/usage.ts +341 -0
  115. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  116. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  117. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  118. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  119. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  120. package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
  121. package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
  122. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  123. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  124. package/src/domains/workflows/runtime/workflows.ts +40 -0
  125. package/src/domains/workflows/runtime/worktree.ts +615 -0
  126. package/src/domains/workflows/workflows/code-review.ts +232 -0
  127. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  128. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  129. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  130. package/src/domains/workflows/workflows/research.ts +169 -0
  131. package/src/services/usage-service.ts +6 -30
  132. package/src/ui/usage-bars.ts +1 -2
  133. package/src/core/codex/oauth.ts +0 -129
@@ -0,0 +1,404 @@
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
+ readonly #maxBytes: number;
64
+ #bytes = 0;
65
+
66
+ constructor(maxBytes: number) {
67
+ this.#maxBytes = maxBytes;
68
+ }
69
+
70
+ add(label: string, value: string | Buffer): void {
71
+ this.#hash.update(label);
72
+ this.#hash.update("\0");
73
+ this.#hash.update(value);
74
+ this.#hash.update("\0");
75
+ }
76
+
77
+ async addFileHandle(label: string, handle: FileHandle, signal?: AbortSignal): Promise<void> {
78
+ this.#hash.update(label);
79
+ this.#hash.update("\0");
80
+ const stream = handle.createReadStream({ autoClose: false, highWaterMark: 64 << 10, signal });
81
+ try {
82
+ for await (const chunk of stream) {
83
+ throwIfAborted(signal);
84
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
85
+ this.#bytes += buffer.length;
86
+ if (this.#bytes > this.#maxBytes) {
87
+ stream.destroy();
88
+ throw new Error(`content fingerprint exceeded ${this.#maxBytes} bytes`);
89
+ }
90
+ this.#hash.update(buffer);
91
+ }
92
+ } catch (error) {
93
+ throwIfAborted(signal);
94
+ throw error;
95
+ }
96
+ this.#hash.update("\0");
97
+ }
98
+
99
+ digest(): string {
100
+ return this.#hash.digest("hex");
101
+ }
102
+ }
103
+
104
+ /** Open, validate, and use one regular file without reopening its pathname. */
105
+ export async function withValidatedFingerprintFile<T>(
106
+ path: string,
107
+ signal: AbortSignal | undefined,
108
+ use: (handle: FileHandle, info: BigIntStats) => Promise<T>,
109
+ operations: FingerprintFileOperations = NODE_FINGERPRINT_FILE_OPERATIONS,
110
+ ): Promise<T> {
111
+ throwIfAborted(signal);
112
+ const flags = process.platform === "win32"
113
+ ? constants.O_RDONLY
114
+ : constants.O_RDONLY | constants.O_NOFOLLOW;
115
+ const handle = await operations.open(path, flags);
116
+ try {
117
+ const [openedInfo, pathInfo] = await Promise.all([
118
+ handle.stat({ bigint: true }),
119
+ operations.lstat(path),
120
+ ]);
121
+ throwIfAborted(signal);
122
+ if (!openedInfo.isFile()) throw new Error(`content fingerprint opened an unsupported entry: ${path}`);
123
+ if (pathInfo.isSymbolicLink()) throw new Error(`content fingerprint encountered a symbolic link: ${path}`);
124
+ if (!pathInfo.isFile()) throw new Error(`content fingerprint encountered an unsupported entry: ${path}`);
125
+ if (openedInfo.dev !== pathInfo.dev || openedInfo.ino !== pathInfo.ino) {
126
+ throw new Error(`content fingerprint path changed while it was being opened: ${path}`);
127
+ }
128
+ return await use(handle, openedInfo);
129
+ } finally {
130
+ await handle.close();
131
+ }
132
+ }
133
+
134
+ /** Fingerprint a bounded directory tree with stable path ordering and entry handling. */
135
+ export async function captureTreeFingerprint(options: TreeFingerprintOptions): Promise<FingerprintCapture> {
136
+ try {
137
+ throwIfAborted(options.signal);
138
+ const root = resolve(options.root);
139
+ const rootInfo = await lstat(root);
140
+ if (!rootInfo.isDirectory()) throw new Error(`fingerprint root is not a directory: ${root}`);
141
+
142
+ const fingerprint = new BoundedFingerprint(options.maxBytes);
143
+ const directories = [root];
144
+ let fileCount = 0;
145
+
146
+ for (let index = 0; index < directories.length; index++) {
147
+ throwIfAborted(options.signal);
148
+ const directory = directories[index]!;
149
+ const entries = await readdir(directory, { withFileTypes: true });
150
+ entries.sort((left, right) => left.name.localeCompare(right.name));
151
+
152
+ for (const entry of entries) {
153
+ throwIfAborted(options.signal);
154
+ const path = join(directory, entry.name);
155
+ const relativePath = relative(root, path);
156
+ if (entry.isDirectory()) {
157
+ if (!isExcludedRelativePath(relativePath, options.excludedRelativePaths)) directories.push(path);
158
+ continue;
159
+ }
160
+
161
+ fileCount += 1;
162
+ if (fileCount > options.maxFiles) throw new Error(`content fingerprint exceeded ${options.maxFiles} files`);
163
+ fingerprint.add("path", relativePath);
164
+
165
+ if (entry.isSymbolicLink()) {
166
+ throw new Error(`content fingerprint encountered a symbolic link: ${path}`);
167
+ }
168
+ if (!entry.isFile()) throw new Error(`content fingerprint encountered an unsupported entry: ${path}`);
169
+
170
+ await withValidatedFingerprintFile(path, options.signal, async (handle, info) => {
171
+ fingerprint.add("mode", String(info.mode));
172
+ await fingerprint.addFileHandle("file", handle, options.signal);
173
+ });
174
+ }
175
+ }
176
+
177
+ return { kind: "verified", fingerprint: fingerprint.digest() };
178
+ } catch (error) {
179
+ throwIfAborted(options.signal);
180
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
181
+ }
182
+ }
183
+
184
+ /** Fingerprint only explicitly declared files/directories under one shared bound. */
185
+ export async function captureDeclaredInputFingerprint(
186
+ options: DeclaredInputFingerprintOptions,
187
+ ): Promise<FingerprintCapture> {
188
+ try {
189
+ throwIfAborted(options.signal);
190
+
191
+ const root = resolve(options.root);
192
+ const rootInfo = await lstat(root);
193
+ if (!rootInfo.isDirectory()) throw new Error(`declared-input root is not a directory: ${root}`);
194
+
195
+ const resolvedInputs = resolveDeclaredInputPaths(root, options.inputs, options.excludedRelativePaths);
196
+ if (resolvedInputs.kind === "unverifiable") return resolvedInputs;
197
+ const inputs = resolvedInputs.paths;
198
+ const fingerprint = new BoundedFingerprint(options.maxBytes);
199
+ let entryCount = 0;
200
+
201
+ const recordEntry = (relativePath: string, kind: "directory" | "file" | "missing", mode?: number): void => {
202
+ entryCount += 1;
203
+ if (entryCount > options.maxEntries) {
204
+ throw new Error(`declared-input fingerprint exceeded ${options.maxEntries} entries`);
205
+ }
206
+ fingerprint.add("path", relativePath);
207
+ fingerprint.add("kind", kind);
208
+ if (mode !== undefined) fingerprint.add("mode", String(mode));
209
+ };
210
+
211
+ const visit = async (path: string, relativePath: string, directInput = false): Promise<boolean> => {
212
+ throwIfAborted(options.signal);
213
+
214
+ let info;
215
+ try {
216
+ info = await lstat(path);
217
+ } catch (error) {
218
+ if (!isMissingPathError(error)) throw error;
219
+ recordEntry(relativePath, "missing");
220
+ return true;
221
+ }
222
+
223
+ if (info.isSymbolicLink()) throw new Error(`declared input contains a symbolic link: ${relativePath}`);
224
+ if (info.isFile()) {
225
+ await withValidatedFingerprintFile(path, options.signal, async (handle, openedInfo) => {
226
+ recordEntry(relativePath, "file", Number(openedInfo.mode));
227
+ await fingerprint.addFileHandle("file", handle, options.signal);
228
+ });
229
+ return true;
230
+ }
231
+ if (!info.isDirectory()) throw new Error(`declared input contains an unsupported entry: ${relativePath}`);
232
+
233
+ if (options.shallowDirectories === true) {
234
+ recordEntry(relativePath, "directory", info.mode);
235
+ return true;
236
+ }
237
+
238
+ const entries = await readdir(path, { withFileTypes: true });
239
+ entries.sort((left, right) => left.name.localeCompare(right.name));
240
+ let hasObservableChild = false;
241
+ for (const entry of entries) {
242
+ const childPath = join(path, entry.name);
243
+ const childRelativePath = relativePath === "." ? entry.name : `${relativePath}/${entry.name}`;
244
+ if (isExcludedDeclaredInput(childRelativePath, options.excludedRelativePaths)) continue;
245
+ hasObservableChild = (await visit(childPath, childRelativePath)) || hasObservableChild;
246
+ }
247
+ const observableEmptyDirectory = entries.length === 0 && !isExcludedAncestor(relativePath, options.excludedRelativePaths);
248
+ if (!directInput && !hasObservableChild && !observableEmptyDirectory) return false;
249
+ recordEntry(relativePath, "directory", info.mode);
250
+ return true;
251
+ };
252
+
253
+ for (const relativePath of inputs) {
254
+ throwIfAborted(options.signal);
255
+ fingerprint.add("input", relativePath);
256
+ const segments = relativePath === "." ? [] : relativePath.split("/");
257
+ let path = root;
258
+ let missing = false;
259
+ for (let index = 0; index < Math.max(0, segments.length - 1); index++) {
260
+ path = join(path, segments[index]!);
261
+ let info;
262
+ try {
263
+ info = await lstat(path);
264
+ } catch (error) {
265
+ if (!isMissingPathError(error)) throw error;
266
+ recordEntry(relativePath, "missing");
267
+ missing = true;
268
+ break;
269
+ }
270
+ if (info.isSymbolicLink()) throw new Error(`declared input traverses a symbolic link: ${relativePath}`);
271
+ if (!info.isDirectory()) {
272
+ recordEntry(relativePath, "missing");
273
+ missing = true;
274
+ break;
275
+ }
276
+ }
277
+ if (!missing) await visit(relativePath === "." ? root : join(root, ...segments), relativePath, true);
278
+ }
279
+
280
+ return { kind: "verified", fingerprint: fingerprint.digest() };
281
+ } catch (error) {
282
+ throwIfAborted(options.signal);
283
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
284
+ }
285
+ }
286
+
287
+ /** Validate and canonicalize caller-authored paths before passing them to Git or the filesystem. */
288
+ export function resolveDeclaredInputPaths(
289
+ root: string,
290
+ inputs: readonly string[],
291
+ excludedRelativePaths?: ReadonlySet<string>,
292
+ base = root,
293
+ ): DeclaredInputPathResolution {
294
+ try {
295
+ if (!Array.isArray(inputs)) throw new Error("declared inputs must be an array");
296
+ const paths = [...new Set(inputs.map((input) => declaredInputPath(root, base, input)))].sort();
297
+ for (const path of paths) rejectExcludedDeclaredInput(path, excludedRelativePaths);
298
+ return { kind: "verified", paths };
299
+ } catch (error) {
300
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
301
+ }
302
+ }
303
+
304
+ /** Verify that a regular file is reached only through directories included in the tree hash. */
305
+ export async function validateTreeFile(options: TreeFileValidationOptions): Promise<TreeFileValidation> {
306
+ try {
307
+ throwIfAborted(options.signal);
308
+ const root = resolve(options.root);
309
+ const path = resolve(options.path);
310
+ if (!isPathWithin(root, path)) return { kind: "unverifiable", reason: "file is outside its fingerprint root" };
311
+
312
+ const rootInfo = await lstat(root);
313
+ if (!rootInfo.isDirectory()) throw new Error(`fingerprint root is not a directory: ${root}`);
314
+ const pathFromRoot = relative(root, path);
315
+ if (pathFromRoot === "") return { kind: "unverifiable", reason: "fingerprint source resolves to its root directory" };
316
+
317
+ const segments = pathFromRoot.split(/[\\/]/).filter(Boolean);
318
+ let current = root;
319
+ for (let index = 0; index < segments.length; index++) {
320
+ throwIfAborted(options.signal);
321
+ const segment = segments[index]!;
322
+ const isFile = index === segments.length - 1;
323
+ if (!isFile && isExcludedRelativePath(relative(root, join(current, segment)), options.excludedRelativePaths)) {
324
+ return { kind: "unverifiable", reason: "file is inside an excluded fingerprint directory" };
325
+ }
326
+ current = join(current, segment);
327
+ const info = await lstat(current);
328
+ if (isFile ? !info.isFile() : !info.isDirectory()) {
329
+ return {
330
+ kind: "unverifiable",
331
+ reason: isFile ? "fingerprint source is not a regular file" : "fingerprint source path traverses a non-directory entry",
332
+ };
333
+ }
334
+ }
335
+ return { kind: "verified" };
336
+ } catch (error) {
337
+ throwIfAborted(options.signal);
338
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
339
+ }
340
+ }
341
+
342
+ export function isPathWithin(root: string, path: string): boolean {
343
+ const pathFromRoot = relative(resolve(root), resolve(path));
344
+ return pathFromRoot === "" || (!isAbsolute(pathFromRoot) && pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`));
345
+ }
346
+
347
+ function isExcludedRelativePath(path: string, excluded: ReadonlySet<string> | undefined): boolean {
348
+ if (!excluded || excluded.size === 0) return false;
349
+ const normalized = normalizeRelativePath(path);
350
+ for (const candidate of excluded) {
351
+ const normalizedCandidate = normalizeRelativePath(candidate);
352
+ if (normalized === normalizedCandidate || normalized.startsWith(`${normalizedCandidate}/`)) return true;
353
+ }
354
+ return false;
355
+ }
356
+
357
+ function normalizeRelativePath(path: string): string {
358
+ return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
359
+ }
360
+
361
+ function declaredInputPath(root: string, base: string, input: string): string {
362
+ if (typeof input !== "string" || input.length === 0) throw new Error("declared input paths must be non-empty strings");
363
+ if (isAbsolute(input)) throw new Error(`declared input must be relative to the workflow cwd: ${input}`);
364
+ const path = resolve(base, input);
365
+ if (!isPathWithin(root, path)) throw new Error(`declared input escapes the repository root: ${input}`);
366
+ if (!isPathWithin(base, path)) throw new Error(`declared input escapes the workflow cwd: ${input}`);
367
+ const relativePath = relative(root, path).split(sep).join("/");
368
+ return relativePath.length === 0 ? "." : relativePath;
369
+ }
370
+
371
+ function rejectExcludedDeclaredInput(path: string, excluded: ReadonlySet<string> | undefined): void {
372
+ if (!isExcludedDeclaredInput(path, excluded)) return;
373
+ throw new Error(`declared input enters excluded path: ${path}`);
374
+ }
375
+
376
+ function isExcludedDeclaredInput(path: string, excluded: ReadonlySet<string> | undefined): boolean {
377
+ if (!excluded) return false;
378
+ const normalized = normalizeRelativePath(path);
379
+ for (const candidate of excluded) {
380
+ const normalizedCandidate = normalizeRelativePath(candidate);
381
+ if (
382
+ normalized === normalizedCandidate ||
383
+ normalized.startsWith(`${normalizedCandidate}/`) ||
384
+ normalized.endsWith(`/${normalizedCandidate}`) ||
385
+ normalized.includes(`/${normalizedCandidate}/`)
386
+ ) {
387
+ return true;
388
+ }
389
+ }
390
+ return false;
391
+ }
392
+
393
+ function isExcludedAncestor(path: string, excluded: ReadonlySet<string> | undefined): boolean {
394
+ if (!excluded) return false;
395
+ const segments = normalizeRelativePath(path).split("/").filter((segment) => segment !== ".");
396
+ for (const candidate of excluded) {
397
+ const normalizedCandidate = normalizeRelativePath(candidate);
398
+ for (let index = 0; index < segments.length; index++) {
399
+ const suffix = segments.slice(index).join("/");
400
+ if (normalizedCandidate.startsWith(`${suffix}/`)) return true;
401
+ }
402
+ }
403
+ return false;
404
+ }
@@ -0,0 +1,255 @@
1
+ import type { Static, TSchema } from "typebox";
2
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
+ import type { WorkflowBudget } from "./budget.ts";
4
+ import type { Pipeline, WorkflowParallel } from "./concurrency.ts";
5
+ import type { PerfSink, PerfSnapshot } from "./perf.ts";
6
+ import type { AgentChatMessage, WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
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";
12
+
13
+ /** A reference to a registered workflow by name. */
14
+ export type WorkflowRef = string;
15
+
16
+ /** Metadata every workflow module must export. */
17
+ export interface WorkflowMeta {
18
+ name: string;
19
+ description: string;
20
+ phases?: Array<{ title: string }>;
21
+ }
22
+
23
+ export interface WorkflowRunStats {
24
+ files: number;
25
+ candidates: number;
26
+ verified: number;
27
+ kept: number;
28
+ [key: string]: string | number;
29
+ }
30
+
31
+ export interface WorkflowRunMetadata {
32
+ readonly runId: string;
33
+ readonly resumedFromRunId?: string;
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;
42
+ }
43
+
44
+ export interface WorkflowRunOptions {
45
+ inspect?: boolean;
46
+ perf?: boolean;
47
+ concurrency?: number;
48
+ parallelSubmissionLimit?: number;
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). */
64
+ budget?: number;
65
+ /** Internal/test override for the generated run id. Omit to generate a new id. */
66
+ runId?: string;
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. */
70
+ resumeFromRunId?: string;
71
+ /** Explicitly allow resume to ignore only a workflow-source fingerprint mismatch. */
72
+ resumeEditedWorkflow?: boolean;
73
+ resultViewer?: "open" | "skip";
74
+ /** Additional abort signal to compose with the host context signal. */
75
+ signal?: AbortSignal;
76
+ /** Internal recorder override for command/tool invocation timing. */
77
+ perfRecorder?: PerfSink;
78
+ /** Resolve a sub-workflow reference to a module, enabling `api.workflow()`. When omitted, `api.workflow()` throws. */
79
+ resolveWorkflow?: (ref: WorkflowRef) => Promise<LoadedWorkflow>;
80
+ /** Called with the final performance snapshot when perf is enabled. */
81
+ onPerfSnapshot?: (snapshot: PerfSnapshot) => void | Promise<void>;
82
+ /** Called with the final workflow subagent usage snapshot. */
83
+ onUsageSnapshot?: (snapshot: WorkflowUsageSnapshot) => void | Promise<void>;
84
+ /** Called once run identity and journal paths are known. */
85
+ onRunMetadata?: (metadata: WorkflowRunMetadata) => void | Promise<void>;
86
+ /** Called with the live progress source while a workflow is running, then undefined when it ends. */
87
+ onProgressSource?: (source: WorkflowProgressSource | undefined) => void | Promise<void>;
88
+ /** Called with the final completed progress snapshot after live workflow UI teardown. */
89
+ onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void | Promise<void>;
90
+ }
91
+
92
+ export interface WorkflowProgressSource {
93
+ snapshot(): WorkflowProgressSnapshot;
94
+ conversation(agentId: number): readonly AgentChatMessage[];
95
+ followUp(agentId: number, message: string): Promise<void>;
96
+ subscribe(listener: () => void): () => void;
97
+ }
98
+
99
+ /** Options for a single `agent()` call. */
100
+ export type AgentToolHint = "search" | "external-search";
101
+ export type AgentResumePolicy = "read-only" | "off";
102
+
103
+ export interface IsolatedAgentResult<T> {
104
+ readonly baselineOid?: string;
105
+ readonly result: T;
106
+ readonly patch: string;
107
+ readonly changed: boolean;
108
+ }
109
+
110
+ export type WorkflowProgressEvent =
111
+ | { type: "counter"; key: string; label: string; value: number }
112
+ | { type: "counter_delta"; key: string; label: string; delta: number }
113
+ | {
114
+ type: "lane_item";
115
+ lane: string;
116
+ title: string;
117
+ subtitle?: string;
118
+ status: WorkflowLaneItemStatus;
119
+ details?: string;
120
+ }
121
+ | { type: "summary"; key: string; value: string | number };
122
+
123
+ export interface AgentOptions<S extends TSchema = TSchema> {
124
+ /** Label shown in the progress tree (e.g. "find:logic-bugs"). */
125
+ label?: string;
126
+ /** Phase to group this agent under in the progress tree. */
127
+ phase?: string;
128
+ /**
129
+ * Optional model id. Overrides profile routing; when both model and profile are
130
+ * omitted, inherit the host model. Explicit refs are strict: bare ids resolve as
131
+ * Anthropic shorthand; use "provider/id" for other providers.
132
+ */
133
+ model?: string;
134
+ /** Reasoning effort for this agent. Overrides the profile's configured effort. */
135
+ thinkingLevel?: ThinkingLevel;
136
+ /** Exact configured model route to use when model/thinkingLevel do not override it. */
137
+ profile?: WorkflowModelProfileName;
138
+ /**
139
+ * Stable identity hint for resume replay. Use this for repeated logical calls
140
+ * with identical prompts/options, e.g. `${stage}:${item.id}`.
141
+ */
142
+ cacheKey?: string;
143
+ /**
144
+ * Resume policy for this call. Shared-workspace agents run live unless they
145
+ * explicitly declare themselves read-only. Those calls bind replay to the full
146
+ * Git-visible workspace; isolated agents bind to their isolated baseline.
147
+ * Use "off" to disable both journal reads and writes.
148
+ */
149
+ resume?: AgentResumePolicy;
150
+ /**
151
+ * Additional ignored/generated inputs under the workflow cwd that this
152
+ * read-only agent may observe. Git-visible files across the repository are
153
+ * captured automatically; list cwd-relative ignored paths here or use
154
+ * `resume: "off"` when their bounded contents cannot be captured.
155
+ */
156
+ resumeInputs?: readonly string[];
157
+ /** Run in an isolated git worktree. Successful runs clean it up; failed runs retain it for recovery. */
158
+ isolation?: "worktree";
159
+ /** Apply a captured candidate to a fresh isolated baseline before evaluation. */
160
+ candidatePatch?: { readonly baselineOid: string; readonly patch: string };
161
+ /** Allowlist of concrete tool names the agent may use (e.g. ["read", "bash"]). */
162
+ tools?: string[];
163
+ /**
164
+ * Dynamically include installed tools matching semantic categories. "search"
165
+ * matches local grep/find/code-search tools; "external-search" matches installed
166
+ * web search, browsing, and URL extraction tools.
167
+ */
168
+ toolHints?: readonly AgentToolHint[];
169
+ /** Fail before prompting unless every semantic tool hint matched an installed tool. */
170
+ requireToolHints?: boolean;
171
+ /**
172
+ * Skill names to expose to this subagent. Subagents receive no skills by default.
173
+ * When omitted, clear prompt text such as `/skill:name`, `include skill name`, or
174
+ * `use the name skill` is treated as a natural-language opt-in. Pass [] to suppress
175
+ * prompt inference for prompts that mention skills only as subject matter.
176
+ */
177
+ skills?: readonly string[];
178
+ /**
179
+ * typebox schema. The agent must call the validated `final_answer` tool; bounded
180
+ * repair exhaustion throws `WorkflowStructuredOutputError` instead of returning null.
181
+ */
182
+ schema?: S;
183
+ }
184
+
185
+ /**
186
+ * The primitives injected into every workflow run. A workflow is any module that
187
+ * exports `meta` plus a default `async (api: WorkflowApi) => result`.
188
+ */
189
+ export interface WorkflowApi {
190
+ /** Run a schema subagent in an isolated worktree and return its structured result plus patch. */
191
+ agent<S extends TSchema>(
192
+ prompt: string,
193
+ opts: AgentOptions<S> & { schema: S; isolation: "worktree" },
194
+ ): Promise<IsolatedAgentResult<Static<S>>>;
195
+ /** Run a text subagent in an isolated worktree and return its final text plus patch. */
196
+ agent(prompt: string, opts: AgentOptions & { isolation: "worktree" }): Promise<IsolatedAgentResult<string>>;
197
+ /** Run a subagent and return validated structured output; rejects with a recoverable typed error on repair exhaustion. */
198
+ agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
199
+ /** Run a subagent and return its final assistant text. */
200
+ agent(prompt: string, opts?: AgentOptions): Promise<string>;
201
+ /**
202
+ * Run another registered workflow inline as a sub-step and return its result. The child shares
203
+ * this run's concurrency cap, abort signal, and perf sink. Nests one level only: calling
204
+ * `workflow()` from within a sub-workflow rejects. Rejects on unknown name — catch it to handle
205
+ * a missing sub-workflow gracefully.
206
+ */
207
+ workflow(ref: WorkflowRef, args?: string): Promise<unknown>;
208
+ /**
209
+ * Run every thunk concurrently and wait for all (a barrier). Recoverable thunk
210
+ * failures become null slots by default; settled mode retains serialisable errors.
211
+ */
212
+ parallel: WorkflowParallel;
213
+ /**
214
+ * Run each item through all stages independently — no barrier between stages.
215
+ * A recoverable stage failure drops that item to null and skips its later stages.
216
+ */
217
+ pipeline: Pipeline;
218
+ /** Start a new phase; subsequent agents group under it in the progress tree. */
219
+ phase(title: string): void;
220
+ /** Emit a progress line. */
221
+ log(message: string): void;
222
+ /** Emit a structured progress event for native workflow UI surfaces. */
223
+ progress(event: WorkflowProgressEvent): void;
224
+ /** Raw argument string passed after the workflow name. */
225
+ args: string;
226
+ /** Working directory of the host session (typically the repo root). */
227
+ cwd: string;
228
+ /**
229
+ * Token budget for the run. `budget.total` is the output-token ceiling (null when unset),
230
+ * `spent()`/`remaining()` are live. Guard loops with `while (budget.total && budget.remaining() > N)`;
231
+ * `agent()` throws `WorkflowBudgetExceededError` once the ceiling is reached.
232
+ */
233
+ budget: WorkflowBudget;
234
+ /** Abort signal for the run, propagated from the host. */
235
+ signal: AbortSignal | undefined;
236
+ }
237
+
238
+ export type WorkflowRun = (api: WorkflowApi) => Promise<unknown>;
239
+
240
+ export type WorkflowSourceIdentity =
241
+ | { readonly kind: "file"; readonly path: string; readonly root: string; readonly fingerprint: string }
242
+ | { readonly kind: "fingerprint"; readonly fingerprint: string }
243
+ | { readonly kind: "unverifiable"; readonly reason: string };
244
+
245
+ /** The authored exports of a workflow module. */
246
+ export interface WorkflowModule {
247
+ meta: WorkflowMeta;
248
+ default: WorkflowRun;
249
+ }
250
+
251
+ /** A validated workflow plus engine-owned provenance used for safe resume replay. */
252
+ export interface LoadedWorkflow extends WorkflowModule {
253
+ readonly source: WorkflowSourceIdentity;
254
+ readonly isolatedWorktreeBaseline?: WorktreeBaseline;
255
+ }
@@ -0,0 +1,28 @@
1
+ const ANSI_PATTERN = /\u001B\[[0-9;?]*[ -/]*[@-~]/g;
2
+ const CONTROL_PATTERN = /[\u0000-\u001F\u007F]/g;
3
+ const MULTILINE_CONTROL_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
4
+
5
+ /** Flattens arbitrary text into one stable, printable row. */
6
+ export function toDisplayLine(text: string, maxLength = 120): string {
7
+ const flat = text
8
+ .replace(ANSI_PATTERN, "")
9
+ .replace(CONTROL_PATTERN, " ")
10
+ .replace(/\s+/g, " ")
11
+ .trim();
12
+ if (maxLength <= 0 || flat.length <= maxLength) return flat;
13
+ return `${flat.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
14
+ }
15
+
16
+ /** Sanitizes chat content while preserving the line breaks and Markdown shown by Pi. */
17
+ export function toDisplayText(text: string, maxLength = 2_000): string {
18
+ const value = text
19
+ .replace(ANSI_PATTERN, "")
20
+ .replace(/\r\n?/g, "\n")
21
+ .replace(MULTILINE_CONTROL_PATTERN, " ")
22
+ .split("\n")
23
+ .map((line) => line.replace(/\t/g, " ").trimEnd())
24
+ .join("\n")
25
+ .trim();
26
+ if (maxLength <= 0 || value.length <= maxLength) return value;
27
+ return `${value.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
28
+ }