@oh-my-pi/pi-coding-agent 14.9.8 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/package.json +7 -7
  3. package/scripts/build-binary.ts +11 -0
  4. package/scripts/format-prompts.ts +1 -1
  5. package/src/cli/args.ts +2 -2
  6. package/src/cli/stats-cli.ts +2 -0
  7. package/src/cli.ts +24 -1
  8. package/src/commands/acp.ts +24 -0
  9. package/src/commands/launch.ts +6 -4
  10. package/src/commit/agentic/prompts/system.md +1 -1
  11. package/src/config/model-resolver.ts +30 -0
  12. package/src/config/settings-schema.ts +61 -9
  13. package/src/config/settings.ts +18 -1
  14. package/src/edit/index.ts +22 -1
  15. package/src/edit/modes/patch.ts +10 -0
  16. package/src/edit/modes/replace.ts +3 -0
  17. package/src/edit/renderer.ts +10 -0
  18. package/src/edit/streaming.ts +1 -1
  19. package/src/eval/js/context-manager.ts +10 -9
  20. package/src/eval/js/shared/rewrite-imports.ts +120 -48
  21. package/src/eval/js/shared/runtime.ts +31 -4
  22. package/src/eval/js/tool-bridge.ts +43 -21
  23. package/src/extensibility/extensions/runner.ts +54 -1
  24. package/src/extensibility/extensions/types.ts +11 -0
  25. package/src/extensibility/skills.ts +33 -1
  26. package/src/hashline/grammar.lark +1 -1
  27. package/src/hashline/input.ts +11 -5
  28. package/src/internal-urls/docs-index.generated.ts +7 -7
  29. package/src/internal-urls/index.ts +1 -0
  30. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  31. package/src/internal-urls/router.ts +6 -3
  32. package/src/internal-urls/types.ts +22 -1
  33. package/src/main.ts +13 -9
  34. package/src/modes/acp/acp-agent.ts +361 -54
  35. package/src/modes/acp/acp-client-bridge.ts +152 -0
  36. package/src/modes/acp/acp-event-mapper.ts +180 -15
  37. package/src/modes/acp/terminal-auth.ts +37 -0
  38. package/src/modes/components/read-tool-group.ts +29 -1
  39. package/src/modes/controllers/command-controller.ts +14 -6
  40. package/src/modes/controllers/event-controller.ts +24 -11
  41. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  42. package/src/modes/controllers/input-controller.ts +72 -39
  43. package/src/modes/interactive-mode.ts +71 -7
  44. package/src/modes/rpc/rpc-mode.ts +17 -2
  45. package/src/modes/types.ts +6 -2
  46. package/src/modes/utils/ui-helpers.ts +15 -3
  47. package/src/prompts/agents/designer.md +5 -5
  48. package/src/prompts/agents/explore.md +7 -7
  49. package/src/prompts/agents/init.md +9 -9
  50. package/src/prompts/agents/librarian.md +14 -14
  51. package/src/prompts/agents/plan.md +4 -4
  52. package/src/prompts/agents/reviewer.md +5 -5
  53. package/src/prompts/agents/task.md +10 -10
  54. package/src/prompts/commands/orchestrate.md +2 -2
  55. package/src/prompts/compaction/branch-summary.md +3 -3
  56. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  57. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  58. package/src/prompts/compaction/compaction-summary.md +5 -5
  59. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  60. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  61. package/src/prompts/memories/consolidation.md +2 -2
  62. package/src/prompts/memories/read-path.md +1 -1
  63. package/src/prompts/memories/stage_one_input.md +1 -1
  64. package/src/prompts/memories/stage_one_system.md +5 -5
  65. package/src/prompts/review-request.md +4 -4
  66. package/src/prompts/system/agent-creation-architect.md +17 -17
  67. package/src/prompts/system/agent-creation-user.md +2 -2
  68. package/src/prompts/system/commit-message-system.md +2 -2
  69. package/src/prompts/system/custom-system-prompt.md +2 -2
  70. package/src/prompts/system/eager-todo.md +6 -6
  71. package/src/prompts/system/handoff-document.md +1 -1
  72. package/src/prompts/system/plan-mode-active.md +22 -21
  73. package/src/prompts/system/plan-mode-approved.md +4 -4
  74. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  75. package/src/prompts/system/plan-mode-reference.md +2 -2
  76. package/src/prompts/system/plan-mode-subagent.md +8 -8
  77. package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
  78. package/src/prompts/system/project-prompt.md +4 -4
  79. package/src/prompts/system/subagent-system-prompt.md +7 -7
  80. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  81. package/src/prompts/system/system-prompt.md +72 -71
  82. package/src/prompts/system/ttsr-interrupt.md +1 -1
  83. package/src/prompts/tools/apply-patch.md +1 -1
  84. package/src/prompts/tools/ast-edit.md +3 -3
  85. package/src/prompts/tools/ast-grep.md +3 -3
  86. package/src/prompts/tools/browser.md +3 -3
  87. package/src/prompts/tools/checkpoint.md +3 -3
  88. package/src/prompts/tools/exit-plan-mode.md +2 -2
  89. package/src/prompts/tools/find.md +3 -3
  90. package/src/prompts/tools/github.md +2 -5
  91. package/src/prompts/tools/hashline.md +20 -20
  92. package/src/prompts/tools/image-gen.md +3 -3
  93. package/src/prompts/tools/irc.md +1 -1
  94. package/src/prompts/tools/lsp.md +2 -2
  95. package/src/prompts/tools/patch.md +6 -6
  96. package/src/prompts/tools/read.md +7 -7
  97. package/src/prompts/tools/replace.md +5 -5
  98. package/src/prompts/tools/retain.md +1 -1
  99. package/src/prompts/tools/rewind.md +2 -2
  100. package/src/prompts/tools/search.md +2 -2
  101. package/src/prompts/tools/ssh.md +2 -2
  102. package/src/prompts/tools/task.md +12 -6
  103. package/src/prompts/tools/web-search.md +2 -2
  104. package/src/prompts/tools/write.md +3 -3
  105. package/src/sdk.ts +69 -12
  106. package/src/session/agent-session.ts +231 -22
  107. package/src/session/client-bridge.ts +81 -0
  108. package/src/session/compaction/errors.ts +31 -0
  109. package/src/session/compaction/index.ts +1 -0
  110. package/src/slash-commands/acp-builtins.ts +46 -0
  111. package/src/slash-commands/builtin-registry.ts +699 -116
  112. package/src/slash-commands/helpers/context-report.ts +39 -0
  113. package/src/slash-commands/helpers/format.ts +23 -0
  114. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  115. package/src/slash-commands/helpers/mcp.ts +532 -0
  116. package/src/slash-commands/helpers/parse.ts +85 -0
  117. package/src/slash-commands/helpers/ssh.ts +193 -0
  118. package/src/slash-commands/helpers/todo.ts +279 -0
  119. package/src/slash-commands/helpers/usage-report.ts +91 -0
  120. package/src/slash-commands/types.ts +126 -0
  121. package/src/task/executor.ts +10 -3
  122. package/src/task/index.ts +29 -51
  123. package/src/task/render.ts +6 -3
  124. package/src/task/worktree.ts +170 -239
  125. package/src/tools/bash.ts +176 -2
  126. package/src/tools/browser/tab-supervisor.ts +13 -13
  127. package/src/tools/conflict-detect.ts +6 -6
  128. package/src/tools/fetch.ts +15 -4
  129. package/src/tools/find.ts +19 -1
  130. package/src/tools/gh-renderer.ts +0 -12
  131. package/src/tools/gh.ts +682 -176
  132. package/src/tools/github-cache.ts +548 -0
  133. package/src/tools/index.ts +3 -0
  134. package/src/tools/read.ts +110 -27
  135. package/src/tools/write.ts +23 -1
  136. package/src/tui/code-cell.ts +70 -2
  137. package/src/utils/git.ts +5 -0
  138. package/src/task/isolation-backend.ts +0 -94
