@quintinshaw/pi-dynamic-workflows 1.4.0 → 1.5.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.
package/README.md CHANGED
@@ -99,6 +99,7 @@ return { inventory, summary }
99
99
  | `phase` | string | Override the current phase for this agent |
100
100
  | `schema` | object | JSON Schema for structured output |
101
101
  | `model` | string | Run this agent on a specific model — `provider/modelId` or a bare `modelId` |
102
+ | `isolation` | `"worktree"` | Run this agent in its own throwaway git worktree (parallel edits without conflict) |
102
103
  | `timeoutMs` | number | Override the default 5-minute agent timeout |
103
104
 
104
105
  Models can also be set per phase via `meta.phases[].model`. Precedence is `opts.model` > phase model > session default; an unknown model logs a warning and falls back to the default.
@@ -135,6 +136,7 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
135
136
  - **Real per-agent / per-phase model routing** — `opts.model` and `meta.phases[].model` actually select the model (resolved against your authed model registry), with graceful fallback
136
137
  - **`/workflows` command** — list, inspect, stop, pause, **resume**, and remove background runs; runs started with `background: true` are reachable from the command
137
138
  - **Resume** — each agent result is journaled by a deterministic call index; resuming replays the unchanged prefix from cache (no re-run, no tokens) and runs only new or edited calls live
139
+ - **Worktree isolation** — `isolation: "worktree"` runs an agent in its own git worktree on a throwaway branch, so parallel agents can edit the same files without conflict; the worktree is torn down after (results are not auto-merged), and it falls back to a logged no-op outside a git repo
138
140
  - **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
139
141
  - **Live progress + token/cost display**, `Esc` to abort
140
142
  - **Log persistence** to `.pi/workflows/runs/`
@@ -143,8 +145,9 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
143
145
 
144
146
  Tracked toward closer parity with Claude Code dynamic workflows:
145
147
 
146
- - **Worktree isolation** for parallel edits, and **bundled `/deep-research`**
148
+ - **Bundled `/deep-research`** and `/adversarial-review` workflows
147
149
  - **Saved workflows** as `/<name>` slash commands
150
+ - **Nested `workflow()`** to compose saved workflows inline
148
151
 
149
152
  ## How it works
150
153
 
package/dist/agent.d.ts CHANGED
@@ -38,6 +38,8 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
38
38
  model?: string;
39
39
  /** Called with the resolved model id once known (for display/telemetry). */
40
40
  onModelResolved?: (modelId: string) => void;
41
+ /** Run this agent in a different working directory (e.g. an isolated worktree). */
42
+ cwd?: string;
41
43
  }
42
44
  export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema ? Static<TSchemaDef> : string;
