@jameslovespancakes/pi-plus 1.0.11 → 1.0.12

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 (120) hide show
  1. package/README.md +44 -20
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +6 -3
  4. package/src/core/accounts/oauth-pool.ts +102 -0
  5. package/src/core/accounts/registry.ts +8 -25
  6. package/src/core/accounts/routing.ts +122 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/quota.ts +8 -53
  9. package/src/core/anthropic/routing.ts +40 -115
  10. package/src/core/anthropic/store.ts +7 -9
  11. package/src/core/catalog/quality.ts +33 -15
  12. package/src/core/codex/quota.ts +8 -2
  13. package/src/core/codex/store.ts +12 -9
  14. package/src/core/config.ts +4 -17
  15. package/src/core/store.ts +13 -12
  16. package/src/domains/models/provider-picker.ts +1 -1
  17. package/src/domains/setup/index.ts +23 -23
  18. package/src/domains/subscriptions/accounts-picker.ts +18 -37
  19. package/src/domains/subscriptions/accounts.ts +23 -29
  20. package/src/domains/subscriptions/footer.ts +48 -29
  21. package/src/domains/subscriptions/index.ts +11 -27
  22. package/src/domains/subscriptions/provider.ts +38 -103
  23. package/src/domains/subscriptions/providers/anthropic.ts +5 -17
  24. package/src/domains/subscriptions/providers/codex.ts +171 -4
  25. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  26. package/src/domains/subscriptions/providers/oauth-pool.ts +273 -0
  27. package/src/domains/subscriptions/routing.ts +19 -11
  28. package/src/domains/workflows/LICENSE.md +21 -0
  29. package/src/domains/workflows/index.ts +836 -0
  30. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  31. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  32. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  33. package/src/domains/workflows/runtime/agent-attempt.ts +147 -0
  34. package/src/domains/workflows/runtime/agent-limits.ts +43 -0
  35. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  36. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  37. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  38. package/src/domains/workflows/runtime/agent-runner.ts +203 -0
  39. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  40. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  41. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  42. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  43. package/src/domains/workflows/runtime/agent-workspace.ts +103 -0
  44. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  45. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  46. package/src/domains/workflows/runtime/budget.ts +53 -0
  47. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  48. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  49. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  50. package/src/domains/workflows/runtime/debug.ts +3 -0
  51. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  52. package/src/domains/workflows/runtime/discovery.ts +137 -0
  53. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  54. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  55. package/src/domains/workflows/runtime/engine.ts +543 -0
  56. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  57. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  58. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  59. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  60. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  61. package/src/domains/workflows/runtime/journal.ts +313 -0
  62. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  63. package/src/domains/workflows/runtime/options.ts +157 -0
  64. package/src/domains/workflows/runtime/perf.ts +146 -0
  65. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  66. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  67. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  68. package/src/domains/workflows/runtime/progress.ts +400 -0
  69. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  70. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  71. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  72. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  73. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  74. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  75. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  76. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  77. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  78. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  79. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  80. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  81. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  82. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  83. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  84. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  85. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  86. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  87. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  88. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  89. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  90. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  91. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  92. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  93. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  94. package/src/domains/workflows/runtime/types.ts +255 -0
  95. package/src/domains/workflows/runtime/ui/display-text.ts +13 -0
  96. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  97. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  98. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +398 -0
  99. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  100. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  101. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  102. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  103. package/src/domains/workflows/runtime/usage.ts +341 -0
  104. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  105. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  106. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  107. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  108. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  109. package/src/domains/workflows/runtime/workflow-run-history.ts +162 -0
  110. package/src/domains/workflows/runtime/workflow-run-record.ts +635 -0
  111. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  112. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  113. package/src/domains/workflows/runtime/workflows.ts +40 -0
  114. package/src/domains/workflows/runtime/worktree.ts +581 -0
  115. package/src/domains/workflows/workflows/code-review.ts +232 -0
  116. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  117. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  118. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  119. package/src/domains/workflows/workflows/research.ts +169 -0
  120. package/src/services/usage-service.ts +6 -30
