pi-workflow-engine 0.8.0 → 0.10.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.
@@ -0,0 +1,96 @@
1
+ import { spawn } from "node:child_process";
2
+ import { performance } from "node:perf_hooks";
3
+
4
+ export interface BoundedProcessOptions {
5
+ readonly file: string;
6
+ readonly args: readonly string[];
7
+ readonly cwd: string;
8
+ readonly env?: NodeJS.ProcessEnv;
9
+ readonly signal?: AbortSignal;
10
+ readonly timeoutMs: number;
11
+ readonly maxBufferBytes?: number;
12
+ readonly killGraceMs?: number;
13
+ readonly abortError: string;
14
+ readonly timeoutError: string;
15
+ readonly maxBufferError?: string;
16
+ readonly exitError: (stderr: string, code: number | null, signal: NodeJS.Signals | null) => string;
17
+ }
18
+
19
+ export interface BoundedProcessResult {
20
+ readonly ok: boolean;
21
+ readonly stdout: string;
22
+ readonly stderr: string;
23
+ readonly durationMs: number;
24
+ readonly bytes: number;
25
+ readonly error?: string;
26
+ }
27
+
28
+ export async function runBoundedProcess(options: BoundedProcessOptions): Promise<BoundedProcessResult> {
29
+ const start = performance.now();
30
+ let stdout = "";
31
+ let stderr = "";
32
+ let bytes = 0;
33
+ let error: string | undefined;
34
+ const child = spawn(options.file, [...options.args], {
35
+ cwd: options.cwd,
36
+ env: options.env ?? process.env,
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ });
39
+
40
+ return await new Promise<BoundedProcessResult>((resolve) => {
41
+ let settled = false;
42
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
43
+ const killGraceMs = Math.max(1, options.killGraceMs ?? 100);
44
+ const finish = (ok: boolean, finishError?: string) => {
45
+ if (settled) return;
46
+ settled = true;
47
+ cleanup();
48
+ resolve({ ok, stdout, stderr, durationMs: performance.now() - start, bytes, error: finishError });
49
+ };
50
+ const kill = (message: string) => {
51
+ error = error ?? message;
52
+ child.kill("SIGTERM");
53
+ forceKillTimer ??= setTimeout(() => {
54
+ child.kill("SIGKILL");
55
+ finish(false, error);
56
+ }, killGraceMs);
57
+ };
58
+ const onAbort = () => kill(options.abortError);
59
+ const timeout = setTimeout(() => kill(options.timeoutError), options.timeoutMs);
60
+ const cleanup = () => {
61
+ clearTimeout(timeout);
62
+ if (forceKillTimer) clearTimeout(forceKillTimer);
63
+ options.signal?.removeEventListener("abort", onAbort);
64
+ };
65
+
66
+ if (options.signal?.aborted) {
67
+ kill(options.abortError);
68
+ } else {
69
+ options.signal?.addEventListener("abort", onAbort, { once: true });
70
+ }
71
+
72
+ child.stdout?.on("data", (chunk: Buffer) => {
73
+ bytes += chunk.length;
74
+ if (options.maxBufferBytes !== undefined && bytes > options.maxBufferBytes) {
75
+ kill(options.maxBufferError ?? `process output exceeded ${options.maxBufferBytes} bytes`);
76
+ return;
77
+ }
78
+ stdout += chunk.toString("utf8");
79
+ });
80
+ child.stderr?.on("data", (chunk: Buffer) => {
81
+ stderr += chunk.toString("utf8");
82
+ });
83
+ child.on("error", (spawnError) => finish(false, spawnError.message));
84
+ child.on("close", (code, signal) => {
85
+ if (error) {
86
+ finish(false, error);
87
+ return;
88
+ }
89
+ if (code === 0) {
90
+ finish(true);
91
+ return;
92
+ }
93
+ finish(false, options.exitError(stderr, code, signal));
94
+ });
95
+ });
96
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Static, TSchema } from "typebox";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
+ import type { WorkflowBudget } from "./budget.ts";
3
4
  import type { Pipeline } from "./concurrency.ts";
4
5
  import type { PerfSink, PerfSnapshot } from "./perf.ts";
5
6
  import type { WorkflowProgressSnapshot } from "./progress.ts";
@@ -23,11 +24,23 @@ export interface WorkflowRunStats {
23
24
  [key: string]: string | number;
24
25
  }
25
26
 