@@ -2,11 +2,13 @@ import type { Dirent } from "node:fs";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { projfsOverlayStart, projfsOverlayStop } from "@oh-my-pi/pi-natives";
6
- import { $which, getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils";
7
- import { $ } from "bun";
5
+ import * as natives from "@oh-my-pi/pi-natives";
6
+ import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
8
7
  import * as git from "../utils/git";
9
8
 
9
+ const { IsoBackendKind } = natives;
10
+ type IsoBackendKind = natives.IsoBackendKind;
11
+
10
12
  /** Baseline state for a single git repository. */
11
13
  export interface RepoBaseline {
12
14
  repoRoot: string;
@@ -14,6 +16,7 @@ export interface RepoBaseline {
14
16
  staged: string;
15
17
  unstaged: string;
16
18
  untracked: string[];
19
+ untrackedPatch: string;
17
20
  }
18
21
 
19
22
  /** Baseline state for the project, including any nested git repos. */
@@ -36,28 +39,12 @@ export async function getRepoRoot(cwd: string): Promise<string> {
36
39
  return repoRoot;
37
40
  }
38
41
 
39
- const PROJFS_UNAVAILABLE_PREFIX = "PROJFS_UNAVAILABLE:";
40
42
  const GIT_NO_INDEX_NULL_PATH = process.platform === "win32" ? "NUL" : "/dev/null";
41
43
 
42
- export function isProjfsUnavailableError(err: unknown): boolean {
43
- return err instanceof Error && err.message.includes(PROJFS_UNAVAILABLE_PREFIX);
44
- }
45
-
46
44
  export function getGitNoIndexNullPath(): string {
47
45
  return GIT_NO_INDEX_NULL_PATH;
48
46
  }
49
47
 
50
- export async function ensureWorktree(baseCwd: string, id: string): Promise<string> {
51
- const repoRoot = await getRepoRoot(baseCwd);
52
- const encodedProject = getEncodedProjectName(repoRoot);
53
- const worktreeDir = getWorktreeDir(encodedProject, id);
54
- await fs.mkdir(path.dirname(worktreeDir), { recursive: true });
55
- await git.worktree.tryRemove(repoRoot, worktreeDir);
56
- await fs.rm(worktreeDir, { recursive: true, force: true });
57
- await git.worktree.add(repoRoot, worktreeDir, "HEAD", { detach: true });
58
- return worktreeDir;
59
- }
60
-
61
48
  /** Find nested git repositories (non-submodule) under the given root. */
62
49
  async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
63
50
  // Get submodule paths so we can exclude them
@@ -96,12 +83,49 @@ async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
96
83
  return result;
97
84
  }
98
85
 
86
+ async function captureUntrackedPatch(repoRoot: string, untracked: readonly string[]): Promise<string> {
87
+ if (untracked.length === 0) return "";
88
+ const nullPath = getGitNoIndexNullPath();
89
+ const untrackedDiffs = await Promise.all(
90
+ untracked.map(entry =>
91
+ git.diff(repoRoot, {
92
+ allowFailure: true,
93
+ binary: true,
94
+ noIndex: { left: nullPath, right: entry },
95
+ }),
96
+ ),
97
+ );
98
+ return untrackedDiffs.filter(diff => diff.trim()).join("\n");
99
+ }
100
+
99
101
  async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
100
102
  const headCommit = (await git.head.sha(repoRoot)) ?? "";
101
103
  const staged = await git.diff(repoRoot, { binary: true, cached: true });
102
104
  const unstaged = await git.diff(repoRoot, { binary: true });
103
105
  const untracked = await git.ls.untracked(repoRoot);
104
- return { repoRoot, headCommit, staged, unstaged, untracked };
106
+ const untrackedPatch = await captureUntrackedPatch(repoRoot, untracked);
107
+ return { repoRoot, headCommit, staged, unstaged, untracked, untrackedPatch };
108
+ }
109
+
110
+ async function writeSyntheticTree(repoDir: string, baseTreeish: string, patches: readonly string[]): Promise<string> {
111
+ const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
112
+ try {
113
+ await git.readTree(repoDir, baseTreeish, {
114
+ env: { GIT_INDEX_FILE: tempIndex },
115
+ });
116
+ for (const patch of patches) {
117
+ if (!patch.trim()) continue;
118
+ await git.patch.applyText(repoDir, patch, {
119
+ cached: true,
120
+ env: { GIT_INDEX_FILE: tempIndex },
121
+ });
122
+ }
123
+ return await git.writeTree(repoDir, {
124
+ env: { GIT_INDEX_FILE: tempIndex },
125
+ });
126
+ } finally {
127
+ await fs.rm(tempIndex, { force: true });
128
+ }
105
129
  }