@@ -0,0 +1,53 @@
1
+ import type { WorkflowUsageSink } from "./usage.ts";
2
+
3
+ /**
4
+ * Thrown by `agent()` when a run has consumed its token budget. Carries the
5
+ * configured ceiling and the output tokens spent so callers can report both.
6
+ */
7
+ export class WorkflowBudgetExceededError extends Error {
8
+ constructor(
9
+ readonly total: number,
10
+ readonly spent: number,
11
+ ) {
12
+ super(`Workflow token budget exhausted: spent ${spent} output tokens of ${total}.`);
13
+ this.name = "WorkflowBudgetExceededError";
14
+ }
15
+ }
16
+
17
+ /**
18
+ * The budget handle exposed on `WorkflowApi`. Mirrors the built-in Workflow tool:
19
+ * `total` is the output-token ceiling (or `null` when unset), `spent()` is the
20
+ * output tokens consumed by recorded subagent attempts this run, and
21
+ * `remaining()` is the headroom left (`Infinity` when there is no ceiling).
22
+ *
23
+ * `spent()`/`remaining()` are LIVE — they re-read usage on every call so that
24
+ * `while (budget.total && budget.remaining() > N) { await agent(...) }` loops and
25
+ * `Math.floor(budget.total / N)` fleet-scaling see up-to-date numbers.
26
+ */
27
+ export interface WorkflowBudget {
28
+ readonly total: number | null;
29
+ /** Output tokens consumed by recorded subagent attempts this run (sub-workflows included). */
30
+ spent(): number;
31
+ /** `total - spent()` clamped at 0, or `Infinity` when `total` is null. */
32
+ remaining(): number;
33
+ }
34
+
35
+ /** Refuse a new model turn after the run has consumed its output-token budget. */
36
+ export function assertWorkflowBudgetAvailable(budget: WorkflowBudget): void {
37
+ if (budget.total !== null && budget.remaining() <= 0) {
38
+ throw new WorkflowBudgetExceededError(budget.total, budget.spent());
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Build a budget backed by the run's usage sink. `spent()` reads
44
+ * `usage.snapshot().totals.output` — the same output-token sum surfaced in the
45
+ * usage line — so no separate token accounting is needed.
46
+ */
47
+ export function createBudget(total: number | null, usage: WorkflowUsageSink): WorkflowBudget {
48
+ return {
49
+ total,
50
+ spent: () => usage.snapshot().totals.output,
51
+ remaining: () => (total === null ? Infinity : Math.max(0, total - usage.snapshot().totals.output)),
52
+ };
53
+ }
@@ -0,0 +1,87 @@
1
+ export class WorkflowAbortError extends Error {
2
+ constructor(message = "Workflow aborted") {
3
+ super(message);
4
+ this.name = "WorkflowAbortError";
5
+ }
6
+ }
7
+
8
+ /** Abort reason used when a durable run can be resumed after its host session shuts down. */
9
+ export class WorkflowPauseError extends WorkflowAbortError {
10
+ constructor(message = "Workflow paused because its host session shut down") {
11
+ super(message);
12
+ this.name = "WorkflowPauseError";
13
+ }
14
+ }
15
+
16
+ export function abortReason(signal: AbortSignal | undefined): Error {
17
+ const reason = signal?.reason;
18
+ if (safeInstanceOf(reason, Error)) return reason;
19
+ if (typeof reason === "string" && reason.length > 0) return new WorkflowAbortError(reason);
20
+ return new WorkflowAbortError();
21
+ }
22
+
23
+ export function throwIfAborted(signal: AbortSignal | undefined): void {
24
+ if (signal?.aborted) throw abortReason(signal);
25
+ }
26
+
27
+ export function isFatalWorkflowError(error: unknown, signal: AbortSignal | undefined): boolean {
28
+ if (signal?.aborted) return true;
29
+ if (safeInstanceOf(error, WorkflowAbortError)) return true;
30
+ if (typeof DOMException !== "undefined" && safeInstanceOf(error, DOMException)) {
31
+ try {
32
+ return error.name === "AbortError";
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+ return false;
38
+ }
39
+
40
+ export function isWorkflowPauseError(error: unknown): error is WorkflowPauseError {
41
+ return safeInstanceOf(error, WorkflowPauseError);
42
+ }
43
+
44
+ export function linkAbortSignal(parent: AbortSignal | undefined, controller: AbortController): () => void {
45
+ if (!parent) return () => {};
46
+ if (parent.aborted) {
47
+ controller.abort(abortReason(parent));
48
+ return () => {};
49
+ }
50
+ const onAbort = () => controller.abort(abortReason(parent));
51
+ parent.addEventListener("abort", onAbort, { once: true });
52
+ return () => parent.removeEventListener("abort", onAbort);
53
+ }
54
+
55
+ export async function raceWithAbort<T>(operation: () => Promise<T>, signal: AbortSignal | undefined): Promise<T> {
56
+ throwIfAborted(signal);
57
+ if (!signal) return await operation();
58
+
59
+ return await new Promise<T>((resolve, reject) => {
60
+ let settled = false;
61
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
62
+ const finish = (action: () => void) => {
63
+ if (settled) return;
64
+ settled = true;
65
+ cleanup();
66
+ action();
67
+ };
68
+ const onAbort = () => finish(() => reject(abortReason(signal)));
69
+ signal.addEventListener("abort", onAbort, { once: true });
70
+ if (signal.aborted) {
71
+ onAbort();
72
+ return;
73
+ }
74
+ void operation().then(
75
+ (value) => finish(() => resolve(value)),
76
+ (error: unknown) => finish(() => reject(error)),
77
+ );
78
+ });
79
+ }
80
+
81
+ function safeInstanceOf<T>(value: unknown, constructor: abstract new (...args: never[]) => T): value is T {
82
+ try {
83
+ return value instanceof constructor;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
@@ -0,0 +1,36 @@
1
+ import { fuzzyFilter, type AutocompleteItem } from "@earendil-works/pi-tui";
2
+
3
+ interface ArgumentCompletionCandidate {
4
+ readonly value: string;
5
+ readonly description?: string;
6
+ }
7
+
8
+ interface ArgumentPrefixParts {
9
+ readonly completed: readonly string[];
10
+ readonly current: string;
11
+ readonly base: string;
12
+ }
13
+
14
+ /** Split a slash-command argument prefix while retaining the text Pi will replace. */
15
+ export function splitArgumentPrefix(argumentPrefix: string): ArgumentPrefixParts {
16
+ const match = /(\S*)$/.exec(argumentPrefix);
17
+ const current = match?.[1] ?? "";
18
+ const base = argumentPrefix.slice(0, argumentPrefix.length - current.length);
19
+ const completed = base.trim().split(/\s+/).filter(Boolean);
20
+ return { completed, current, base };
21
+ }
22
+
23
+ /** Complete the current argument and return full replacement values, as Pi expects. */
24
+ export function completeCurrentArgument(
25
+ argumentPrefix: string,
26
+ candidates: readonly ArgumentCompletionCandidate[],
27
+ ): AutocompleteItem[] | null {
28
+ const { current, base } = splitArgumentPrefix(argumentPrefix);
29
+ const matches = fuzzyFilter([...candidates], current, (candidate) => candidate.value)
30
+ .map((candidate) => ({
31
+ value: `${base}${candidate.value}`,
32
+ label: candidate.value,
33
+ ...(candidate.description === undefined ? {} : { description: candidate.description }),
34
+ }));
35
+ return matches.length > 0 ? matches : null;
36
+ }
@@ -0,0 +1,403 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { abortReason, isFatalWorkflowError, throwIfAborted } from "./cancellation.ts";
3
+ import { unknownErrorMessage } from "./unknown-error.ts";
4
+
5
+ /** Global agent semaphore. A null capacity is unlimited. */
6
+ export class Semaphore {
7
+ private active = 0;
8
+ private readonly max: number | null;
9
+ private readonly waiters: Array<() => void> = [];
10
+
11
+ constructor(max: number | null) {
12
+ if (max !== null && (!Number.isInteger(max) || max < 1)) {
13
+ throw new RangeError("Semaphore capacity must be a positive integer or null");
14
+ }
15
+ this.max = max;
16
+ }
17
+
18
+ async run<T>(fn: () => Promise<T>, options: { onQueueWaitMs?: (durationMs: number) => void; signal?: AbortSignal } = {}): Promise<T> {
19
+ throwIfAborted(options.signal);
20
+ const queuedAt = performance.now();
21
+ if (this.max !== null && this.active >= this.max) {
22
+ await this.waitForSlot(options.signal);
23
+ } else {
24
+ this.active++;
25
+ }
26
+ try {
27
+ options.onQueueWaitMs?.(performance.now() - queuedAt);
28
+ throwIfAborted(options.signal);
29
+ return await fn();
30
+ } finally {
31
+ // Transfer the reservation directly to the next waiter.
32
+ const next = this.waiters.shift();
33
+ if (next) next();
34
+ else this.active--;
35
+ }
36
+ }
37
+
38
+ private async waitForSlot(signal: AbortSignal | undefined): Promise<void> {
39
+ await new Promise<void>((resolve, reject) => {
40
+ let settled = false;
41
+ const cleanup = () => {
42
+ if (signal) signal.removeEventListener("abort", onAbort);
43
+ };
44
+ const waiter = () => {
45
+ if (settled) return;
46
+ settled = true;
47
+ cleanup();
48
+ resolve();
49
+ };
50
+ const onAbort = () => {
51
+ if (settled) return;
52
+ settled = true;
53
+ const index = this.waiters.indexOf(waiter);
54
+ if (index !== -1) this.waiters.splice(index, 1);
55
+ cleanup();
56
+ reject(abortReason(signal));
57
+ };
58
+ if (signal?.aborted) {
59
+ reject(abortReason(signal));
60
+ return;
61
+ }
62
+ this.waiters.push(waiter);
63
+ signal?.addEventListener("abort", onAbort, { once: true });
64
+ });
65
+ }
66
+ }
67
+
68
+ export interface ParallelOptions {
69
+ readonly signal?: AbortSignal;
70
+ readonly abortController?: AbortController;
71
+ readonly limit?: number;
72
+ /** Maximum time to drain already-started tasks after a fatal failure. */
73
+ readonly drainTimeoutMs?: number;
74
+ }
75
+
76
+ export interface ParallelSettledOptions {
77
+ readonly settled: true;
78
+ }
79
+
80
+ export interface ParallelSettledError {
81
+ readonly name?: string;
82
+ readonly message: string;
83
+ readonly code?: string;
84
+ readonly details?: Readonly<Record<string, string | number | boolean | null>>;
85
+ }
86
+
87
+ export type ParallelSettledResult<T> =
88
+ | { readonly ok: true; readonly value: T }
89
+ | { readonly ok: false; readonly error: ParallelSettledError };
90
+
91
+ export interface WorkflowParallel {
92
+ <T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
93
+ <T>(thunks: Array<() => Promise<T>>, options: ParallelSettledOptions): Promise<Array<ParallelSettledResult<T>>>;
94
+ }
95
+
96
+ export interface PipelineOptions {
97
+ readonly signal?: AbortSignal;
98
+ readonly abortController?: AbortController;
99
+ /** Maximum time to drain already-started item chains after a fatal failure. */
100
+ readonly drainTimeoutMs?: number;
101
+ }
102
+
103
+ const DEFAULT_FATAL_DRAIN_TIMEOUT_MS = 1_000;
104
+
105
+ export function compactResults<T>(values: ReadonlyArray<T | null | undefined>): T[] {
106
+ return values.filter((value): value is T => value != null);
107
+ }
108
+
109
+ /** Run every thunk concurrently and wait for all results; recoverable failures become null slots by default. */
110
+ export function parallel<T>(
111
+ thunks: Array<() => Promise<T>>,
112
+ options: ParallelOptions & ParallelSettledOptions,
113
+ ): Promise<Array<ParallelSettledResult<T>>>;
114
+ export function parallel<T>(thunks: Array<() => Promise<T>>, options?: ParallelOptions): Promise<Array<T | null>>;
115
+ export async function parallel<T>(
116
+ thunks: Array<() => Promise<T>>,
117
+ options: ParallelOptions & Partial<ParallelSettledOptions> = {},
118
+ ): Promise<Array<T | null> | Array<ParallelSettledResult<T>>> {
119
+ if (options.settled) {
120
+ return await runParallel(
121
+ thunks,
122
+ options,
123
+ (value): ParallelSettledResult<T> => ({ ok: true, value }),
124
+ (error): ParallelSettledResult<T> => ({ ok: false, error: serializeParallelError(error) }),
125
+ );
126
+ }
127
+
128
+ return await runParallel(
129
+ thunks,
130
+ options,
131
+ (value): T | null => value,
132
+ (): T | null => null,
133
+ );
134
+ }
135
+
136
+ export function bindParallel(options: ParallelOptions): WorkflowParallel {
137
+ const executionOptions: ParallelOptions = {
138
+ signal: options.signal,
139
+ abortController: options.abortController,
140
+ limit: options.limit,
141
+ drainTimeoutMs: options.drainTimeoutMs,
142
+ };
143
+
144
+ function boundParallel<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
145
+ function boundParallel<T>(
146
+ thunks: Array<() => Promise<T>>,
147
+ workflowOptions: ParallelSettledOptions,
148
+ ): Promise<Array<ParallelSettledResult<T>>>;
149
+ async function boundParallel<T>(
150
+ thunks: Array<() => Promise<T>>,
151
+ workflowOptions?: ParallelSettledOptions,
152
+ ): Promise<Array<T | null> | Array<ParallelSettledResult<T>>> {
153
+ if (workflowOptions?.settled) {
154
+ return await parallel(thunks, { ...executionOptions, settled: true });
155
+ }
156
+ return await parallel(thunks, executionOptions);
157
+ }
158
+
159
+ return boundParallel;
160
+ }
161
+
162
+ async function runParallel<T, Result>(
163
+ thunks: Array<() => Promise<T>>,
164
+ options: ParallelOptions,
165
+ onSuccess: (value: T) => Result,
166
+ onFailure: (error: unknown) => Result,
167
+ ): Promise<Result[]> {
168
+ throwIfAborted(options.signal);
169
+ const results = Array.from<Result>({ length: thunks.length });
170
+ if (thunks.length === 0) return results;
171
+
172
+ const limit = normalizeLimit(options.limit, thunks.length);
173
+ return await new Promise<Result[]>((resolve, reject) => {
174
+ let terminal = false;
175
+ let nextIndex = 0;
176
+ let workersRemaining = limit;
177
+ let fatalFailure: { readonly error: unknown } | undefined;
178
+ let drainTimer: ReturnType<typeof setTimeout> | undefined;
179
+ const cleanup = () => {
180
+ options.signal?.removeEventListener("abort", onAbort);
181
+ if (drainTimer) clearTimeout(drainTimer);
182
+ };
183
+ const settle = (force = false) => {
184
+ if (terminal || (!force && workersRemaining > 0)) return;
185
+ terminal = true;
186
+ cleanup();
187
+ if (fatalFailure) reject(fatalFailure.error);
188
+ else resolve(results);
189
+ };
190
+ const recordFatalFailure = (error: unknown) => {
191
+ if (fatalFailure) return;
192
+ fatalFailure = { error };
193
+ options.abortController?.abort(error);
194
+ const timeoutMs = normalizeDrainTimeout(options.drainTimeoutMs);
195
+ drainTimer = setTimeout(() => settle(true), timeoutMs);
196
+ };
197
+ const completeWorker = () => {
198
+ workersRemaining--;
199
+ settle();
200
+ };
201
+ const onAbort = () => recordFatalFailure(abortReason(options.signal));
202
+ const runWorker = async () => {
203
+ while (true) {
204
+ if (fatalFailure) return;
205
+ throwIfAborted(options.signal);
206
+ const index = nextIndex++;
207
+ if (index >= thunks.length) return;
208
+ try {
209
+ const value = await thunks[index]();
210
+ if (fatalFailure) return;
211
+ results[index] = onSuccess(value);
212
+ } catch (error) {
213
+ if (fatalFailure) return;
214
+ if (isFatalWorkflowError(error, options.signal)) {
215
+ recordFatalFailure(error);
216
+ return;
217
+ }
218
+ results[index] = onFailure(error);
219
+ }
220
+ }
221
+ };
222
+
223
+ options.signal?.addEventListener("abort", onAbort, { once: true });
224
+ if (options.signal?.aborted) onAbort();
225
+ for (let worker = 0; worker < limit; worker++) {
226
+ void runWorker().then(completeWorker, (error) => {
227
+ recordFatalFailure(error);
228
+ completeWorker();
229
+ });
230
+ }
231
+ });
232
+ }
233
+
234
+ function serializeParallelError(error: unknown): ParallelSettledError {
235
+ const message = readStringProperty(error, "message") ?? unknownErrorMessage(error);
236
+ const name = readStringProperty(error, "name");
237
+ const code = readStringProperty(error, "code");
238
+ const details = readSerializableDetails(error);
239
+ return {
240
+ ...(name && name.length > 0 ? { name } : {}),
241
+ message,
242
+ ...(code && code.length > 0 ? { code } : {}),
243
+ ...(details ? { details } : {}),
244
+ };
245
+ }
246
+
247
+ function readStringProperty(value: unknown, property: "message" | "name" | "code"): string | undefined {
248
+ const candidate = readProperty(value, property);
249
+ return typeof candidate === "string" ? candidate : undefined;
250
+ }
251
+
252
+ function readSerializableDetails(
253
+ value: unknown,
254
+ ): Readonly<Record<string, string | number | boolean | null>> | undefined {
255
+ const candidate = readProperty(value, "details");
256
+ if (typeof candidate !== "object" || candidate === null) return undefined;
257
+ try {
258
+ const details: Record<string, string | number | boolean | null> = {};
259
+ for (const [key, detail] of Object.entries(candidate)) {
260
+ if (
261
+ typeof detail === "string" ||
262
+ typeof detail === "boolean" ||
263
+ detail === null ||
264
+ (typeof detail === "number" && Number.isFinite(detail))
265
+ ) {
266
+ details[key] = detail;
267
+ }
268
+ }
269
+ return Object.keys(details).length > 0 ? details : undefined;
270
+ } catch {
271
+ return undefined;
272
+ }
273
+ }
274
+
275
+ function readProperty(value: unknown, property: string): unknown {
276
+ if ((typeof value !== "object" && typeof value !== "function") || value === null) return undefined;
277
+ try {
278
+ return Reflect.get(value, property);
279
+ } catch {
280
+ return undefined;
281
+ }
282
+ }
283
+
284
+ function normalizeLimit(limit: number | undefined, itemCount: number): number {
285
+ if (limit === undefined || !Number.isFinite(limit)) return Math.max(1, itemCount);
286
+ return Math.max(1, Math.min(itemCount, Math.trunc(limit)));
287
+ }
288
+
289
+ function normalizeDrainTimeout(timeoutMs: number | undefined): number {
290
+ if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) return DEFAULT_FATAL_DRAIN_TIMEOUT_MS;
291
+ return Math.max(0, Math.trunc(timeoutMs));
292
+ }
293
+
294
+ /**
295
+ * Run each item through all stages independently. There is NO barrier between
296
+ * stages: item A can be in stage 3 while item B is still in stage 1. Actual agent
297
+ * concurrency is bounded by the run's Semaphore (inside each `agent()`), not here —
298
+ * so launching every item at once is safe.
299
+ *
300
+ * Each stage receives (previousResult, originalItem, index).
301
+ */
302
+ export async function pipeline<Item, A>(
303
+ items: readonly Item[],
304
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
305
+ ): Promise<Array<A | null>>;
306
+ export async function pipeline<Item, A, B>(
307
+ items: readonly Item[],
308
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
309
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
310
+ ): Promise<Array<B | null>>;
311
+ export async function pipeline<Item, A, B, C>(
312
+ items: readonly Item[],
313
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
314
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
315
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
316
+ ): Promise<Array<C | null>>;
317
+ export async function pipeline<Item, A, B, C, D>(
318
+ items: readonly Item[],
319
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
320
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
321
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
322
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
323
+ ): Promise<Array<D | null>>;
324
+ export async function pipeline<Item, A, B, C, D, E>(
325
+ items: readonly Item[],
326
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
327
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
328
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
329
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
330
+ stage5: (prev: D, item: Item, index: number) => Promise<E>,
331
+ ): Promise<Array<E | null>>;
332
+ export async function pipeline(
333
+ items: readonly unknown[],
334
+ ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
335
+ ): Promise<Array<unknown | null>>;
336
+ export async function pipeline(
337
+ items: readonly unknown[],
338
+ ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
339
+ ): Promise<Array<unknown | null>> {
340
+ return pipelineWithOptions(items, stages);
341
+ }
342
+
343
+ export function bindPipeline(options: PipelineOptions): Pipeline {
344
+ async function boundPipeline<Item, A>(
345
+ items: readonly Item[],
346
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
347
+ ): Promise<Array<A | null>>;
348
+ async function boundPipeline<Item, A, B>(
349
+ items: readonly Item[],
350
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
351
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
352
+ ): Promise<Array<B | null>>;
353
+ async function boundPipeline<Item, A, B, C>(
354
+ items: readonly Item[],
355
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
356
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
357
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
358
+ ): Promise<Array<C | null>>;
359
+ async function boundPipeline<Item, A, B, C, D>(
360
+ items: readonly Item[],
361
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
362
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
363
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
364
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
365
+ ): Promise<Array<D | null>>;
366
+ async function boundPipeline<Item, A, B, C, D, E>(
367
+ items: readonly Item[],
368
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
369
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
370
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
371
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
372
+ stage5: (prev: D, item: Item, index: number) => Promise<E>,
373
+ ): Promise<Array<E | null>>;
374
+ async function boundPipeline(
375
+ items: readonly unknown[],
376
+ ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
377
+ ): Promise<Array<unknown | null>> {
378
+ return pipelineWithOptions(items, stages, options);
379
+ }
380
+ return boundPipeline;
381
+ }
382
+
383
+ export async function pipelineWithOptions(
384
+ items: readonly unknown[],
385
+ stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>,
386
+ options: PipelineOptions = {},
387
+ ): Promise<Array<unknown | null>> {
388
+ throwIfAborted(options.signal);
389
+ return await parallel(
390
+ items.map((item, index) => async () => {
391
+ throwIfAborted(options.signal);
392
+ let acc: unknown = item;
393
+ for (const stage of stages) {
394
+ throwIfAborted(options.signal);
395
+ acc = await stage(acc, item, index);
396
+ }
397
+ return acc;
398
+ }),
399
+ options,
400
+ );
401
+ }
402
+
403
+ export type Pipeline = typeof pipeline;
@@ -0,0 +1,3 @@
1
+ export function workflowDebug(message: string): void {
2
+ if (process.env.PI_WORKFLOW_DEBUG === "1") process.stderr.write(`[workflow] ${message}\n`);
3
+ }
@@ -0,0 +1,81 @@
1
+ import type { PerfSink } from "./perf.ts";
2
+ import { runBoundedProcess, type BoundedProcessFailure } from "./process-runner.ts";
3
+ import { parseAllowedDiffCommand, reviewDiffCommand, type ReviewDiffTarget } from "./review-diff-target.ts";
4
+
5
+ export {
6
+ formatReviewDiffTarget,
7
+ GitReviewDiffTargetSchema,
8
+ isReviewDiffTarget,
9
+ parseAllowedDiffCommand,
10
+ PullRequestReviewDiffTargetSchema,
11
+ reviewDiffCommand,
12
+ ReviewDiffTargetSchema,
13
+ reviewGitDiffBaseline,
14
+ type GitDiffBaselineTarget,
15
+ type GitReviewDiffTarget,
16
+ type PullRequestReviewDiffTarget,
17
+ type ReviewDiffTarget,
18
+ } from "./review-diff-target.ts";
19
+
20
+ interface DiffCaptureResultBase {
21
+ readonly stdout: string;
22
+ readonly durationMs: number;
23
+ readonly bytes: number;
24
+ }
25
+
26
+ export type DiffCaptureResult =
27
+ | (DiffCaptureResultBase & { readonly ok: true; readonly error?: undefined; readonly failure?: undefined })
28
+ | (DiffCaptureResultBase & { readonly ok: false; readonly error: string; readonly failure: DiffCaptureFailure });
29
+
30
+ export type DiffCaptureFailure = BoundedProcessFailure | { readonly kind: "invalid-target"; readonly message: string };
31
+
32
+ export interface DiffCaptureOptions {
33
+ readonly cwd: string;
34
+ readonly signal?: AbortSignal;
35
+ readonly timeoutMs: number;
36
+ readonly maxBufferBytes: number;
37
+ readonly perf?: PerfSink;
38
+ readonly env?: NodeJS.ProcessEnv;
39
+ readonly killGraceMs?: number;
40
+ }
41
+
42
+ export async function captureDiff(command: string, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
43
+ const parsed = parseAllowedDiffCommand(command);
44
+ if ("error" in parsed) {
45
+ return {
46
+ ok: false,
47
+ stdout: "",
48
+ durationMs: 0,
49
+ bytes: 0,
50
+ error: parsed.error,
51
+ failure: { kind: "invalid-target", message: parsed.error },
52
+ };
53
+ }
54
+
55
+ return await captureDiffTarget(parsed, options);
56
+ }
57
+
58
+ export async function captureDiffTarget(target: ReviewDiffTarget, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
59
+ const command = reviewDiffCommand(target);
60
+ const result = await runBoundedProcess({
61
+ file: command.file,
62
+ args: command.args,
63
+ cwd: options.cwd,
64
+ env: diffCaptureEnv(options.env),
65
+ signal: options.signal,
66
+ timeoutMs: options.timeoutMs,
67
+ maxBufferBytes: options.maxBufferBytes,
68
+ killGraceMs: options.killGraceMs,
69
+ abortError: "diff capture aborted",
70
+ timeoutError: `diff capture timed out after ${options.timeoutMs}ms`,
71
+ maxBufferError: `diff capture exceeded ${options.maxBufferBytes} bytes`,
72
+ exitError: (stderr, code, signal) => stderr.trim() || `diff command exited with code ${code ?? `signal ${signal ?? "unknown"}`}`,
73
+ });
74
+ options.perf?.observe("diff.capture_ms", result.durationMs);
75
+ options.perf?.observe("diff.bytes", result.bytes);
76
+ return result;
77
+ }
78
+
79
+ function diffCaptureEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
80
+ return { ...(env ?? process.env), GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" };
81
+ }