27
+ export interface WorkflowRunMetadata {
28
+ readonly runId: string;
29
+ readonly resumedFromRunId?: string;
30
+ readonly journalPath: string;
31
+ }
32
+
26
33
  export interface WorkflowRunOptions {
27
34
  inspect?: boolean;
28
35
  perf?: boolean;
29
36
  concurrency?: number;
30
37
  parallelSubmissionLimit?: number;
38
+ /** Output-token ceiling for the whole run. Omit for no limit (budget.total === null). */
39
+ budget?: number;
40
+ /** Internal/test override for the generated run id. Omit to generate a new id. */
41
+ runId?: string;
42
+ /** Replay completed agent results from this prior run id when prompt/options hashes still match. */
43
+ resumeFromRunId?: string;
31
44
  resultViewer?: "open" | "skip";
32
45
  /** Additional abort signal to compose with the host context signal. */
33
46
  signal?: AbortSignal;
@@ -39,6 +52,8 @@ export interface WorkflowRunOptions {
39
52
  onPerfSnapshot?: (snapshot: PerfSnapshot) => void;
40
53
  /** Called with the final workflow subagent usage snapshot. */
41
54
  onUsageSnapshot?: (snapshot: WorkflowUsageSnapshot) => void;
55
+ /** Called once run identity and journal paths are known. */
56
+ onRunMetadata?: (metadata: WorkflowRunMetadata) => void;
42
57
  /** Called with the live progress source while a workflow is running, then undefined when it ends. */
43
58
  onProgressSource?: (source: WorkflowProgressSource | undefined) => void;
44
59
  /** Called with the final completed progress snapshot after live workflow UI teardown. */
@@ -52,6 +67,14 @@ export interface WorkflowProgressSource {
52
67
  /** Options for a single `agent()` call. */
53
68
  export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
54
69
 
70
+ export type AgentToolHint = "search";
71
+
72
+ export interface IsolatedAgentResult<T> {
73
+ readonly result: T;
74
+ readonly patch: string;
75
+ readonly changed: boolean;
76
+ }
77
+
55
78
  export type WorkflowProgressEvent =
56
79
  | { type: "counter"; key: string; label: string; value: number }
57
80
  | { type: "counter_delta"; key: string; label: string; delta: number }
@@ -77,8 +100,28 @@ export interface AgentOptions<S extends TSchema = TSchema> {
77
100
  model?: string;
78
101
  /** Reasoning effort for this agent. */
79
102
  thinkingLevel?: ThinkingLevel;
80
- /** Allowlist of built-in tool names the agent may use (e.g. ["read", "bash"]). */
103
+ /**
104
+ * Stable identity hint for resume replay. Use this for repeated logical calls
105
+ * with identical prompts/options, e.g. `${stage}:${item.id}`.
106
+ */
107
+ cacheKey?: string;
108
+ /** Run this agent in a disposable git worktree and return its patch with the result. */
109
+ isolation?: "worktree";
110
+ /** Allowlist of concrete tool names the agent may use (e.g. ["read", "bash"]). */
81
111
  tools?: string[];
112
+ /**
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.
116
+ */
117
+ toolHints?: readonly AgentToolHint[];
118
+ /**
119
+ * Skill names to expose to this subagent. Subagents receive no skills by default.
120
+ * When omitted, clear prompt text such as `/skill:name`, `include skill name`, or
121
+ * `use the name skill` is treated as a natural-language opt-in. Pass [] to suppress
122
+ * prompt inference for prompts that mention skills only as subject matter.
123
+ */
124
+ skills?: readonly string[];
82
125
  /** typebox schema. When set, the agent is forced to return matching structured data. */
83
126
  schema?: S;
84
127
  }
@@ -88,6 +131,13 @@ export interface AgentOptions<S extends TSchema = TSchema> {
88
131
  * exports `meta` plus a default `async (api: WorkflowApi) => result`.
89
132
  */
90
133
  export interface WorkflowApi {
134
+ /** Run a schema subagent in a disposable worktree and return its structured result plus patch. */
135
+ agent<S extends TSchema>(
136
+ prompt: string,
137
+ opts: AgentOptions<S> & { schema: S; isolation: "worktree" },
138
+ ): Promise<IsolatedAgentResult<Static<S> | null>>;
139
+ /** Run a text subagent in a disposable worktree and return its final text plus patch. */
140
+ agent(prompt: string, opts: AgentOptions & { isolation: "worktree" }): Promise<IsolatedAgentResult<string>>;
91
141
  /** Run a subagent and return its validated structured output (null if it never produced one). */
92
142
  agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S> | null>;
93
143
  /** Run a subagent and return its final assistant text. */
@@ -99,9 +149,15 @@ export interface WorkflowApi {
99
149
  * a missing sub-workflow gracefully.
100
150
  */
101
151
  workflow(ref: WorkflowRef, args?: string): Promise<unknown>;
102
- /** Run every thunk concurrently and wait for all (a barrier). */
103
- parallel<T>(thunks: Array<() => Promise<T>>): Promise<T[]>;
104
- /** Run each item through all stages independently — no barrier between stages. */
152
+ /**
153
+ * Run every thunk concurrently and wait for all (a barrier). Recoverable thunk
154
+ * failures become null slots; filter nulls before consuming survivors.
155
+ */
156
+ parallel<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
157
+ /**
158
+ * Run each item through all stages independently — no barrier between stages.
159
+ * A recoverable stage failure drops that item to null and skips its later stages.
160
+ */
105
161
  pipeline: Pipeline;
106
162
  /** Start a new phase; subsequent agents group under it in the progress tree. */
107
163
  phase(title: string): void;
@@ -113,6 +169,12 @@ export interface WorkflowApi {
113
169
  args: string;
114
170
  /** Working directory of the host session (typically the repo root). */
115
171
  cwd: string;
172
+ /**
173
+ * Token budget for the run. `budget.total` is the output-token ceiling (null when unset),
174
+ * `spent()`/`remaining()` are live. Guard loops with `while (budget.total && budget.remaining() > N)`;
175
+ * `agent()` throws `WorkflowBudgetExceededError` once the ceiling is reached.
176
+ */
177
+ budget: WorkflowBudget;
116
178
  /** Abort signal for the run, propagated from the host. */
117
179
  signal: AbortSignal | undefined;
118
180
  }
@@ -19,6 +19,8 @@ export interface WorkflowResultEnvelope {
19
19
  completedAt: number;
20
20
  usage?: WorkflowUsageSnapshot;
21
21
  perf?: WorkflowPerfDetails;
22
+ runId?: string;
23
+ resumedFromRunId?: string;
22
24
  }
23
25
 
24
26
  export interface AdvisoryWorkflowResult extends AdvisoryReport {
@@ -61,27 +63,61 @@ export function isAdvisoryReport(value: unknown): value is AdvisoryWorkflowResul
61
63
  return true;
62
64
  }
63
65
 
64
- export function renderWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme, usage?: unknown): Component {
66
+ export interface WorkflowRunDisplayMetadata {
67
+ readonly runId?: string;
68
+ readonly resumedFromRunId?: string;
69
+ }
70
+
71
+ export interface WorkflowDetailLineInput {
72
+ readonly usage?: unknown;
73
+ readonly perf?: WorkflowPerfDetails;
74
+ readonly metadata?: WorkflowRunDisplayMetadata;
75
+ }
76
+
77
+ export function renderWorkflowResult(
78
+ name: string,
79
+ result: unknown,
80
+ expanded: boolean,
81
+ theme: Theme,
82
+ usage?: unknown,
83
+ metadata?: WorkflowRunDisplayMetadata,
84
+ perf?: WorkflowPerfDetails,
85
+ ): Component {
65
86
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
66
- box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme, usage), 0, 0));
87
+ box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme, usage, metadata, perf), 0, 0));
67
88
  return box;
68
89
  }