106
130
 
107
131
  export async function captureBaseline(repoRoot: string): Promise<WorktreeBaseline> {
@@ -115,138 +139,24 @@ export async function captureBaseline(repoRoot: string): Promise<WorktreeBaselin
115
139
  return { root, nested };
116
140
  }
117
141
 
118
- async function applyRepoBaseline(worktreeDir: string, rb: RepoBaseline, sourceRoot: string): Promise<void> {
119
- await git.patch.applyText(worktreeDir, rb.staged, { cached: true });
120
- await git.patch.applyText(worktreeDir, rb.staged);
121
- await git.patch.applyText(worktreeDir, rb.unstaged);
122
-
123
- for (const entry of rb.untracked) {
124
- const source = path.join(sourceRoot, entry);
125
- const destination = path.join(worktreeDir, entry);
126
- try {
127
- await fs.mkdir(path.dirname(destination), { recursive: true });
128
- await fs.cp(source, destination, { recursive: true });
129
- } catch (err) {
130
- if (isEnoent(err)) continue;
131
- throw err;
132
- }
133
- }
134
- }
135
-
136
- export async function applyBaseline(worktreeDir: string, baseline: WorktreeBaseline): Promise<void> {
137
- await applyRepoBaseline(worktreeDir, baseline.root, baseline.root.repoRoot);
138
-
139
- // Restore nested repos into the worktree
140
- for (const entry of baseline.nested) {
141
- const nestedDir = path.join(worktreeDir, entry.relativePath);
142
- // Copy the nested repo wholesale (it's not managed by root git)
143
- const sourceDir = path.join(baseline.root.repoRoot, entry.relativePath);
144
- try {
145
- await fs.cp(sourceDir, nestedDir, { recursive: true });
146
- } catch (err) {
147
- if (isEnoent(err)) continue;
148
- throw err;
149
- }
150
- // Apply any uncommitted changes from the nested baseline
151
- await applyRepoBaseline(nestedDir, entry.baseline, entry.baseline.repoRoot);
152
- // Commit baseline state so captureRepoDeltaPatch can cleanly subtract it.
153
- // Without this, `git add -A && git commit` by the task would include
154
- // baseline untracked files in the diff-tree output.
155
- if ((await git.status(nestedDir)).trim().length > 0) {
156
- await git.stage.files(nestedDir);
157
- await git.commit(nestedDir, "omp-baseline", { allowEmpty: true });
158
- // Update baseline to reflect the committed state — prevents double-apply
159
- // in captureRepoDeltaPatch's temp-index path
160
- entry.baseline.headCommit = (await git.head.sha(nestedDir)) ?? "";
161
- entry.baseline.staged = "";
162
- entry.baseline.unstaged = "";
163
- entry.baseline.untracked = [];
164
- }
165
- }
166
- }
167
-
168
142
  async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise<string> {
169
- // Check if HEAD advanced (task committed changes)
170
143
  const currentHead = (await git.head.sha(repoDir)) ?? "";
171
- const headAdvanced = currentHead && currentHead !== rb.headCommit;
172
-
173
- if (headAdvanced) {
174
- // HEAD moved: use diff-tree to capture committed changes, plus any uncommitted on top
175
- const parts: string[] = [];
176
-
177
- // Committed changes since baseline
178
- const committedDiff = await git.diff.tree(repoDir, rb.headCommit, currentHead, {
179
- allowFailure: true,
180
- binary: true,
181
- });
182
- if (committedDiff.trim()) parts.push(committedDiff);
183
-
184
- // Uncommitted changes on top of the new HEAD
185
- const staged = await git.diff(repoDir, { binary: true, cached: true });
186
- const unstaged = await git.diff(repoDir, { binary: true });
187
- if (staged.trim()) parts.push(staged);
188
- if (unstaged.trim()) parts.push(unstaged);
189
-
190
- // New untracked files (relative to both baseline and current tracking)
191
- const currentUntracked = await git.ls.untracked(repoDir);
192
- const baselineUntracked = new Set(rb.untracked);
193
- const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
194
- if (newUntracked.length > 0) {
195
- const nullPath = getGitNoIndexNullPath();
196
- const untrackedDiffs = await Promise.all(
197
- newUntracked.map(entry =>
198
- git.diff(repoDir, {
199
- allowFailure: true,
200
- binary: true,
201
- noIndex: { left: nullPath, right: entry },
202
- }),
203
- ),
204
- );
205
- parts.push(...untrackedDiffs.filter(d => d.trim()));
206
- }
207
-
208
- return parts.join("\n");
209
- }
210
-
211
- // HEAD unchanged: use temp index approach (subtracts baseline from delta)
212
- const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
213
- try {
214
- await git.readTree(repoDir, rb.headCommit, {
215
- env: { GIT_INDEX_FILE: tempIndex },
216
- });
217
- await git.patch.applyText(repoDir, rb.staged, {
218
- cached: true,
219
- env: { GIT_INDEX_FILE: tempIndex },
220
- });
221
- await git.patch.applyText(repoDir, rb.unstaged, {
222
- cached: true,
223
- env: { GIT_INDEX_FILE: tempIndex },
224
- });
225
- const diff = await git.diff(repoDir, {
226
- binary: true,
227
- env: { GIT_INDEX_FILE: tempIndex },
228
- });
229
-
230
- const currentUntracked = await git.ls.untracked(repoDir);
231
- const baselineUntracked = new Set(rb.untracked);
232
- const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
233
-
234
- if (newUntracked.length === 0) return diff;
235
-
236
- const nullPath = getGitNoIndexNullPath();
237
- const untrackedDiffs = await Promise.all(
238
- newUntracked.map(entry =>
239
- git.diff(repoDir, {
240
- allowFailure: true,
241
- binary: true,
242
- noIndex: { left: nullPath, right: entry },
243
- }),
244
- ),
245
- );
246
- return `${diff}${diff && !diff.endsWith("\n") ? "\n" : ""}${untrackedDiffs.join("\n")}`;
247
- } finally {
248
- await fs.rm(tempIndex, { force: true });
249
- }
144
+ const currentStaged = await git.diff(repoDir, { binary: true, cached: true });
145
+ const currentUnstaged = await git.diff(repoDir, { binary: true });
146
+ const currentUntracked = await git.ls.untracked(repoDir);
147
+ const currentUntrackedPatch = await captureUntrackedPatch(repoDir, currentUntracked);
148
+
149
+ const baselineTree = await writeSyntheticTree(repoDir, rb.headCommit, [rb.staged, rb.unstaged, rb.untrackedPatch]);
150
+ const currentTree = await writeSyntheticTree(repoDir, currentHead, [
151
+ currentStaged,
152
+ currentUnstaged,
153
+ currentUntrackedPatch,
154
+ ]);
155
+
156
+ return git.diff.tree(repoDir, baselineTree, currentTree, {
157
+ allowFailure: true,
158
+ binary: true,
159
+ });
250
160
  }
