@nexrall/code-core 1.4.59 → 1.4.60
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/dist/agent/crossProcessLock.d.ts +45 -0
- package/dist/agent/crossProcessLock.d.ts.map +1 -0
- package/dist/agent/crossProcessLock.js +270 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +114 -16
- package/dist/agent/peerRegistry.d.ts +64 -0
- package/dist/agent/peerRegistry.d.ts.map +1 -0
- package/dist/agent/peerRegistry.js +238 -0
- package/dist/agent/peerTransport.d.ts +122 -0
- package/dist/agent/peerTransport.d.ts.map +1 -0
- package/dist/agent/peerTransport.js +348 -0
- package/dist/agent/sharedTasks.d.ts +63 -0
- package/dist/agent/sharedTasks.d.ts.map +1 -0
- package/dist/agent/sharedTasks.js +284 -0
- package/dist/agent/worktree.d.ts +111 -0
- package/dist/agent/worktree.d.ts.map +1 -0
- package/dist/agent/worktree.js +502 -0
- package/dist/agent/worktreeEnforcement.d.ts +29 -0
- package/dist/agent/worktreeEnforcement.d.ts.map +1 -0
- package/dist/agent/worktreeEnforcement.js +203 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +5 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/tools/executor.d.ts +4 -1
- package/dist/tools/executor.d.ts.map +1 -1
- package/dist/tools/executor.js +194 -4
- package/dist/types.d.ts +83 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports._worktreeInternal = void 0;
|
|
37
|
+
exports.randomWorktreeName = randomWorktreeName;
|
|
38
|
+
exports.createWorktree = createWorktree;
|
|
39
|
+
exports.worktreeHasWork = worktreeHasWork;
|
|
40
|
+
exports.removeWorktree = removeWorktree;
|
|
41
|
+
exports.enterWorktree = enterWorktree;
|
|
42
|
+
exports.pathEscapesWorktree = pathEscapesWorktree;
|
|
43
|
+
exports.listWorktrees = listWorktrees;
|
|
44
|
+
exports.findWorktreeByName = findWorktreeByName;
|
|
45
|
+
/**
|
|
46
|
+
* Worktree isolation — lets one session confine ALL its file mutations to a
|
|
47
|
+
* separate directory, so a SECOND session working the same repo can never be
|
|
48
|
+
* physically overwritten by the first. This is the primitive Claude Code's
|
|
49
|
+
* docs describe under "Isolate sessions with worktrees": messaging (see
|
|
50
|
+
* peer messaging, once that lands) is a SIGNAL channel — it tells another
|
|
51
|
+
* session what happened. Worktree isolation is a PHYSICAL guarantee — it
|
|
52
|
+
* makes "what happened" impossible to be "you overwrote my file", because
|
|
53
|
+
* the two sessions were never touching the same bytes on disk in the first
|
|
54
|
+
* place.
|
|
55
|
+
*
|
|
56
|
+
* Two backends, chosen automatically per workDir:
|
|
57
|
+
*
|
|
58
|
+
* 1. GIT-NATIVE (`git worktree add`) — used when workDir is inside a git
|
|
59
|
+
* repository. A real git worktree: its own checkout, its own branch,
|
|
60
|
+
* full history/remote sharing with the main checkout. Cleanup is
|
|
61
|
+
* `git worktree remove`. This mirrors Claude Code's own `--worktree`
|
|
62
|
+
* flag almost exactly (`.nexrall/worktrees/<name>/` instead of
|
|
63
|
+
* `.claude/worktrees/<name>/`, `worktree-<name>` branch naming kept the
|
|
64
|
+
* same since there's no reason to diverge on a convention this legible).
|
|
65
|
+
*
|
|
66
|
+
* 2. COPY-BASED (non-git) — used when workDir has no git ancestor at all
|
|
67
|
+
* (Perforce, SVN, no VCS, or a git repo the user explicitly opted out
|
|
68
|
+
* of). Claude Code solves this with a `WorktreeCreate` hook the PROJECT
|
|
69
|
+
* must supply; we go one step further and ALSO ship a zero-config
|
|
70
|
+
* default (a plain recursive copy, respecting .gitignore-style ignore
|
|
71
|
+
* patterns) so isolation works out of the box even in a project that
|
|
72
|
+
* hasn't written a hook. A project CAN still override this default with
|
|
73
|
+
* its own `.nexrall/hooks/worktree-create|enter|exit` command for a
|
|
74
|
+
* VCS-aware equivalent (Perforce `p4 client`, SVN working copies, etc.)
|
|
75
|
+
* — see `runWorktreeHook` below — exactly the escape hatch Claude Code's
|
|
76
|
+
* docs describe for "Non-git version control".
|
|
77
|
+
*
|
|
78
|
+
* STATE
|
|
79
|
+
* -----
|
|
80
|
+
* A worktree session's active isolation state lives in-memory in the caller
|
|
81
|
+
* (loop.ts / the client), passed as `WorktreeState` through AgentLoopOptions.
|
|
82
|
+
* This module only creates/removes/enforces — it does not own "which
|
|
83
|
+
* worktree is this session in right now", because that already has an
|
|
84
|
+
* owner in every client (ChatPanel's session state, the CLI's chat loop
|
|
85
|
+
* locals, TaskHost) and a second source of truth would drift from it.
|
|
86
|
+
*/
|
|
87
|
+
const fs = __importStar(require("fs"));
|
|
88
|
+
const path = __importStar(require("path"));
|
|
89
|
+
const child_process_1 = require("child_process");
|
|
90
|
+
const crypto = __importStar(require("crypto"));
|
|
91
|
+
const WORKTREE_ROOT_DIRNAME = '.nexrall/worktrees';
|
|
92
|
+
// Directories that must never be copied into a copy-based worktree: they are
|
|
93
|
+
// either huge/regenerable (node_modules, dist, build caches) or themselves a
|
|
94
|
+
// VCS metadata directory that would confuse the copy semantics. This list is
|
|
95
|
+
// intentionally conservative and copy-specific — NOT the same list search/glob
|
|
96
|
+
// tools use, because those exist to avoid noisy search results, while this one
|
|
97
|
+
// exists to keep worktree creation fast and avoid duplicating gigabytes of
|
|
98
|
+
// reproducible artifacts. A project can still get everything copied verbatim
|
|
99
|
+
// by using its OWN hook instead of the built-in copy fallback.
|
|
100
|
+
const COPY_SKIP_DIRS = new Set([
|
|
101
|
+
'.git', 'node_modules', 'dist', 'build', '.next', '__pycache__',
|
|
102
|
+
'.venv', 'venv', '.nexrall', '.svn', '.hg',
|
|
103
|
+
]);
|
|
104
|
+
function isGitRepo(dir) {
|
|
105
|
+
try {
|
|
106
|
+
const r = (0, child_process_1.spawnSync)('git', ['rev-parse', '--show-toplevel'], {
|
|
107
|
+
cwd: dir, encoding: 'utf-8', timeout: 5000,
|
|
108
|
+
});
|
|
109
|
+
if (r.status === 0 && r.stdout)
|
|
110
|
+
return { ok: true, root: r.stdout.trim() };
|
|
111
|
+
}
|
|
112
|
+
catch { /* git not installed or not a repo */ }
|
|
113
|
+
return { ok: false };
|
|
114
|
+
}
|
|
115
|
+
/** Generate a readable random name, same shape Claude Code uses ("bright-running-fox"). */
|
|
116
|
+
function randomWorktreeName() {
|
|
117
|
+
const adjectives = ['bright', 'quiet', 'brave', 'calm', 'swift', 'keen', 'bold', 'clear', 'gentle', 'sharp'];
|
|
118
|
+
const nouns = ['fox', 'river', 'falcon', 'harbor', 'meadow', 'ember', 'summit', 'cedar', 'otter', 'quartz'];
|
|
119
|
+
const a = adjectives[crypto.randomInt(adjectives.length)];
|
|
120
|
+
const n = nouns[crypto.randomInt(nouns.length)];
|
|
121
|
+
const suffix = crypto.randomBytes(2).toString('hex');
|
|
122
|
+
return `${a}-${n}-${suffix}`;
|
|
123
|
+
}
|
|
124
|
+
function safeName(name) {
|
|
125
|
+
// Same constraint a git branch name / directory basename both need: no
|
|
126
|
+
// path separators, no leading dot, nothing that could escape
|
|
127
|
+
// .nexrall/worktrees/ via `..`.
|
|
128
|
+
const cleaned = name.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/^\.+/, '');
|
|
129
|
+
return cleaned || randomWorktreeName();
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Look for a project-supplied override hook: `.nexrall/hooks/worktree-<phase>`
|
|
133
|
+
* (an executable script/command), same escape hatch Claude Code's docs
|
|
134
|
+
* describe for non-git version control. Returns the command string to run,
|
|
135
|
+
* or null if the project hasn't defined one for this phase.
|
|
136
|
+
*/
|
|
137
|
+
function findWorktreeHook(workDir, phase) {
|
|
138
|
+
try {
|
|
139
|
+
const settingsPath = path.join(workDir, '.nexrall', 'settings.json');
|
|
140
|
+
if (!fs.existsSync(settingsPath))
|
|
141
|
+
return null;
|
|
142
|
+
const raw = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
143
|
+
const hooks = (raw.worktreeHooks ?? {});
|
|
144
|
+
const cmd = hooks[phase];
|
|
145
|
+
return typeof cmd === 'string' && cmd.trim() ? cmd.trim() : null;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function runWorktreeHook(workDir, phase, env) {
|
|
152
|
+
const cmd = findWorktreeHook(workDir, phase);
|
|
153
|
+
if (!cmd)
|
|
154
|
+
return { ran: false, ok: false };
|
|
155
|
+
try {
|
|
156
|
+
const r = (0, child_process_1.spawnSync)(cmd, {
|
|
157
|
+
shell: true,
|
|
158
|
+
cwd: workDir,
|
|
159
|
+
timeout: 60000,
|
|
160
|
+
encoding: 'utf-8',
|
|
161
|
+
env: { ...process.env, ...env },
|
|
162
|
+
});
|
|
163
|
+
return { ran: true, ok: r.status === 0, output: (r.stdout ?? '') + (r.stderr ?? '') };
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return { ran: true, ok: false, output: err.message };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function copyRecursive(src, dest) {
|
|
170
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
171
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
172
|
+
if (entry.isDirectory() && COPY_SKIP_DIRS.has(entry.name))
|
|
173
|
+
continue;
|
|
174
|
+
const s = path.join(src, entry.name);
|
|
175
|
+
const d = path.join(dest, entry.name);
|
|
176
|
+
if (entry.isSymbolicLink()) {
|
|
177
|
+
try {
|
|
178
|
+
fs.symlinkSync(fs.readlinkSync(s), d);
|
|
179
|
+
}
|
|
180
|
+
catch { /* best-effort */ }
|
|
181
|
+
}
|
|
182
|
+
else if (entry.isDirectory()) {
|
|
183
|
+
copyRecursive(s, d);
|
|
184
|
+
}
|
|
185
|
+
else if (entry.isFile()) {
|
|
186
|
+
fs.copyFileSync(s, d);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Create an isolated worktree for `workDir`, choosing the best backend
|
|
192
|
+
* automatically:
|
|
193
|
+
* 1. A project-supplied `.nexrall/hooks` override for 'create', if defined
|
|
194
|
+
* (works for ANY VCS, or a fully custom isolation scheme).
|
|
195
|
+
* 2. Git-native `git worktree add`, if workDir is inside a git repo.
|
|
196
|
+
* 3. Plain recursive copy (skips COPY_SKIP_DIRS), as a zero-config
|
|
197
|
+
* fallback that always works.
|
|
198
|
+
*/
|
|
199
|
+
function createWorktree(workDir, requestedName) {
|
|
200
|
+
const name = safeName(requestedName?.trim() || randomWorktreeName());
|
|
201
|
+
const root = path.join(workDir, WORKTREE_ROOT_DIRNAME);
|
|
202
|
+
const worktreePath = path.join(root, name);
|
|
203
|
+
if (fs.existsSync(worktreePath)) {
|
|
204
|
+
return { ok: false, error: `A worktree named "${name}" already exists at ${worktreePath}.` };
|
|
205
|
+
}
|
|
206
|
+
// 1. Project hook takes priority — even in a git repo, a project may want
|
|
207
|
+
// VCS-specific behaviour (e.g. Perforce) instead of the git-native path.
|
|
208
|
+
const hookEnv = { NEXRALL_WORKTREE_NAME: name, NEXRALL_WORKTREE_PATH: worktreePath, NEXRALL_MAIN_CHECKOUT: workDir };
|
|
209
|
+
const hook = runWorktreeHook(workDir, 'create', hookEnv);
|
|
210
|
+
if (hook.ran) {
|
|
211
|
+
if (!hook.ok)
|
|
212
|
+
return { ok: false, error: `worktree-create hook failed: ${hook.output ?? 'unknown error'}` };
|
|
213
|
+
if (!fs.existsSync(worktreePath)) {
|
|
214
|
+
return { ok: false, error: `worktree-create hook exited 0 but did not create ${worktreePath}.` };
|
|
215
|
+
}
|
|
216
|
+
const state = { worktreePath, mainCheckout: workDir, backend: 'hook', name };
|
|
217
|
+
writeMetadata(state);
|
|
218
|
+
return { ok: true, state };
|
|
219
|
+
}
|
|
220
|
+
// 2. Git-native.
|
|
221
|
+
const git = isGitRepo(workDir);
|
|
222
|
+
if (git.ok && git.root) {
|
|
223
|
+
fs.mkdirSync(root, { recursive: true });
|
|
224
|
+
const branch = `worktree-${name}`;
|
|
225
|
+
const r = (0, child_process_1.spawnSync)('git', ['worktree', 'add', '-b', branch, worktreePath], { cwd: git.root, encoding: 'utf-8', timeout: 30000 });
|
|
226
|
+
if (r.status !== 0) {
|
|
227
|
+
return { ok: false, error: `git worktree add failed: ${(r.stderr ?? r.stdout ?? '').trim() || 'unknown error'}` };
|
|
228
|
+
}
|
|
229
|
+
const state = { worktreePath, mainCheckout: workDir, backend: 'git', branch, name };
|
|
230
|
+
writeMetadata(state);
|
|
231
|
+
return { ok: true, state };
|
|
232
|
+
}
|
|
233
|
+
// 3. Zero-config copy fallback — always works, no git and no hook required.
|
|
234
|
+
try {
|
|
235
|
+
copyRecursive(workDir, worktreePath);
|
|
236
|
+
const state = { worktreePath, mainCheckout: workDir, backend: 'copy', name };
|
|
237
|
+
writeMetadata(state);
|
|
238
|
+
return { ok: true, state };
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
return { ok: false, error: `Failed to create copy-based worktree: ${err.message}` };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** Whether a worktree has anything in it worth not silently discarding. */
|
|
245
|
+
function worktreeHasWork(state) {
|
|
246
|
+
if (state.backend === 'git') {
|
|
247
|
+
try {
|
|
248
|
+
const status = (0, child_process_1.spawnSync)('git', ['status', '--porcelain'], {
|
|
249
|
+
cwd: state.worktreePath, encoding: 'utf-8', timeout: 10000,
|
|
250
|
+
});
|
|
251
|
+
// Filter our own bookkeeping file out of the porcelain output before
|
|
252
|
+
// deciding — see writeMetadata's comment on why this is filtered here
|
|
253
|
+
// rather than via `.git/info/exclude` (which is shared repo-wide and
|
|
254
|
+
// would leak into the main checkout's status).
|
|
255
|
+
const meaningfulLines = (status.stdout ?? '')
|
|
256
|
+
.split('\n')
|
|
257
|
+
.filter((line) => line.trim().length > 0 && !line.includes(METADATA_FILENAME));
|
|
258
|
+
if (meaningfulLines.length > 0)
|
|
259
|
+
return true;
|
|
260
|
+
// Also check for unpushed commits on the worktree's own branch.
|
|
261
|
+
const unpushed = (0, child_process_1.spawnSync)('git', ['log', '@{u}..HEAD', '--oneline'], { cwd: state.worktreePath, encoding: 'utf-8', timeout: 10000 });
|
|
262
|
+
if (unpushed.status === 0 && unpushed.stdout && unpushed.stdout.trim().length > 0)
|
|
263
|
+
return true;
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return true; // can't verify — be conservative like Claude Code's own docs describe
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Copy-based / hook-based: can't diff against an origin, so any content at
|
|
271
|
+
// all counts as "has work" — conservative on purpose (see Claude Code's
|
|
272
|
+
// "worktree's state can't be verified" case: it prompts rather than assumes clean).
|
|
273
|
+
// Our own bookkeeping file doesn't count — a freshly created worktree with
|
|
274
|
+
// nothing done in it yet must still read as clean.
|
|
275
|
+
try {
|
|
276
|
+
return fs.readdirSync(state.worktreePath).some((f) => f !== METADATA_FILENAME);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function removeWorktree(state, opts = {}) {
|
|
283
|
+
if (!opts.force && worktreeHasWork(state)) {
|
|
284
|
+
return { ok: false, hadWork: true };
|
|
285
|
+
}
|
|
286
|
+
const hookEnv = { NEXRALL_WORKTREE_NAME: state.name, NEXRALL_WORKTREE_PATH: state.worktreePath, NEXRALL_MAIN_CHECKOUT: state.mainCheckout };
|
|
287
|
+
if (state.backend === 'hook') {
|
|
288
|
+
const hook = runWorktreeHook(state.mainCheckout, 'exit', hookEnv);
|
|
289
|
+
if (hook.ran && !hook.ok)
|
|
290
|
+
return { ok: false, error: `worktree-exit hook failed: ${hook.output ?? 'unknown error'}` };
|
|
291
|
+
// A hook backend is responsible for its own directory removal during 'exit'.
|
|
292
|
+
// If it left the directory in place, that's the hook's choice to make (it
|
|
293
|
+
// may want to keep worktrees around for its own reasons) — don't second-guess it.
|
|
294
|
+
return { ok: true };
|
|
295
|
+
}
|
|
296
|
+
if (state.backend === 'git') {
|
|
297
|
+
// worktreeHasWork (checked above) already treats our own bookkeeping file
|
|
298
|
+
// as invisible, but `git worktree remove` itself does its OWN untracked-
|
|
299
|
+
// file check and does not know about that exception — it would refuse a
|
|
300
|
+
// "clean" (per worktreeHasWork) worktree purely because our metadata
|
|
301
|
+
// file is untracked. Remove it first: by this point it has served its
|
|
302
|
+
// only purpose (letting a fresh process reconstruct this WorktreeState),
|
|
303
|
+
// and the worktree itself is one syscall from being deleted anyway.
|
|
304
|
+
try {
|
|
305
|
+
fs.unlinkSync(metadataPath(state.worktreePath));
|
|
306
|
+
}
|
|
307
|
+
catch { /* fine if already gone */ }
|
|
308
|
+
const r = (0, child_process_1.spawnSync)('git', ['worktree', 'remove', state.worktreePath, ...(opts.force ? ['--force'] : [])], { cwd: state.mainCheckout, encoding: 'utf-8', timeout: 30000 });
|
|
309
|
+
if (r.status !== 0) {
|
|
310
|
+
return { ok: false, error: `git worktree remove failed: ${(r.stderr ?? r.stdout ?? '').trim() || 'unknown error'}` };
|
|
311
|
+
}
|
|
312
|
+
// Also drop the branch — an unnamed worktree's branch is disposable once
|
|
313
|
+
// its worktree is gone (mirrors Claude Code's automatic cleanup for
|
|
314
|
+
// unnamed sessions). Best-effort: a shared/renamed branch failing to
|
|
315
|
+
// delete is not worth surfacing as an error.
|
|
316
|
+
if (state.branch) {
|
|
317
|
+
(0, child_process_1.spawnSync)('git', ['branch', '-D', state.branch], { cwd: state.mainCheckout, encoding: 'utf-8', timeout: 10000 });
|
|
318
|
+
}
|
|
319
|
+
return { ok: true };
|
|
320
|
+
}
|
|
321
|
+
// Copy-based.
|
|
322
|
+
try {
|
|
323
|
+
fs.rmSync(state.worktreePath, { recursive: true, force: true });
|
|
324
|
+
return { ok: true };
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
return { ok: false, error: err.message };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Called when a session ENTERS an already-created worktree (including
|
|
332
|
+
* re-entering across a resume). Fires the project's 'enter' hook if defined
|
|
333
|
+
* — informational only for the git/copy backends (nothing to actually do:
|
|
334
|
+
* the directory already exists and is already the session's cwd), but
|
|
335
|
+
* REQUIRED for the 'hook' backend, which may need to check out/refresh
|
|
336
|
+
* VCS-specific state each time.
|
|
337
|
+
*/
|
|
338
|
+
function enterWorktree(state) {
|
|
339
|
+
if (state.backend !== 'hook')
|
|
340
|
+
return { ok: true };
|
|
341
|
+
const hookEnv = { NEXRALL_WORKTREE_NAME: state.name, NEXRALL_WORKTREE_PATH: state.worktreePath, NEXRALL_MAIN_CHECKOUT: state.mainCheckout };
|
|
342
|
+
const hook = runWorktreeHook(state.mainCheckout, 'enter', hookEnv);
|
|
343
|
+
if (hook.ran && !hook.ok)
|
|
344
|
+
return { ok: false, error: `worktree-enter hook failed: ${hook.output ?? 'unknown error'}` };
|
|
345
|
+
return { ok: true };
|
|
346
|
+
}
|
|
347
|
+
function isWithin(real, ancestorReal) {
|
|
348
|
+
if (real === ancestorReal)
|
|
349
|
+
return true;
|
|
350
|
+
const rel = path.relative(ancestorReal, real);
|
|
351
|
+
// Within the ancestor iff the relative path does NOT start with '..' and is
|
|
352
|
+
// NOT itself absolute (path.relative returns an absolute path on Windows
|
|
353
|
+
// when the two inputs are on different drives, which correctly means "not
|
|
354
|
+
// within").
|
|
355
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Resolve `p` to a real, symlink-free absolute path, even when `p` itself
|
|
359
|
+
* does not exist yet (e.g. a `write_file` target about to be created).
|
|
360
|
+
*
|
|
361
|
+
* A plain `fs.realpathSync` fails outright on a non-existent path, and a
|
|
362
|
+
* plain `path.resolve` fallback for that case is not enough on its own: on
|
|
363
|
+
* macOS, `/tmp` is itself a symlink to `/private/tmp`, so a target
|
|
364
|
+
* `/tmp/x/new-file.txt` (lexical, unresolved) and its EXISTING sibling
|
|
365
|
+
* directory `/tmp/x` (which realpathSync resolves to `/private/tmp/x`) would
|
|
366
|
+
* compare as two different roots even though they are the same directory —
|
|
367
|
+
* silently defeating `isWithin` for any not-yet-created path under a
|
|
368
|
+
* symlinked ancestor. The fix: walk up to the NEAREST ancestor that DOES
|
|
369
|
+
* exist, realpath just that ancestor, then re-append the non-existent
|
|
370
|
+
* remainder lexically (a component that doesn't exist cannot itself be a
|
|
371
|
+
* symlink to resolve).
|
|
372
|
+
*/
|
|
373
|
+
function realOrLexical(p) {
|
|
374
|
+
try {
|
|
375
|
+
return fs.realpathSync(p);
|
|
376
|
+
}
|
|
377
|
+
catch { /* fall through — p (or a piece of it) doesn't exist yet */ }
|
|
378
|
+
let dir = path.dirname(p);
|
|
379
|
+
const tail = [path.basename(p)];
|
|
380
|
+
for (;;) {
|
|
381
|
+
try {
|
|
382
|
+
const realDir = fs.realpathSync(dir);
|
|
383
|
+
return path.join(realDir, ...tail.reverse());
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
const parent = path.dirname(dir);
|
|
387
|
+
if (parent === dir)
|
|
388
|
+
return path.resolve(p); // reached filesystem root without finding an existing ancestor
|
|
389
|
+
tail.push(path.basename(dir));
|
|
390
|
+
dir = parent;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* True when `absPath` resolves to somewhere inside the MAIN CHECKOUT that
|
|
396
|
+
* isolation is meant to protect, while NOT being inside the currently active
|
|
397
|
+
* worktree — the check `blockOutsideWorktree` (in executor.ts) uses to
|
|
398
|
+
* refuse a write/bash-cwd that targets the wrong tree. This is exactly the
|
|
399
|
+
* check Claude Code's own worktree docs describe: "blocks an Edit, Write, or
|
|
400
|
+
* NotebookEdit that targets a path in the main checkout" — NOT "any path
|
|
401
|
+
* outside the worktree" (a write to some unrelated directory elsewhere on
|
|
402
|
+
* disk isn't the hazard this specific check exists to prevent; ordinary
|
|
403
|
+
* sandbox/permission rules still govern that separately).
|
|
404
|
+
*
|
|
405
|
+
* The git/copy backends nest the worktree INSIDE the main checkout
|
|
406
|
+
* (`<mainCheckout>/.nexrall/worktrees/<name>`), so "inside mainCheckout" on
|
|
407
|
+
* its own would wrongly flag every path inside a perfectly legitimate
|
|
408
|
+
* worktree — this function excludes the active worktree's own subtree first.
|
|
409
|
+
*
|
|
410
|
+
* Deliberately resolves symlinks (fs.realpathSync) so a symlink planted
|
|
411
|
+
* INSIDE the worktree that points back at the main checkout can't be used to
|
|
412
|
+
* defeat the check — the same class of bypass Claude Code's own worktree
|
|
413
|
+
* docs call out for git redirects.
|
|
414
|
+
*/
|
|
415
|
+
function pathEscapesWorktree(absPath, state) {
|
|
416
|
+
const real = realOrLexical(absPath);
|
|
417
|
+
const worktreeReal = realOrLexical(state.worktreePath);
|
|
418
|
+
if (isWithin(real, worktreeReal))
|
|
419
|
+
return false; // inside the active worktree — never escaping
|
|
420
|
+
const mainReal = realOrLexical(state.mainCheckout);
|
|
421
|
+
return isWithin(real, mainReal); // outside the worktree AND inside the main checkout — this IS the hazard
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Small metadata file written next to a worktree's own contents
|
|
425
|
+
* (`.nexrall-worktree.json` at its root) so a LATER process invocation —
|
|
426
|
+
* `nex --worktree <name>` in a brand-new CLI process, VS Code re-opening the
|
|
427
|
+
* same project, a resumed session — can reconstruct the WorktreeState
|
|
428
|
+
* without re-deriving `backend`/`branch` from scratch. This is what makes
|
|
429
|
+
* "resume into an existing worktree" possible at all: the git/copy/hook
|
|
430
|
+
* distinction is a property of HOW it was created, not something a fresh
|
|
431
|
+
* process can re-detect reliably from the directory alone (a copy-based
|
|
432
|
+
* worktree contains no marker of its own; nothing stops a git worktree's
|
|
433
|
+
* branch from being renamed after the fact).
|
|
434
|
+
*/
|
|
435
|
+
const METADATA_FILENAME = '.nexrall-worktree.json';
|
|
436
|
+
function metadataPath(worktreePath) {
|
|
437
|
+
return path.join(worktreePath, METADATA_FILENAME);
|
|
438
|
+
}
|
|
439
|
+
function writeMetadata(state) {
|
|
440
|
+
try {
|
|
441
|
+
fs.writeFileSync(metadataPath(state.worktreePath), JSON.stringify(state));
|
|
442
|
+
// NOTE: deliberately NOT using `.git/info/exclude` to hide this file from
|
|
443
|
+
// git status. `info/exclude` is a REPO-WIDE file shared by every linked
|
|
444
|
+
// worktree (`git rev-parse --git-path info/exclude` resolves to the SAME
|
|
445
|
+
// path from the main checkout and from any worktree) — writing to it
|
|
446
|
+
// would leak this exclusion into the main checkout's own `git status`
|
|
447
|
+
// too, which is exactly the cross-contamination worktree isolation
|
|
448
|
+
// exists to prevent. Instead, worktreeHasWork (below) filters this one
|
|
449
|
+
// filename out of `git status --porcelain`'s output directly — no shared
|
|
450
|
+
// state touched, no risk to the main checkout.
|
|
451
|
+
}
|
|
452
|
+
catch { /* best-effort */ }
|
|
453
|
+
}
|
|
454
|
+
function readMetadata(worktreePath) {
|
|
455
|
+
try {
|
|
456
|
+
const raw = JSON.parse(fs.readFileSync(metadataPath(worktreePath), 'utf-8'));
|
|
457
|
+
if (raw && typeof raw.worktreePath === 'string' && typeof raw.mainCheckout === 'string')
|
|
458
|
+
return raw;
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* List every worktree registered under `workDir`'s `.nexrall/worktrees/`,
|
|
467
|
+
* reconstructed from each one's metadata file. Skips (rather than throws on)
|
|
468
|
+
* a directory with no readable metadata — e.g. one a user created by hand,
|
|
469
|
+
* or debris from an older version — since one bad entry must not hide every
|
|
470
|
+
* legitimate one from a session picker.
|
|
471
|
+
*/
|
|
472
|
+
function listWorktrees(workDir) {
|
|
473
|
+
const root = path.join(workDir, WORKTREE_ROOT_DIRNAME);
|
|
474
|
+
let entries;
|
|
475
|
+
try {
|
|
476
|
+
entries = fs.readdirSync(root);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return [];
|
|
480
|
+
}
|
|
481
|
+
const out = [];
|
|
482
|
+
for (const name of entries) {
|
|
483
|
+
const wt = path.join(root, name);
|
|
484
|
+
try {
|
|
485
|
+
if (!fs.statSync(wt).isDirectory())
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const meta = readMetadata(wt);
|
|
492
|
+
if (meta)
|
|
493
|
+
out.push(meta);
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
/** Find a previously created worktree by name, for resuming into it in a fresh process. */
|
|
498
|
+
function findWorktreeByName(workDir, name) {
|
|
499
|
+
return listWorktrees(workDir).find((w) => w.name === name) ?? null;
|
|
500
|
+
}
|
|
501
|
+
exports._worktreeInternal = { isGitRepo, safeName, copyRecursive, findWorktreeHook, COPY_SKIP_DIRS, WORKTREE_ROOT_DIRNAME, metadataPath };
|
|
502
|
+
//# sourceMappingURL=worktree.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { WorktreeState } from './worktree';
|
|
2
|
+
export interface WorktreeRefusal {
|
|
3
|
+
reason: 'edit-outside-worktree' | 'bash-cwd-outside-worktree' | 'git-redirect-outside-worktree' | 'bash-shape-unverifiable';
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Check ONE tool call against active worktree isolation. Returns null when
|
|
8
|
+
* the call is fine (either no isolation is active, or the call stays inside
|
|
9
|
+
* the worktree). Call this the same way checkPlanMode is called — BEFORE
|
|
10
|
+
* requestPermission, since no user answer at that prompt can lawfully unlock
|
|
11
|
+
* a session-wide isolation boundary.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkWorktreeIsolation(tool: string, input: Record<string, unknown>, state: WorktreeState | undefined, workDir?: string): WorktreeRefusal | null;
|
|
14
|
+
declare function resolvesIntoMainCheckout(candidate: string, state: WorktreeState): boolean;
|
|
15
|
+
declare function checkBashAgainstWorktree(command: string, state: WorktreeState): WorktreeRefusal | null;
|
|
16
|
+
/**
|
|
17
|
+
* Prepended to nexrall.md when worktree isolation is active — same rationale
|
|
18
|
+
* as PLAN_MODE_INSTRUCTIONS in planMode.ts: telling the model up front is not
|
|
19
|
+
* the enforcement (checkWorktreeIsolation at the call site is), it exists so
|
|
20
|
+
* the model plans its work AROUND the boundary instead of discovering it one
|
|
21
|
+
* refused tool call at a time.
|
|
22
|
+
*/
|
|
23
|
+
export declare function worktreeInstructions(state: WorktreeState): string;
|
|
24
|
+
export declare const _worktreeEnforcementInternal: {
|
|
25
|
+
checkBashAgainstWorktree: typeof checkBashAgainstWorktree;
|
|
26
|
+
resolvesIntoMainCheckout: typeof resolvesIntoMainCheckout;
|
|
27
|
+
};
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=worktreeEnforcement.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worktreeEnforcement.d.ts","sourceRoot":"","sources":["../../src/agent/worktreeEnforcement.ts"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAGhD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,uBAAuB,GAAG,2BAA2B,GAAG,+BAA+B,GAAG,yBAAyB,CAAC;IAC5H,OAAO,EAAE,MAAM,CAAC;CACjB;AAwBD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,KAAK,EAAE,aAAa,GAAG,SAAS,EAChC,OAAO,CAAC,EAAE,MAAM,GACf,eAAe,GAAG,IAAI,CAuBxB;AA2BD,iBAAS,wBAAwB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,OAAO,CAIlF;AAED,iBAAS,wBAAwB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,eAAe,GAAG,IAAI,CAyC/F;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAcjE;AAED,eAAO,MAAM,4BAA4B;;;CAAyD,CAAC"}
|