@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.
- package/CHANGELOG.md +101 -0
- package/package.json +7 -7
- package/scripts/build-binary.ts +11 -0
- package/scripts/format-prompts.ts +1 -1
- package/src/cli/args.ts +2 -2
- package/src/cli/stats-cli.ts +2 -0
- package/src/cli.ts +24 -1
- package/src/commands/acp.ts +24 -0
- package/src/commands/launch.ts +6 -4
- package/src/commit/agentic/prompts/system.md +1 -1
- package/src/config/model-resolver.ts +30 -0
- package/src/config/settings-schema.ts +61 -9
- package/src/config/settings.ts +18 -1
- package/src/edit/index.ts +22 -1
- package/src/edit/modes/patch.ts +10 -0
- package/src/edit/modes/replace.ts +3 -0
- package/src/edit/renderer.ts +10 -0
- package/src/edit/streaming.ts +1 -1
- package/src/eval/js/context-manager.ts +10 -9
- package/src/eval/js/shared/rewrite-imports.ts +120 -48
- package/src/eval/js/shared/runtime.ts +31 -4
- package/src/eval/js/tool-bridge.ts +43 -21
- package/src/extensibility/extensions/runner.ts +54 -1
- package/src/extensibility/extensions/types.ts +11 -0
- package/src/extensibility/skills.ts +33 -1
- package/src/hashline/grammar.lark +1 -1
- package/src/hashline/input.ts +11 -5
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/issue-pr-protocol.ts +577 -0
- package/src/internal-urls/router.ts +6 -3
- package/src/internal-urls/types.ts +22 -1
- package/src/main.ts +13 -9
- package/src/modes/acp/acp-agent.ts +361 -54
- package/src/modes/acp/acp-client-bridge.ts +152 -0
- package/src/modes/acp/acp-event-mapper.ts +180 -15
- package/src/modes/acp/terminal-auth.ts +37 -0
- package/src/modes/components/read-tool-group.ts +29 -1
- package/src/modes/controllers/command-controller.ts +14 -6
- package/src/modes/controllers/event-controller.ts +24 -11
- package/src/modes/controllers/extension-ui-controller.ts +8 -2
- package/src/modes/controllers/input-controller.ts +72 -39
- package/src/modes/interactive-mode.ts +71 -7
- package/src/modes/rpc/rpc-mode.ts +17 -2
- package/src/modes/types.ts +6 -2
- package/src/modes/utils/ui-helpers.ts +15 -3
- package/src/prompts/agents/designer.md +5 -5
- package/src/prompts/agents/explore.md +7 -7
- package/src/prompts/agents/init.md +9 -9
- package/src/prompts/agents/librarian.md +14 -14
- package/src/prompts/agents/plan.md +4 -4
- package/src/prompts/agents/reviewer.md +5 -5
- package/src/prompts/agents/task.md +10 -10
- package/src/prompts/commands/orchestrate.md +2 -2
- package/src/prompts/compaction/branch-summary.md +3 -3
- package/src/prompts/compaction/compaction-short-summary.md +7 -7
- package/src/prompts/compaction/compaction-summary-context.md +1 -1
- package/src/prompts/compaction/compaction-summary.md +5 -5
- package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
- package/src/prompts/compaction/compaction-update-summary.md +11 -11
- package/src/prompts/memories/consolidation.md +2 -2
- package/src/prompts/memories/read-path.md +1 -1
- package/src/prompts/memories/stage_one_input.md +1 -1
- package/src/prompts/memories/stage_one_system.md +5 -5
- package/src/prompts/review-request.md +4 -4
- package/src/prompts/system/agent-creation-architect.md +17 -17
- package/src/prompts/system/agent-creation-user.md +2 -2
- package/src/prompts/system/commit-message-system.md +2 -2
- package/src/prompts/system/custom-system-prompt.md +2 -2
- package/src/prompts/system/eager-todo.md +6 -6
- package/src/prompts/system/handoff-document.md +1 -1
- package/src/prompts/system/plan-mode-active.md +22 -21
- package/src/prompts/system/plan-mode-approved.md +4 -4
- package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
- package/src/prompts/system/plan-mode-reference.md +2 -2
- package/src/prompts/system/plan-mode-subagent.md +8 -8
- package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
- package/src/prompts/system/project-prompt.md +4 -4
- package/src/prompts/system/subagent-system-prompt.md +7 -7
- package/src/prompts/system/subagent-yield-reminder.md +4 -4
- package/src/prompts/system/system-prompt.md +72 -71
- package/src/prompts/system/ttsr-interrupt.md +1 -1
- package/src/prompts/tools/apply-patch.md +1 -1
- package/src/prompts/tools/ast-edit.md +3 -3
- package/src/prompts/tools/ast-grep.md +3 -3
- package/src/prompts/tools/browser.md +3 -3
- package/src/prompts/tools/checkpoint.md +3 -3
- package/src/prompts/tools/exit-plan-mode.md +2 -2
- package/src/prompts/tools/find.md +3 -3
- package/src/prompts/tools/github.md +2 -5
- package/src/prompts/tools/hashline.md +20 -20
- package/src/prompts/tools/image-gen.md +3 -3
- package/src/prompts/tools/irc.md +1 -1
- package/src/prompts/tools/lsp.md +2 -2
- package/src/prompts/tools/patch.md +6 -6
- package/src/prompts/tools/read.md +7 -7
- package/src/prompts/tools/replace.md +5 -5
- package/src/prompts/tools/retain.md +1 -1
- package/src/prompts/tools/rewind.md +2 -2
- package/src/prompts/tools/search.md +2 -2
- package/src/prompts/tools/ssh.md +2 -2
- package/src/prompts/tools/task.md +12 -6
- package/src/prompts/tools/web-search.md +2 -2
- package/src/prompts/tools/write.md +3 -3
- package/src/sdk.ts +69 -12
- package/src/session/agent-session.ts +231 -22
- package/src/session/client-bridge.ts +81 -0
- package/src/session/compaction/errors.ts +31 -0
- package/src/session/compaction/index.ts +1 -0
- package/src/slash-commands/acp-builtins.ts +46 -0
- package/src/slash-commands/builtin-registry.ts +699 -116
- package/src/slash-commands/helpers/context-report.ts +39 -0
- package/src/slash-commands/helpers/format.ts +23 -0
- package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
- package/src/slash-commands/helpers/mcp.ts +532 -0
- package/src/slash-commands/helpers/parse.ts +85 -0
- package/src/slash-commands/helpers/ssh.ts +193 -0
- package/src/slash-commands/helpers/todo.ts +279 -0
- package/src/slash-commands/helpers/usage-report.ts +91 -0
- package/src/slash-commands/types.ts +126 -0
- package/src/task/executor.ts +10 -3
- package/src/task/index.ts +29 -51
- package/src/task/render.ts +6 -3
- package/src/task/worktree.ts +170 -239
- package/src/tools/bash.ts +176 -2
- package/src/tools/browser/tab-supervisor.ts +13 -13
- package/src/tools/conflict-detect.ts +6 -6
- package/src/tools/fetch.ts +15 -4
- package/src/tools/find.ts +19 -1
- package/src/tools/gh-renderer.ts +0 -12
- package/src/tools/gh.ts +682 -176
- package/src/tools/github-cache.ts +548 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/read.ts +110 -27
- package/src/tools/write.ts +23 -1
- package/src/tui/code-cell.ts +70 -2
- package/src/utils/git.ts +5 -0
- package/src/task/isolation-backend.ts +0 -94
package/src/task/worktree.ts
CHANGED
|
@@ -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
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
+
/** Tear down a handle returned by {@link ensureIsolation}. */
|
|
351
|
+
export async function cleanupIsolation(handle: IsolationHandle): Promise<void> {
|
|
420
352
|
try {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
}
|