69
90
 
70
- export function renderWorkflowResultText(name: string, result: unknown, expanded: boolean, theme: Theme, usage?: unknown): string {
91
+ export function renderWorkflowResultText(
92
+ name: string,
93
+ result: unknown,
94
+ expanded: boolean,
95
+ theme: Theme,
96
+ usage?: unknown,
97
+ metadata?: WorkflowRunDisplayMetadata,
98
+ perf?: WorkflowPerfDetails,
99
+ ): string {
71
100
  if (isAdvisoryReport(result)) {
72
- return renderAdvisoryResult(name, result, expanded, theme, usage);
101
+ return renderAdvisoryResult(name, result, expanded, theme, usage, metadata, perf);
73
102
  }
74
- return renderGenericWorkflowResult(name, result, expanded, theme, usage);
75
- }
76
-
77
- function renderAdvisoryResult(name: string, result: AdvisoryWorkflowResult, expanded: boolean, theme: Theme, usage?: unknown): string {
103
+ return renderGenericWorkflowResult(name, result, expanded, theme, usage, metadata, perf);
104
+ }
105
+
106
+ function renderAdvisoryResult(
107
+ name: string,
108
+ result: AdvisoryWorkflowResult,
109
+ expanded: boolean,
110
+ theme: Theme,
111
+ usage?: unknown,
112
+ metadata?: WorkflowRunDisplayMetadata,
113
+ perf?: WorkflowPerfDetails,
114
+ ): string {
78
115
  const icon = theme.fg("success", "✓");
79
116
  const title = theme.fg("accent", theme.bold(`Workflow: ${name}`));
80
117
  const lines = [`${icon} ${title}`, theme.fg("muted", result.summary)];
81
118
  const stats = statsLine(result.stats, theme);
82
119
  if (stats) lines.push(stats);
83
- const usageLine = formatWorkflowUsageLine(usage);
84
- if (usageLine) lines.push(theme.fg("dim", usageLine));
120
+ pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
85
121
 
86
122
  if (result.findings.length === 0) {
87
123
  lines.push(theme.fg("success", "No findings."));
@@ -108,17 +144,49 @@ function renderNextSteps(nextSteps: string[], lines: string[], theme: Theme): vo
108
144
  }
109
145
  }
110
146
 
111
- function renderGenericWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme, usage?: unknown): string {
147
+ function renderGenericWorkflowResult(
148
+ name: string,
149
+ result: unknown,
150
+ expanded: boolean,
151
+ theme: Theme,
152
+ usage?: unknown,
153
+ metadata?: WorkflowRunDisplayMetadata,
154
+ perf?: WorkflowPerfDetails,
155
+ ): string {
112
156
  const lines = [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(`Workflow: ${name}`))}`];
113
157
  const summary = extractSummary(result);
114
158
  if (summary) lines.push(theme.fg("muted", summary));
115
- const usageLine = formatWorkflowUsageLine(usage);
116
- if (usageLine) lines.push(theme.fg("dim", usageLine));
159
+ pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
117
160
  if (expanded) lines.push(theme.fg("dim", safeJson(result)));
118
161
  else if (!summary) lines.push(theme.fg("dim", "Result available in expanded view."));
119
162
  return lines.join("\n");
120
163
  }
121
164
 
165
+ function pushWorkflowDetailLines(lines: string[], theme: Theme, input: WorkflowDetailLineInput): void {
166
+ for (const line of formatWorkflowDetailLines(input)) {
167
+ lines.push(theme.fg("dim", line));
168
+ }
169
+ }
170
+
171
+ export function formatWorkflowDetailLines(input: WorkflowDetailLineInput): string[] {
172
+ return [
173
+ formatWorkflowRunLine(input.metadata),
174
+ formatWorkflowUsageLine(input.usage),
175
+ formatWorkflowPerfLine(input.perf),
176
+ ].filter((line): line is string => line !== undefined);
177
+ }
178
+
179
+ export function formatWorkflowRunLine(metadata: WorkflowRunDisplayMetadata | undefined): string | undefined {
180
+ if (!metadata?.runId) return undefined;
181
+ return metadata.resumedFromRunId ? `Run: ${metadata.runId} (resumed from ${metadata.resumedFromRunId})` : `Run: ${metadata.runId}`;
182
+ }
183
+
184
+ export function formatWorkflowPerfLine(perf: WorkflowPerfDetails | undefined): string | undefined {
185
+ if (!perf) return undefined;
186
+ const parts = perf.aggregates.slice(0, 4).map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
187
+ return parts.length > 0 ? `Perf: ${parts.join(" · ")}` : "Perf: no samples";
188
+ }
189
+
122
190
  function statsLine(stats: Record<string, string | number> | undefined, theme: Theme): string | undefined {
123
191
  if (!stats) return undefined;
124
192
  const ordered = ["files", "candidates", "dropped", "verified", "kept"];
@@ -1,4 +1,5 @@
1
1
  import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type AdvisoryFinding, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
2
+ import { compactResults } from "./concurrency.ts";
2
3
  import type { AgentOptions, WorkflowApi, WorkflowProgressEvent } from "./types.ts";
3
4
 
4
5
  export interface AdvisoryLens {
@@ -20,7 +21,11 @@ export interface LensVerificationPipelineResult<Verified> {
20
21
  refuted: number;
21
22
  }
22
23
 
23
- const DEFAULT_ADVISORY_TOOLS = ["read", "bash"];
24
+ /** Default concrete read/inspect tools for advisory workflows. */
25
+ export const DEFAULT_ADVISORY_TOOLS: NonNullable<AgentOptions["tools"]> = ["read", "bash", "grep", "find", "ls"];
26
+
27
+ /** Dynamically include installed grep/find/search-like extension tools. */
28
+ export const DEFAULT_ADVISORY_TOOL_HINTS: NonNullable<AgentOptions["toolHints"]> = ["search"];
24
29
 
25
30
  export type AdvisorySchedulingMode = "pipeline" | "finder-barrier";
26
31
 
@@ -29,6 +34,7 @@ export interface LensVerificationPipelineOptions<Lens extends AdvisoryLens, Veri
29
34
  lenses: readonly Lens[];
30
35
  perLens: number;
31
36
  tools?: AgentOptions["tools"];
37
+ toolHints?: AgentOptions["toolHints"];
32
38
  finderPhase?: string;
33
39
  verifierPhase?: string;
34
40
  schedulingMode?: AdvisorySchedulingMode;
@@ -60,6 +66,7 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
60
66
  lenses,
61
67
  perLens,
62
68
  tools = DEFAULT_ADVISORY_TOOLS,
69
+ toolHints = DEFAULT_ADVISORY_TOOL_HINTS,
63
70
  finderPhase = "Find",
64
71
  verifierPhase = "Verify",
65
72
  schedulingMode = "pipeline",
@@ -77,6 +84,7 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
77
84
  phase: finderPhase,
78
85
  label: `find:${lens.label}`,
79
86
  tools,
87
+ toolHints,
80
88
  thinkingLevel: "low",
81
89
  schema: AdvisoryCandidatesSchema,
82
90
  });
@@ -113,6 +121,7 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
113
121
  phase: verifierPhase,
114
122
  label: `verify:${location.file.split("/").pop() ?? location.file}`,
115
123
  tools,
124
+ toolHints,
116
125
  thinkingLevel: "low",
117
126
  schema: AdvisoryVerdictSchema,
118
127
  });
@@ -125,9 +134,9 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
125
134
 
126
135
  if (schedulingMode === "finder-barrier") {
127
136
  const found = await api.parallel(lenses.map((lens) => async () => findForLens(lens)));
128
- const novel = found.flatMap(dedupeFound);
137
+ const novel = compactResults(found).flatMap(dedupeFound);
129
138
  const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
130
- return { verified: verdicts.filter((value): value is Verified => value !== null), rawCandidates, dropped, refuted };
139
+ return { verified: compactResults(verdicts), rawCandidates, dropped, refuted };
131
140
  }
132
141
 
133
142
  const perLensVerified = await api.pipeline(
@@ -136,11 +145,11 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
136
145
  async (found) => {
137
146
  const novel = dedupeFound(found);
138
147
  const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
139
- return verdicts.filter((value): value is Verified => value !== null);
148
+ return compactResults(verdicts);
140
149
  },
141
150
  );
142
151
 
143
- return { verified: perLensVerified.flat(), rawCandidates, dropped, refuted };
152
+ return { verified: compactResults(perLensVerified).flat(), rawCandidates, dropped, refuted };
144
153
  }
145
154
 
146
155
  function candidatesNovelToRun(candidates: readonly AdvisoryCandidate[], seen: Set<string>): AdvisoryCandidate[] {
@@ -0,0 +1,238 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { runBoundedProcess } from "./process-runner.ts";
5
+
6
+ export interface WorktreeGitCommandOptions {
7
+ readonly cwd: string;
8
+ readonly args: readonly string[];
9
+ readonly signal?: AbortSignal;
10
+ readonly timeoutMs: number;
11
+ readonly maxBufferBytes?: number;
12
+ }
13
+
14
+ export interface WorktreeGitCommandResult {
15
+ readonly ok: boolean;
16
+ readonly stdout: string;
17
+ readonly stderr: string;
18
+ readonly error?: string;
19
+ }
20
+
21
+ export interface WorktreeGitRunner {
22
+ runGit(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult>;
23
+ }
24
+
25
+ export interface WorktreeRef {
26
+ readonly path: string;
27
+ }
28
+
29
+ export interface WorktreeAddFailure {
30
+ readonly path: string;
31
+ readonly error: string;
32
+ readonly cleanup?: WorktreeGitCommandResult;
33
+ }
34
+
35
+ export interface GitWorktreeProbe {
36
+ readonly ok: boolean;
37
+ readonly inside: boolean;
38
+ readonly error?: string;
39
+ }
40
+
41
+ export interface WorktreeRemovalOutcome extends WorktreeGitCommandResult {
42
+ readonly path: string;
43
+ }
44
+
45
+ export interface WorktreePatch {
46
+ readonly patch: string;
47
+ readonly changed: boolean;
48
+ }
49
+
50
+ export interface WorktreeRegistryOptions {
51
+ readonly repoCwd: string;
52
+ readonly runner?: WorktreeGitRunner;
53
+ readonly patchCapture?: WorktreePatchCapture;
54
+ readonly timeoutMs?: number;
55
+ }
56
+
57
+ export type WorktreePatchCapture = (options: {
58
+ readonly worktreePath: string;
59
+ readonly runner?: WorktreeGitRunner;
60
+ readonly signal?: AbortSignal;
61
+ readonly timeoutMs?: number;
62
+ }) => Promise<WorktreePatch | { readonly error: string }>;
63
+
64
+ const DEFAULT_WORKTREE_TIMEOUT_MS = 30_000;
65
+ const WORKTREE_DIFF_MAX_BYTES = 16 << 20;
66
+ let pathCounter = 0;
67
+
68
+ export class WorktreeRegistry {
69
+ private readonly paths = new Set<string>();
70
+ private readonly runner: WorktreeGitRunner;
71
+ private readonly patchCapture: WorktreePatchCapture;
72
+ private readonly timeoutMs: number;
73
+
74
+ constructor(private readonly repoCwd: string, options: Omit<WorktreeRegistryOptions, "repoCwd"> = {}) {
75
+ this.runner = options.runner ?? spawnGitRunner;
76
+ this.patchCapture = options.patchCapture ?? captureWorktreePatch;
77
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
78
+ }
79
+
80
+ get size(): number {
81
+ return this.paths.size;
82
+ }
83
+
84
+ register(path: string): void {
85
+ this.paths.add(path);
86
+ }
87
+
88
+ async probe(signal?: AbortSignal): Promise<GitWorktreeProbe> {
89
+ return await isGitWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
90
+ }
91
+
92
+ async add(signal?: AbortSignal): Promise<WorktreeRef | WorktreeAddFailure> {
93
+ const added = await addWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
94
+ this.register(added.path);
95
+ if ("error" in added) {
96
+ const cleanup = await this.remove(added.path);
97
+ return { ...added, cleanup };
98
+ }
99
+ return added;
100
+ }
101
+
102
+ async capturePatch(path: string, signal?: AbortSignal): Promise<WorktreePatch | { readonly error: string }> {
103
+ return await this.patchCapture({ worktreePath: path, runner: this.runner, signal, timeoutMs: this.timeoutMs });
104
+ }
105
+
106
+ async remove(path: string): Promise<WorktreeGitCommandResult> {
107
+ const result = await removeWorktree({ repoCwd: this.repoCwd, path, runner: this.runner, timeoutMs: this.timeoutMs }).catch((error: unknown) => ({
108
+ ok: false,
109
+ stdout: "",
110
+ stderr: "",
111
+ error: formatError(error),
112
+ }));
113
+ if (result.ok) this.paths.delete(path);
114
+ return result;
115
+ }
116
+
117
+ async removeAll(): Promise<readonly WorktreeRemovalOutcome[]> {
118
+ return await Promise.all(
119
+ [...this.paths].map(async (path) => ({
120
+ path,
121
+ ...(await this.remove(path)),
122
+ })),
123
+ );
124
+ }
125
+ }
126
+
127
+ export function createWorktreePath(baseDir = tmpdir()): string {
128
+ pathCounter += 1;
129
+ return join(baseDir, `pi-workflow-${process.pid}-${pathCounter}-${randomUUID()}`);
130
+ }
131
+
132
+ export async function isGitWorktree(options: {
133
+ readonly repoCwd: string;
134
+ readonly runner?: WorktreeGitRunner;
135
+ readonly signal?: AbortSignal;
136
+ readonly timeoutMs?: number;
137
+ }): Promise<GitWorktreeProbe> {
138
+ const result = await (options.runner ?? spawnGitRunner)
139
+ .runGit({
140
+ cwd: options.repoCwd,
141
+ args: ["rev-parse", "--is-inside-work-tree"],
142
+ signal: options.signal,
143
+ timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
144
+ })
145
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
146
+ if (!result.ok) return { ok: false, inside: false, error: result.error ?? (result.stderr.trim() || "git worktree probe failed") };
147
+ return { ok: true, inside: result.stdout.trim() === "true" };
148
+ }
149
+
150
+ export async function addWorktree(options: {
151
+ readonly repoCwd: string;
152
+ readonly runner?: WorktreeGitRunner;
153
+ readonly signal?: AbortSignal;
154
+ readonly timeoutMs?: number;
155
+ readonly baseDir?: string;
156
+ }): Promise<WorktreeRef | WorktreeAddFailure> {
157
+ const path = createWorktreePath(options.baseDir);
158
+ const result = await (options.runner ?? spawnGitRunner)
159
+ .runGit({
160
+ cwd: options.repoCwd,
161
+ args: ["worktree", "add", "--detach", path, "HEAD"],
162
+ signal: options.signal,
163
+ timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
164
+ })
165
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
166
+ if (!result.ok) return { path, error: result.error ?? (result.stderr.trim() || "git worktree add failed") };
167
+ return { path };
168
+ }
169
+
170
+ export async function removeWorktree(options: {
171
+ readonly repoCwd: string;
172
+ readonly path: string;
173
+ readonly runner?: WorktreeGitRunner;
174
+ readonly timeoutMs?: number;
175
+ }): Promise<WorktreeGitCommandResult> {
176
+ return await (options.runner ?? spawnGitRunner).runGit({
177
+ cwd: options.repoCwd,
178
+ args: ["worktree", "remove", "--force", options.path],
179
+ timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
180
+ });
181
+ }
182
+
183
+ export async function captureWorktreePatch(options: {
184
+ readonly worktreePath: string;
185
+ readonly runner?: WorktreeGitRunner;
186
+ readonly signal?: AbortSignal;
187
+ readonly timeoutMs?: number;
188
+ }): Promise<WorktreePatch | { readonly error: string }> {
189
+ const runner = options.runner ?? spawnGitRunner;
190
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
191
+ const intentToAdd = await runner
192
+ .runGit({
193
+ cwd: options.worktreePath,
194
+ args: ["add", "-N", "."],
195
+ signal: options.signal,
196
+ timeoutMs,
197
+ })
198
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
199
+ if (!intentToAdd.ok) return { error: intentToAdd.error ?? (intentToAdd.stderr.trim() || "git add -N failed before patch capture") };
200
+
201
+ const diff = await runner
202
+ .runGit({
203
+ cwd: options.worktreePath,
204
+ args: ["diff", "--no-color", "HEAD"],
205
+ signal: options.signal,
206
+ timeoutMs,
207
+ maxBufferBytes: WORKTREE_DIFF_MAX_BYTES,
208
+ })
209
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
210
+ if (!diff.ok) return { error: diff.error ?? (diff.stderr.trim() || "worktree diff capture failed") };
211
+ return { patch: diff.stdout, changed: diff.stdout.trim().length > 0 };
212
+ }
213
+
214
+ export const spawnGitRunner: WorktreeGitRunner = {
215
+ async runGit(options) {
216
+ return await runGitCommand(options);
217
+ },
218
+ };
219
+
220
+ async function runGitCommand(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult> {
221
+ return await runBoundedProcess({
222
+ file: "git",
223
+ args: options.args,
224
+ cwd: options.cwd,
225
+ env: { ...process.env, GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" },
226
+ signal: options.signal,
227
+ timeoutMs: options.timeoutMs,
228
+ maxBufferBytes: options.maxBufferBytes,
229
+ abortError: "git worktree command aborted",
230
+ timeoutError: `git worktree command timed out after ${options.timeoutMs}ms`,
231
+ maxBufferError: options.maxBufferBytes === undefined ? undefined : `git worktree command exceeded ${options.maxBufferBytes} bytes`,
232
+ exitError: (stderr, code, signal) => stderr.trim() || `git exited with code ${code ?? `signal ${signal ?? "unknown"}`}`,
233
+ });
234
+ }
235
+
236
+ function formatError(error: unknown): string {
237
+ return error instanceof Error ? error.message : String(error);
238
+ }