251
161
 
252
162
  export interface NestedRepoPatch {
@@ -318,119 +228,140 @@ export async function applyNestedPatches(
318
228
  }
319
229
  }
320
230
 
321
- export async function cleanupWorktree(dir: string): Promise<void> {
322
- try {
323
- const repository = await git.repo.resolve(dir);
324
- const commonDir = repository?.commonDir ?? "";
325
- if (commonDir && path.basename(commonDir) === ".git") {
326
- const repoRoot = path.dirname(commonDir);
327
- await git.worktree.tryRemove(repoRoot, dir);
328
- }
329
- } finally {
330
- await fs.rm(dir, { recursive: true, force: true });
331
- }
332
- }
333
-
334
231
  // ═══════════════════════════════════════════════════════════════════════════
335
- // Fuse-overlay isolation (Unix)
232
+ // Unified isolation lifecycle — picks the best backend via the PAL and
233
+ // returns the merged-view path together with the resolved kind.
336
234
  // ═══════════════════════════════════════════════════════════════════════════
337
235
 
338
- export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<string> {
339
- if (process.platform === "win32") {
340
- throw new Error('fuse-overlay isolation is unsupported on Windows. Use task.isolation.mode = "fuse-projfs".');
341
- }
342
-
343
- const repoRoot = await getRepoRoot(baseCwd);
344
- const encodedProject = getEncodedProjectName(repoRoot);
345
- const baseDir = getWorktreeDir(encodedProject, id);
346
- const upperDir = path.join(baseDir, "upper");
347
- const workDir = path.join(baseDir, "work");
348
- const mergedDir = path.join(baseDir, "merged");
349
-
350
- // Clean up any stale mount at this path (linux only)
351
- const fusermount = $which("fusermount3") ?? $which("fusermount");
352
- if (fusermount) {
353
- await $`${fusermount} -u ${mergedDir}`.quiet().nothrow();
354
- }
355
-
356
- await fs.rm(baseDir, { recursive: true, force: true });
357
- await fs.mkdir(upperDir, { recursive: true });
358
- await fs.mkdir(workDir, { recursive: true });
359
- await fs.mkdir(mergedDir, { recursive: true });
360
-
361
- const binary = $which("fuse-overlayfs");
362
- if (!binary) {
363
- await fs.rm(baseDir, { recursive: true, force: true });
364
- throw new Error(
365
- "fuse-overlayfs not found. Install it (e.g. `apt install fuse-overlayfs` or `pacman -S fuse-overlayfs`) to use fuse-overlay isolation.",
366
- );
367
- }
236
+ /**
237
+ * User-facing isolation mode names exposed by the `task.isolation.mode`
238
+ * setting. Mapped to a backend-kind hint via {@link parseIsolationMode};
239
+ * the PAL's `iso_resolve` then falls back through the kind order
240
+ * whenever the hint isn't available on the current host.
241
+ */
242
+ export type TaskIsolationMode =
243
+ | "none"
244
+ | "auto"
245
+ | "apfs"
246
+ | "btrfs"
247
+ | "zfs"
248
+ | "reflink"
249
+ | "overlayfs"
250
+ | "projfs"
251
+ | "block-clone"
252
+ | "rcopy"
253
+ // Legacy values, accepted for back-compat with pre-PAL settings files.
254
+ | "worktree"
255
+ | "fuse-overlay"
256
+ | "fuse-projfs";
368
257
 
369
- const result = await $`${binary} -o lowerdir=${repoRoot},upperdir=${upperDir},workdir=${workDir} ${mergedDir}`
370
- .quiet()
371
- .nothrow();
372
- if (result.exitCode !== 0) {
373
- const stderr = result.stderr.toString().trim();
374
- await fs.rm(baseDir, { recursive: true, force: true });
375
- throw new Error(`fuse-overlayfs mount failed (exit ${result.exitCode}): ${stderr}`);
258
+ /**
259
+ * Translate a {@link TaskIsolationMode} string to an [`IsoBackendKind`]
260
+ * the PAL can act on. `"none"` returns `null` (caller skips isolation
261
+ * entirely); `"auto"` returns `undefined` (no hint let the resolver
262
+ * pick). Anything else returns the matching kind.
263
+ */
264
+ export function parseIsolationMode(mode: TaskIsolationMode): IsoBackendKind | undefined {
265
+ switch (mode) {
266
+ case "none":
267
+ case "auto":
268
+ return undefined;
269
+ case "apfs":
270
+ return IsoBackendKind.Apfs;
271
+ case "btrfs":
272
+ return IsoBackendKind.Btrfs;
273
+ case "zfs":
274
+ return IsoBackendKind.Zfs;
275
+ case "reflink":
276
+ return IsoBackendKind.LinuxReflink;
277
+ case "overlayfs":
278
+ case "fuse-overlay":
279
+ return IsoBackendKind.Overlayfs;
280
+ case "projfs":
281
+ case "fuse-projfs":
282
+ return IsoBackendKind.Projfs;
283
+ case "block-clone":
284
+ return IsoBackendKind.WindowsBlockClone;
285
+ case "rcopy":
286
+ case "worktree":
287
+ return IsoBackendKind.Rcopy;
376
288
  }
377
-
378
- return mergedDir;
379
289
  }
380
290
 
381
- export async function cleanupFuseOverlay(mergedDir: string): Promise<void> {
382
- try {
383
- const fusermount = $which("fusermount3") ?? $which("fusermount");
384
- if (fusermount) {
385
- await $`${fusermount} -u ${mergedDir}`.quiet().nothrow();
386
- }
387
- } finally {
388
- // baseDir is the parent of the merged directory
389
- const baseDir = path.dirname(mergedDir);
390
- await fs.rm(baseDir, { recursive: true, force: true });
391
- }
291
+ export interface IsolationHandle {
292
+ /** Merged view materialised by the backend; pass this to the task. */
293
+ mergedDir: string;
294
+ /** Backend the PAL actually used. */
295
+ backend: IsoBackendKind;
296
+ /** True when the resolver downgraded from `preferred` to `backend`. */
297
+ fellBack: boolean;
298
+ /** Optional reason associated with `fellBack`. */
299
+ fallbackReason: string | null;
392
300
  }
393
301
 
394
- // ═══════════════════════════════════════════════════════════════════════════
395
- // ProjFS isolation (Windows)
396
- // ═══════════════════════════════════════════════════════════════════════════
302
+ /**
303
+ * Materialise `merged` for a single task. `preferred` is a hint — when
304
+ * its prerequisites are missing the PAL silently falls back, and the
305
+ * caller learns about that through `IsolationHandle.fellBack` +
306
+ * `fallbackReason`.
307
+ */
397
308
 
398
- export async function ensureProjfsOverlay(baseCwd: string, id: string): Promise<string> {
399
- if (process.platform !== "win32") {
400
- throw new Error("fuse-projfs isolation is only available on Windows.");
401
- }
309
+ function errorMessage(err: unknown): string {
310
+ return err instanceof Error ? err.message : String(err);
311
+ }
402
312
 
313
+ export async function ensureIsolation(
314
+ baseCwd: string,
315
+ id: string,
316
+ preferred?: IsoBackendKind,
317
+ ): Promise<IsolationHandle> {
403
318
  const repoRoot = await getRepoRoot(baseCwd);
404
319
  const encodedProject = getEncodedProjectName(repoRoot);
405
320
  const baseDir = getWorktreeDir(encodedProject, id);
406
321
  const mergedDir = path.join(baseDir, "merged");
407
322
 
408
- await fs.rm(baseDir, { recursive: true, force: true });
409
- await fs.mkdir(mergedDir, { recursive: true });
410
- try {
411
- projfsOverlayStart(repoRoot, mergedDir);
412
- return mergedDir;
413
- } catch (err) {
323
+ const resolution = natives.isoResolve(preferred ?? null);
324
+ const candidates = resolution.candidates.length > 0 ? resolution.candidates : [resolution.kind];
325
+ let fallbackReason = resolution.reason ?? null;
326
+
327
+ for (const candidate of candidates) {
414
328
  await fs.rm(baseDir, { recursive: true, force: true });
415
- throw err;
329
+ try {
330
+ await natives.isoStart(candidate, repoRoot, mergedDir);
331
+ return {
332
+ mergedDir,
333
+ backend: candidate,
334
+ fellBack: candidate !== resolution.kind || resolution.fellBack,
335
+ fallbackReason,
336
+ };
337
+ } catch (err) {
338
+ await fs.rm(baseDir, { recursive: true, force: true });
339
+ const message = errorMessage(err);
340
+ if (!natives.isoIsUnavailableError(message)) {
341
+ throw err;
342
+ }
343
+ fallbackReason ??= message;
344
+ }
416
345
  }
346
+
347
+ throw new Error(fallbackReason ?? "No isolation backend is available.");
417
348
  }
418
349
 
419
- export async function cleanupProjfsOverlay(mergedDir: string): Promise<void> {
350
+ /** Tear down a handle returned by {@link ensureIsolation}. */
351
+ export async function cleanupIsolation(handle: IsolationHandle): Promise<void> {
420
352
  try {
421
- if (process.platform === "win32") {
422
- try {
423
- projfsOverlayStop(mergedDir);
424
- } catch (err) {
425
- logger.warn("ProjFS overlay stop failed during cleanup", {
426
- mergedDir,
427
- error: err instanceof Error ? err.message : String(err),
428
- });
429
- }
353
+ try {
354
+ await natives.isoStop(handle.backend, handle.mergedDir);
355
+ } catch (err) {
356
+ logger.warn("isolation backend stop failed during cleanup", {
357
+ backend: handle.backend,
358
+ mergedDir: handle.mergedDir,
359
+ error: err instanceof Error ? err.message : String(err),
360
+ });
430
361
  }
431
362
  } finally {
432
363
  // baseDir is the parent of the merged directory
433
- const baseDir = path.dirname(mergedDir);
364
+ const baseDir = path.dirname(handle.mergedDir);
434
365
  await fs.rm(baseDir, { recursive: true, force: true });
435
366
  }
436
367
  }