43
45
  export declare class WorkflowAgent {
package/dist/agent.js CHANGED
@@ -39,7 +39,11 @@ export class WorkflowAgent {
39
39
  }
40
40
  async run(prompt, options = {}) {
41
41
  const capture = { called: false, value: undefined };
42
- const customTools = [...this.baseTools, ...(options.tools ?? [])];
42
+ // Per-call cwd (e.g. a worktree) needs coding tools bound to that directory,
43
+ // since tools capture their cwd at construction and can't be relocated.
44
+ const runCwd = options.cwd ?? this.cwd;
45
+ const baseTools = runCwd === this.cwd ? this.baseTools : createCodingTools(runCwd);
46
+ const customTools = [...baseTools, ...(options.tools ?? [])];
43
47
  if (options.schema) {
44
48
  customTools.push(createStructuredOutputTool({ schema: options.schema, capture }));
45
49
  }
@@ -57,7 +61,7 @@ export class WorkflowAgent {
57
61
  }
58
62
  const agentDir = getAgentDir();
59
63
  const { session } = await createAgentSession({
60
- cwd: this.cwd,
64
+ cwd: runCwd,
61
65
  agentDir,
62
66
  sessionManager: SessionManager.inMemory(),
63
67
  // Use real SettingsManager to inherit user's default provider/model settings.
package/dist/index.d.ts CHANGED
@@ -18,7 +18,7 @@ export type { PersistedRunState, RunPersistence, RunStatus } from "./run-persist
18
18
  export { createRunPersistence, generateRunId } from "./run-persistence.js";
19
19
  export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.js";
20
20
  export { createStructuredOutputTool } from "./structured-output.js";
21
- export type { AgentOptions, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
21
+ export type { AgentOptions, JournalEntry, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
22
22
  export { parseWorkflowScript, runWorkflow } from "./workflow.js";
23
23
  export { registerWorkflowCommands } from "./workflow-commands.js";
24
24
  export type { ManagedRun, WorkflowManagerOptions } from "./workflow-manager.js";
@@ -27,3 +27,5 @@ export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
27
27
  export { createWorkflowStorage } from "./workflow-saved.js";
28
28
  export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.js";
29
29
  export { createWorkflowTool } from "./workflow-tool.js";
30
+ export type { Worktree } from "./worktree.js";
31
+ export { createWorktree, removeWorktree } from "./worktree.js";
package/dist/index.js CHANGED
@@ -14,3 +14,4 @@ export { registerWorkflowCommands } from "./workflow-commands.js";
14
14
  export { WorkflowManager } from "./workflow-manager.js";
15
15
  export { createWorkflowStorage } from "./workflow-saved.js";
16
16
  export { createWorkflowTool } from "./workflow-tool.js";
17
+ export { createWorktree, removeWorktree } from "./worktree.js";
@@ -51,6 +51,7 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
51
51
  phase?: string;
52
52
  result: unknown;
53
53
  tokens?: number;
54
+ worktree?: string;
54
55
  }) => void;
55
56
  onTokenUsage?: (usage: {
56
57
  input: number;
package/dist/workflow.js CHANGED
@@ -6,6 +6,7 @@ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from ".
6
6
  import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
7
7
  import { createWorkflowLogger } from "./logger.js";
8
8
  import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
9
+ import { createWorktree, removeWorktree } from "./worktree.js";
9
10
  const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
10
11
  export async function runWorkflow(script, options = {}) {
11
12
  const started = Date.now();
@@ -15,6 +16,7 @@ export async function runWorkflow(script, options = {}) {
15
16
  const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
16
17
  const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
17
18
  const runId = options.runId ?? `run-${started.toString(36)}`;
19
+ const baseCwd = options.cwd ?? process.cwd();
18
20
  // Initialize logger
19
21
  const logger = createWorkflowLogger({
20
22
  runId,
@@ -88,6 +90,14 @@ export async function runWorkflow(script, options = {}) {
88
90
  const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
89
91
  const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
90
92
  options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
93
+ // Optional per-agent worktree isolation (deterministic name -> stable resume keys).
94
+ let worktree;
95
+ if (agentOptions.isolation === "worktree") {
96
+ worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
97
+ if (!worktree.isolated)
98
+ log(`isolation ignored for "${label}" (${worktree.reason})`);
99
+ }
100
+ const runCwd = worktree?.isolated ? worktree.cwd : undefined;
91
101
  // Captured from the subagent's real session usage; falls back to an
92
102
  // estimate when the provider reports no usage (total === 0).
93
103
  let usage;
@@ -111,6 +121,7 @@ export async function runWorkflow(script, options = {}) {
111
121
  signal: options.signal,
112
122
  instructions: buildAgentInstructions(assignedPhase, agentOptions),
113
123
  model: modelSpec,
124
+ cwd: runCwd,
114
125
  onUsage: (u) => {
115
126
  usage = u;
116
127
  },
@@ -118,7 +129,7 @@ export async function runWorkflow(script, options = {}) {
118
129
  throwIfAborted();
119
130
  const tokens = recordTokens(result);
120
131
  options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
121
- options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
132
+ options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
122
133
  return result;
123
134
  }
124
135
  catch (error) {
@@ -127,13 +138,18 @@ export async function runWorkflow(script, options = {}) {
127
138
  const workflowError = wrapError(error, { agentLabel: label });
128
139
  logger.error(`agent ${label} failed: ${workflowError.message}`);
129
140
  const tokens = recordTokens(null);
130
- options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens });
141
+ options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens, worktree: runCwd });
131
142
  // Return null for recoverable errors
132
143
  if (workflowError.recoverable) {
133
144
  return null;
134
145
  }
135
146
  throw workflowError;
136
147
  }
148
+ finally {
149
+ // Always tear down the worktree, even on timeout/abort.
150
+ if (worktree?.isolated)
151
+ await removeWorktree(worktree);
152
+ }
137
153
  });
138
154
  };
139
155
  const parallel = async (thunks) => {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
3
+ * it runs in a throwaway worktree on its own branch so parallel agents can edit the
4
+ * same files without conflict. Results are NOT auto-merged — the path is surfaced for
5
+ * the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
6
+ */
7
+ export interface Worktree {
8
+ /** True when a real worktree was created; false means "ran in the shared tree". */
9
+ isolated: boolean;
10
+ /** cwd the agent should run in (worktree path when isolated, else the base cwd). */
11
+ cwd: string;
12
+ branch?: string;
13
+ /** Repo root the worktree was added to (for teardown). */
14
+ repoRoot?: string;
15
+ /** Why isolation was skipped, when isolated === false. */
16
+ reason?: string;
17
+ }
18
+ /**
19
+ * Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
20
+ * `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
21
+ * never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
22
+ */
23
+ export declare function createWorktree(baseCwd: string, name: string): Promise<Worktree>;
24
+ /** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
25
+ export declare function removeWorktree(wt: Worktree): Promise<void>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
3
+ * it runs in a throwaway worktree on its own branch so parallel agents can edit the
4
+ * same files without conflict. Results are NOT auto-merged — the path is surfaced for
5
+ * the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
6
+ */
7
+ import { execFile } from "node:child_process";
8
+ import { join } from "node:path";
9
+ import { promisify } from "node:util";
10
+ const exec = promisify(execFile);
11
+ function slug(name) {
12
+ return (name
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, "-")
15
+ .replace(/^-+|-+$/g, "")
16
+ .slice(0, 32) || "agent");
17
+ }
18
+ /**
19
+ * Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
20
+ * `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
21
+ * never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
22
+ */
23
+ export async function createWorktree(baseCwd, name) {
24
+ const id = slug(name);
25
+ let repoRoot;
26
+ try {
27
+ const { stdout } = await exec("git", ["-C", baseCwd, "rev-parse", "--show-toplevel"]);
28
+ repoRoot = stdout.trim();
29
+ }
30
+ catch {
31
+ return { isolated: false, cwd: baseCwd, reason: "not a git repository" };
32
+ }
33
+ const path = join(repoRoot, ".pi", "worktrees", id);
34
+ const branch = `pi/wf/${id}`;
35
+ try {
36
+ await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
37
+ return { isolated: true, cwd: path, branch, repoRoot };
38
+ }
39
+ catch (error) {
40
+ return { isolated: false, cwd: baseCwd, reason: error instanceof Error ? error.message : String(error) };
41
+ }
42
+ }
43
+ /** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
44
+ export async function removeWorktree(wt) {
45
+ if (!wt.isolated || !wt.repoRoot)
46
+ return;
47
+ try {
48
+ await exec("git", ["-C", wt.repoRoot, "worktree", "remove", "--force", wt.cwd]);
49
+ }
50
+ catch {
51
+ // already gone / locked — fall through
52
+ }
53
+ if (wt.branch) {
54
+ try {
55
+ await exec("git", ["-C", wt.repoRoot, "branch", "-D", wt.branch]);
56
+ }
57
+ catch {
58
+ // branch already deleted
59
+ }
60
+ }
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quintinshaw/pi-dynamic-workflows",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Claude-Code-style dynamic workflow orchestration for Pi.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/agent.ts CHANGED
@@ -54,6 +54,8 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
54
54
  model?: string;
55
55
  /** Called with the resolved model id once known (for display/telemetry). */
56
56
  onModelResolved?: (modelId: string) => void;
57
+ /** Run this agent in a different working directory (e.g. an isolated worktree). */
58
+ cwd?: string;
57
59
  }
58
60
 
59
61
  export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema
@@ -105,7 +107,11 @@ export class WorkflowAgent {
105
107
  options: AgentRunOptions<TSchemaDef> = {},
106
108
  ): Promise<AgentRunResult<TSchemaDef>> {
107
109
  const capture: StructuredOutputCapture<any> = { called: false, value: undefined };
108
- const customTools: ToolDefinition[] = [...this.baseTools, ...(options.tools ?? [])];
110
+ // Per-call cwd (e.g. a worktree) needs coding tools bound to that directory,
111
+ // since tools capture their cwd at construction and can't be relocated.
112
+ const runCwd = options.cwd ?? this.cwd;
113
+ const baseTools = runCwd === this.cwd ? this.baseTools : createCodingTools(runCwd);
114
+ const customTools: ToolDefinition[] = [...baseTools, ...(options.tools ?? [])];
109
115
 
110
116
  if (options.schema) {
111
117
  customTools.push(createStructuredOutputTool({ schema: options.schema, capture }) as unknown as ToolDefinition);
@@ -125,7 +131,7 @@ export class WorkflowAgent {
125
131
 
126
132
  const agentDir = getAgentDir();
127
133
  const { session } = await createAgentSession({
128
- cwd: this.cwd,
134
+ cwd: runCwd,
129
135
  agentDir,
130
136
  sessionManager: SessionManager.inMemory(),
131
137
  // Use real SettingsManager to inherit user's default provider/model settings.
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./str
41
41
  export { createStructuredOutputTool } from "./structured-output.js";
42
42
  export type {
43
43
  AgentOptions,
44
+ JournalEntry,
44
45
  WorkflowMeta,
45
46
  WorkflowMetaPhase,
46
47
  WorkflowRunOptions,
@@ -54,3 +55,5 @@ export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
54
55
  export { createWorkflowStorage } from "./workflow-saved.js";
55
56
  export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.js";
56
57
  export { createWorkflowTool } from "./workflow-tool.js";
58
+ export type { Worktree } from "./worktree.js";
59
+ export { createWorktree, removeWorktree } from "./worktree.js";
package/src/workflow.ts CHANGED
@@ -9,6 +9,7 @@ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from ".
9
9
  import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
10
10
  import { createWorkflowLogger } from "./logger.js";
11
11
  import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
12
+ import { createWorktree, removeWorktree, type Worktree } from "./worktree.js";
12
13
 
13
14
  export interface WorkflowMetaPhase {
14
15
  title: string;
@@ -54,7 +55,7 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
54
55
  onLog?: (message: string) => void;
55
56
  onPhase?: (title: string) => void;
56
57
  onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
57
- onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number }) => void;
58
+ onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number; worktree?: string }) => void;
58
59
  onTokenUsage?: (usage: { input: number; output: number; total: number; cost: number }) => void;
59
60
  }
60
61
 
@@ -116,6 +117,7 @@ export async function runWorkflow<T = unknown>(
116
117
  const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
117
118
  const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
118
119
  const runId = options.runId ?? `run-${started.toString(36)}`;
120
+ const baseCwd = options.cwd ?? process.cwd();
119
121
 
120
122
  // Initialize logger
121
123
  const logger = createWorkflowLogger({
@@ -211,6 +213,14 @@ export async function runWorkflow<T = unknown>(
211
213
 
212
214
  options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
213
215
 
216
+ // Optional per-agent worktree isolation (deterministic name -> stable resume keys).
217
+ let worktree: Worktree | undefined;
218
+ if (agentOptions.isolation === "worktree") {
219
+ worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
220
+ if (!worktree.isolated) log(`isolation ignored for "${label}" (${worktree.reason})`);
221
+ }
222
+ const runCwd = worktree?.isolated ? worktree.cwd : undefined;
223
+
214
224
  // Captured from the subagent's real session usage; falls back to an
215
225
  // estimate when the provider reports no usage (total === 0).
216
226
  let usage: AgentUsage | undefined;
@@ -237,6 +247,7 @@ export async function runWorkflow<T = unknown>(
237
247
  signal: options.signal,
238
248
  instructions: buildAgentInstructions(assignedPhase, agentOptions),
239
249
  model: modelSpec,
250
+ cwd: runCwd,
240
251
  onUsage: (u: AgentUsage) => {
241
252
  usage = u;
242
253
  },
@@ -249,7 +260,7 @@ export async function runWorkflow<T = unknown>(
249
260
 
250
261
  const tokens = recordTokens(result);
251
262
  options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
252
- options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
263
+ options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
253
264
  return result;
254
265
  } catch (error) {
255
266
  if (options.signal?.aborted) throw error;
@@ -257,13 +268,16 @@ export async function runWorkflow<T = unknown>(
257
268
  const workflowError = wrapError(error, { agentLabel: label });
258
269
  logger.error(`agent ${label} failed: ${workflowError.message}`);
259
270
  const tokens = recordTokens(null);
260
- options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens });
271
+ options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens, worktree: runCwd });
261
272
 
262
273
  // Return null for recoverable errors
263
274
  if (workflowError.recoverable) {
264
275
  return null;
265
276
  }
266
277
  throw workflowError;
278
+ } finally {
279
+ // Always tear down the worktree, even on timeout/abort.
280
+ if (worktree?.isolated) await removeWorktree(worktree);
267
281
  }
268
282
  });
269
283
  };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
3
+ * it runs in a throwaway worktree on its own branch so parallel agents can edit the
4
+ * same files without conflict. Results are NOT auto-merged — the path is surfaced for
5
+ * the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
6
+ */
7
+
8
+ import { execFile } from "node:child_process";
9
+ import { join } from "node:path";
10
+ import { promisify } from "node:util";
11
+
12
+ const exec = promisify(execFile);
13
+
14
+ export interface Worktree {
15
+ /** True when a real worktree was created; false means "ran in the shared tree". */
16
+ isolated: boolean;
17
+ /** cwd the agent should run in (worktree path when isolated, else the base cwd). */
18
+ cwd: string;
19
+ branch?: string;
20
+ /** Repo root the worktree was added to (for teardown). */
21
+ repoRoot?: string;
22
+ /** Why isolation was skipped, when isolated === false. */
23
+ reason?: string;
24
+ }
25
+
26
+ function slug(name: string): string {
27
+ return (
28
+ name
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9]+/g, "-")
31
+ .replace(/^-+|-+$/g, "")
32
+ .slice(0, 32) || "agent"
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
38
+ * `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
39
+ * never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
40
+ */
41
+ export async function createWorktree(baseCwd: string, name: string): Promise<Worktree> {
42
+ const id = slug(name);
43
+ let repoRoot: string;
44
+ try {
45
+ const { stdout } = await exec("git", ["-C", baseCwd, "rev-parse", "--show-toplevel"]);
46
+ repoRoot = stdout.trim();
47
+ } catch {
48
+ return { isolated: false, cwd: baseCwd, reason: "not a git repository" };
49
+ }
50
+
51
+ const path = join(repoRoot, ".pi", "worktrees", id);
52
+ const branch = `pi/wf/${id}`;
53
+ try {
54
+ await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
55
+ return { isolated: true, cwd: path, branch, repoRoot };
56
+ } catch (error) {
57
+ return { isolated: false, cwd: baseCwd, reason: error instanceof Error ? error.message : String(error) };
58
+ }
59
+ }
60
+
61
+ /** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
62
+ export async function removeWorktree(wt: Worktree): Promise<void> {
63
+ if (!wt.isolated || !wt.repoRoot) return;
64
+ try {
65
+ await exec("git", ["-C", wt.repoRoot, "worktree", "remove", "--force", wt.cwd]);
66
+ } catch {
67
+ // already gone / locked — fall through
68
+ }
69
+ if (wt.branch) {
70
+ try {
71
+ await exec("git", ["-C", wt.repoRoot, "branch", "-D", wt.branch]);
72
+ } catch {
73
+ // branch already deleted
74
+ }
75
+ }
76
+ }