@bill10/agent-007 0.6.2
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/LICENSE +21 -0
- package/README.md +222 -0
- package/VERSION +1 -0
- package/bin/adduser.js +69 -0
- package/bin/agent-007.js +88 -0
- package/lib/cron.js +189 -0
- package/lib/helpers.js +541 -0
- package/lib/jobs.js +965 -0
- package/package.json +63 -0
- package/public/app.js +650 -0
- package/public/assets/characters/LICENSE +21 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/furniture/bookshelf.png +0 -0
- package/public/assets/furniture/cactus.png +0 -0
- package/public/assets/furniture/chair_back.png +0 -0
- package/public/assets/furniture/chair_front.png +0 -0
- package/public/assets/furniture/chair_side.png +0 -0
- package/public/assets/furniture/coffee.png +0 -0
- package/public/assets/furniture/coffee_table.png +0 -0
- package/public/assets/furniture/desk.png +0 -0
- package/public/assets/furniture/desk2.png +0 -0
- package/public/assets/furniture/plant_2.png +0 -0
- package/public/assets/furniture/sofa_front.png +0 -0
- package/public/assets/furniture/sofa_side.png +0 -0
- package/public/assets/furniture/table_front.png +0 -0
- package/public/index.html +245 -0
- package/public/modules/auth.js +83 -0
- package/public/modules/explorer.js +760 -0
- package/public/modules/jobs.js +971 -0
- package/public/modules/office.js +2154 -0
- package/public/modules/paths.js +20 -0
- package/public/modules/shortcuts.js +54 -0
- package/public/modules/state.js +75 -0
- package/public/modules/terminal.js +651 -0
- package/public/modules/voice.js +393 -0
- package/public/modules/ws.js +56 -0
- package/public/style.css +1843 -0
- package/server/agent-mcp-bridge.js +45 -0
- package/server/agent-mcp.js +184 -0
- package/server/agent-transcripts.js +195 -0
- package/server/approvals.js +155 -0
- package/server/auth.js +162 -0
- package/server/billion.js +176 -0
- package/server/claude-trust.js +66 -0
- package/server/command-path.js +102 -0
- package/server/config.js +184 -0
- package/server/direct-run.js +33 -0
- package/server/git.js +630 -0
- package/server/http.js +276 -0
- package/server/jobs.js +2044 -0
- package/server/mcp.js +596 -0
- package/server/messages.js +319 -0
- package/server/permission-hook.js +47 -0
- package/server/pty.js +360 -0
- package/server/state.js +104 -0
- package/server/ws.js +583 -0
- package/server.js +306 -0
- package/templates/billion/COMPANY.md +14 -0
- package/templates/billion/STATE.md +17 -0
- package/templates/billion/charter.md +232 -0
- package/templates/billion/owner.md +11 -0
package/server/git.js
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
// Git operations — exec, worktree management, file tree scanning, diffs
|
|
2
|
+
|
|
3
|
+
import { execFile as execFileCb } from 'child_process';
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
5
|
+
import { mkdirSync } from 'fs';
|
|
6
|
+
import { basename, isAbsolute, join, sep } from 'path';
|
|
7
|
+
import { realpathSync } from 'fs';
|
|
8
|
+
import { createHash } from 'crypto';
|
|
9
|
+
import {
|
|
10
|
+
config, orphans, sessions, knownConflictKeys,
|
|
11
|
+
codenamePool, cocktailPool, colorCycler,
|
|
12
|
+
GIT_AUTO_TIMEOUT, GIT_USER_TIMEOUT, WORKTREE_DIR,
|
|
13
|
+
} from './state.js';
|
|
14
|
+
import { saveConfig, syncOrphansToConfig } from './config.js';
|
|
15
|
+
import { parseGitStatus, buildFileTree, repoDirName } from '../lib/helpers.js';
|
|
16
|
+
|
|
17
|
+
// --- Git Exec ---
|
|
18
|
+
export function gitExec(args, opts = {}) {
|
|
19
|
+
const timeout = opts.timeout || GIT_AUTO_TIMEOUT;
|
|
20
|
+
const cwd = opts.cwd;
|
|
21
|
+
// LC_ALL=C so stderr stays English. createWorktree decides whether to retry by
|
|
22
|
+
// matching git's message, and distro git ships translated message catalogs — a
|
|
23
|
+
// non-English operator would otherwise turn every branch collision into a hard
|
|
24
|
+
// spawn failure.
|
|
25
|
+
// GIT_TERMINAL_PROMPT=0 so a command that needs credentials fails fast instead
|
|
26
|
+
// of blocking on a prompt nobody can answer. Only the pre-spawn fetch talks to
|
|
27
|
+
// a network today, but a server must never be one auth prompt away from
|
|
28
|
+
// hanging, so it is set for every git call.
|
|
29
|
+
const env = { ...process.env, LC_ALL: 'C', LANGUAGE: '', GIT_TERMINAL_PROMPT: '0' };
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
execFileCb('git', args, { timeout, maxBuffer: 1024 * 1024, cwd, env }, (err, stdout, stderr) => {
|
|
32
|
+
if (err) {
|
|
33
|
+
err.stderr = stderr;
|
|
34
|
+
reject(err);
|
|
35
|
+
} else {
|
|
36
|
+
resolve(stdout);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Path Validation ---
|
|
43
|
+
export async function validateRepoPath(repoPath) {
|
|
44
|
+
if (!repoPath || typeof repoPath !== 'string') return { valid: false, error: 'Path is required' };
|
|
45
|
+
// isAbsolute rather than a leading-slash test: an absolute Windows path is
|
|
46
|
+
// `C:\repo` or `\\server\share`, neither of which starts with a slash, so the
|
|
47
|
+
// old check rejected every path on Windows. The point of the guard is to stop
|
|
48
|
+
// a relative path resolving against the server's own cwd, which isAbsolute
|
|
49
|
+
// enforces on both platforms.
|
|
50
|
+
if (!isAbsolute(repoPath)) return { valid: false, error: 'Path must be absolute' };
|
|
51
|
+
if (!existsSync(repoPath)) return { valid: false, error: 'Directory does not exist' };
|
|
52
|
+
let resolvedPath;
|
|
53
|
+
try { resolvedPath = realpathSync(repoPath); } catch { return { valid: false, error: 'Cannot resolve path' }; }
|
|
54
|
+
try { await gitExec(['-C', resolvedPath, 'rev-parse', '--git-dir']); } catch { return { valid: false, error: 'Not a git repository' }; }
|
|
55
|
+
try {
|
|
56
|
+
await gitExec(['-C', resolvedPath, 'rev-parse', 'HEAD']);
|
|
57
|
+
} catch {
|
|
58
|
+
try {
|
|
59
|
+
await gitExec(['-C', resolvedPath, 'commit', '--allow-empty', '-m', 'Initial commit']);
|
|
60
|
+
console.log(`Created initial commit for empty repo: ${resolvedPath}`);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { valid: false, error: 'Repository has no commits and could not create one' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { valid: true, resolvedPath };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- Repo Management ---
|
|
69
|
+
|
|
70
|
+
// How long to wait for the pre-spawn fetch. Longer than GIT_AUTO_TIMEOUT because
|
|
71
|
+
// this one talks to the network, but bounded: a slow or unreachable remote must
|
|
72
|
+
// delay a spawn, never block it. On timeout we fall back to local refs.
|
|
73
|
+
//
|
|
74
|
+
// This sits inside the spawn path, so it has to fit the client's spawn budget
|
|
75
|
+
// (see the spawn-form timeout in public/app.js) with room left for the worktree
|
|
76
|
+
// add and the PTY spawn. Raise one without the other and a slow fetch makes the
|
|
77
|
+
// UI report a timeout for a spawn that is still on its way to succeeding.
|
|
78
|
+
const FETCH_TIMEOUT = 10000;
|
|
79
|
+
|
|
80
|
+
// The repo's base branch, by name (no `origin/` prefix). Asks the remote's own
|
|
81
|
+
// HEAD first, so a repo whose default is `develop` or `trunk` is handled without
|
|
82
|
+
// a hardcoded list; falls back to the two common names, then null.
|
|
83
|
+
export async function resolveBaseBranch(repoPath) {
|
|
84
|
+
try {
|
|
85
|
+
const ref = await gitExec(['-C', repoPath, 'symbolic-ref', 'refs/remotes/origin/HEAD']);
|
|
86
|
+
const name = ref.trim().replace('refs/remotes/origin/', '');
|
|
87
|
+
if (name) return name;
|
|
88
|
+
} catch (_) {}
|
|
89
|
+
for (const candidate of ['main', 'master']) {
|
|
90
|
+
try {
|
|
91
|
+
await gitExec(['-C', repoPath, 'rev-parse', '--verify', candidate]);
|
|
92
|
+
return candidate;
|
|
93
|
+
} catch (_) {}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// What a new agent's branch should start FROM.
|
|
99
|
+
//
|
|
100
|
+
// `worktree add -b <name>` with no start-point uses HEAD of the main checkout —
|
|
101
|
+
// whatever the user happens to have checked out there. That is wrong twice over:
|
|
102
|
+
// a local base branch that has fallen behind silently starts the agent on
|
|
103
|
+
// superseded code, and a user sitting on an unrelated feature branch hands the
|
|
104
|
+
// agent that branch's half-finished work, which then rides along in its PR.
|
|
105
|
+
//
|
|
106
|
+
// So: refresh the remote refs, then branch from `origin/<base>` — what a person
|
|
107
|
+
// does by hand with `git fetch && git checkout -b feature origin/main`.
|
|
108
|
+
//
|
|
109
|
+
// Every step degrades instead of failing. No network, no remote, or a repo that
|
|
110
|
+
// has never been pushed falls back to the local base branch, and a repo with
|
|
111
|
+
// neither falls back to null, which means "use HEAD" — the old behaviour, which
|
|
112
|
+
// is still correct for a brand-new repo with a single branch.
|
|
113
|
+
async function resolveStartPoint(repoPath, explicit) {
|
|
114
|
+
// An explicitly requested start-point is the user's call, but it arrives from
|
|
115
|
+
// a WebSocket message and lands in a git argument list, so it is checked
|
|
116
|
+
// twice before use.
|
|
117
|
+
//
|
|
118
|
+
// A leading dash is refused outright: `worktree add <path> -b <name> --detach`
|
|
119
|
+
// parses that as an OPTION, not a commit-ish (git says "options '-b', '-B',
|
|
120
|
+
// and '--detach' cannot be used together"), and a `--` delimiter does not
|
|
121
|
+
// protect this position. No legitimate value is affected — a git ref name
|
|
122
|
+
// cannot begin with a dash.
|
|
123
|
+
//
|
|
124
|
+
// Then it must actually resolve. Without that a typo fails later inside
|
|
125
|
+
// `worktree add` with a message about a branch the user never typed; here it
|
|
126
|
+
// fails immediately, naming what they asked for.
|
|
127
|
+
if (explicit) {
|
|
128
|
+
if (explicit.startsWith('-')) {
|
|
129
|
+
return { error: `Invalid start point "${explicit}" — a branch, tag or commit cannot start with "-"` };
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
await gitExec(['-C', repoPath, 'rev-parse', '--verify', '--quiet', `${explicit}^{commit}`]);
|
|
133
|
+
} catch (_) {
|
|
134
|
+
return { error: `Start point "${explicit}" not found in this repository` };
|
|
135
|
+
}
|
|
136
|
+
return { ref: explicit };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await gitExec(['-C', repoPath, 'fetch', 'origin', '--quiet'], { timeout: FETCH_TIMEOUT });
|
|
141
|
+
} catch (_) {
|
|
142
|
+
// Offline, no remote, or auth prompt suppressed. Local refs may be stale,
|
|
143
|
+
// which is still better than HEAD of an unrelated branch.
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const base = await resolveBaseBranch(repoPath);
|
|
147
|
+
if (!base) return { ref: null };
|
|
148
|
+
for (const ref of [`origin/${base}`, base]) {
|
|
149
|
+
try {
|
|
150
|
+
await gitExec(['-C', repoPath, 'rev-parse', '--verify', ref]);
|
|
151
|
+
return { ref };
|
|
152
|
+
} catch (_) {}
|
|
153
|
+
}
|
|
154
|
+
return { ref: null };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The `${gitUser}` half of every branch this app creates, e.g. `bill/vesper`.
|
|
158
|
+
// Falls back to `agent` when the repo has no user.name configured.
|
|
159
|
+
async function branchPrefix(repoPath) {
|
|
160
|
+
try {
|
|
161
|
+
const name = (await gitExec(['-C', repoPath, 'config', 'user.name'])).trim().toLowerCase().replace(/\s+/g, '-');
|
|
162
|
+
// An UNSET user.name exits non-zero and lands in the catch, but one set to
|
|
163
|
+
// the empty string exits 0 with empty output. Without this the prefix is
|
|
164
|
+
// '', every candidate becomes `/vesper`, and git rejects all of them
|
|
165
|
+
// ("not a valid branch name") — so every spawn in that repo fails after
|
|
166
|
+
// walking the whole pool.
|
|
167
|
+
return name || 'agent';
|
|
168
|
+
} catch (_) {
|
|
169
|
+
return 'agent';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function addRepo(repoPath, broadcast) {
|
|
174
|
+
const validation = await validateRepoPath(repoPath);
|
|
175
|
+
if (!validation.valid) return { error: validation.error };
|
|
176
|
+
const resolved = validation.resolvedPath;
|
|
177
|
+
if (config.repos.some(r => r.path === resolved)) {
|
|
178
|
+
return { ok: true, path: resolved, slug: basename(resolved) };
|
|
179
|
+
}
|
|
180
|
+
config.repos.push({ path: resolved, addedAt: new Date().toISOString() });
|
|
181
|
+
saveConfig(broadcast);
|
|
182
|
+
broadcastReposList(broadcast);
|
|
183
|
+
return { ok: true, path: resolved, slug: basename(resolved) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function removeRepo(repoPath, broadcast) {
|
|
187
|
+
config.repos = config.repos.filter(r => r.path !== repoPath);
|
|
188
|
+
saveConfig(broadcast);
|
|
189
|
+
broadcastReposList(broadcast);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function broadcastReposList(broadcast) {
|
|
193
|
+
if (!broadcast) return;
|
|
194
|
+
broadcast({
|
|
195
|
+
type: 'repos-list',
|
|
196
|
+
repos: config.repos.map(r => ({
|
|
197
|
+
path: r.path,
|
|
198
|
+
slug: basename(r.path),
|
|
199
|
+
exists: existsSync(r.path),
|
|
200
|
+
})),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- Worktree Management ---
|
|
205
|
+
// Only a BRANCH collision is worth retrying under a different cocktail. The
|
|
206
|
+
// worktree-dir collision (`fatal: '<path>' already exists`) also contains the
|
|
207
|
+
// words "already exists" but no other name can fix it, so matching the looser
|
|
208
|
+
// substring would walk the entire pool for nothing.
|
|
209
|
+
//
|
|
210
|
+
// Two messages, because git checks the branch then locks the ref: the common one
|
|
211
|
+
// comes from the existence check, and the second from losing the lock race after
|
|
212
|
+
// passing that check. Both mean the same thing — the name is taken, try another.
|
|
213
|
+
const BRANCH_EXISTS_RE = /a branch named .* already exists|cannot lock ref '[^']*': reference already exists/;
|
|
214
|
+
|
|
215
|
+
// Branch names that exist on the remote. Needed because a finished job deletes
|
|
216
|
+
// its LOCAL branch but deliberately leaves the remote one alone — that is the
|
|
217
|
+
// open PR. So `worktree add -b` will happily hand out a name whose remote
|
|
218
|
+
// counterpart still exists, and the new agent's first `git push -u` then fails
|
|
219
|
+
// non-fast-forward (or, worse, force-pushes over someone's open PR).
|
|
220
|
+
//
|
|
221
|
+
// Best-effort: on a timeout, no remote, or no network this returns null and the
|
|
222
|
+
// caller falls back to local-only checking, which is what it did before.
|
|
223
|
+
async function remoteBranchNames(repoPath) {
|
|
224
|
+
try {
|
|
225
|
+
const out = await gitExec(['-C', repoPath, 'ls-remote', '--heads', 'origin']);
|
|
226
|
+
const names = new Set();
|
|
227
|
+
for (const line of out.split('\n')) {
|
|
228
|
+
const match = line.match(/refs\/heads\/(.+)$/);
|
|
229
|
+
if (match) names.add(match[1].trim());
|
|
230
|
+
}
|
|
231
|
+
return names;
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Ask git for a name rather than tracking which names are free.
|
|
238
|
+
//
|
|
239
|
+
// `worktree add -b` creates the ref atomically: it either succeeds, or it tells us
|
|
240
|
+
// the branch is taken and leaves nothing behind (no partial worktree dir to clean
|
|
241
|
+
// up — verified). So we walk candidates until one lands. That also settles
|
|
242
|
+
// concurrent spawns for free: two agents opening with the same cocktail can't both
|
|
243
|
+
// win, and the loser simply takes the next candidate instead of erroring.
|
|
244
|
+
//
|
|
245
|
+
// `customBranch` never retries — the user asked for that specific name, so a
|
|
246
|
+
// collision is an error to report, not a cue to silently pick something else.
|
|
247
|
+
export async function createWorktree(repoPath, agentName, customBranch, { suffixOnCollision = false, startPoint = null } = {}) {
|
|
248
|
+
const dirName = repoDirName(repoPath);
|
|
249
|
+
const gitUser = await branchPrefix(repoPath);
|
|
250
|
+
const worktreePath = join(WORKTREE_DIR, dirName, agentName);
|
|
251
|
+
mkdirSync(join(WORKTREE_DIR, dirName), { recursive: true });
|
|
252
|
+
|
|
253
|
+
// Resolved once: the fetch inside it must not run per candidate name.
|
|
254
|
+
const resolved = await resolveStartPoint(repoPath, startPoint);
|
|
255
|
+
if (resolved.error) return { error: resolved.error };
|
|
256
|
+
const start = resolved.ref;
|
|
257
|
+
|
|
258
|
+
const attempt = async (cocktail) => {
|
|
259
|
+
const branchName = `${gitUser}/${cocktail}`;
|
|
260
|
+
const args = ['-C', repoPath, 'worktree', 'add', worktreePath, '-b', branchName];
|
|
261
|
+
if (start) args.push(start);
|
|
262
|
+
try {
|
|
263
|
+
await gitExec(args);
|
|
264
|
+
return { worktreePath, branchName, cocktail, startPoint: start };
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const msg = err.stderr || err.message || '';
|
|
267
|
+
if (BRANCH_EXISTS_RE.test(msg)) return { branchTaken: true, branchName };
|
|
268
|
+
return { error: `Failed to create worktree: ${msg}` };
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if (customBranch) {
|
|
273
|
+
// suffixOnCollision is for names DERIVED from something else — the job
|
|
274
|
+
// board builds a branch from the job title, and two jobs may reasonably
|
|
275
|
+
// share a title ("fix flaky test"). A hard failure there would strand the
|
|
276
|
+
// job forever, so walk -2, -3, ... the same way the cocktail pool does.
|
|
277
|
+
//
|
|
278
|
+
// A name the USER typed still fails loudly: they asked for that specific
|
|
279
|
+
// branch, so a collision is something to report, not to silently rename.
|
|
280
|
+
if (suffixOnCollision) {
|
|
281
|
+
const MAX_SUFFIX = 50;
|
|
282
|
+
const remote = await remoteBranchNames(repoPath);
|
|
283
|
+
for (let n = 1; n <= MAX_SUFFIX; n++) {
|
|
284
|
+
const candidate = n === 1 ? customBranch : `${customBranch}-${n}`;
|
|
285
|
+
// Skip names already taken on the remote, not just locally.
|
|
286
|
+
if (remote && remote.has(`${gitUser}/${candidate}`)) continue;
|
|
287
|
+
const result = await attempt(candidate);
|
|
288
|
+
if (result.branchTaken) continue;
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
return { error: `Could not find a free branch name based on "${customBranch}"` };
|
|
292
|
+
}
|
|
293
|
+
const result = await attempt(customBranch);
|
|
294
|
+
if (result.branchTaken) {
|
|
295
|
+
return { error: `Branch "${result.branchName}" already in use — choose a different agent name` };
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Bounded so a repo that somehow rejects everything fails loudly instead of
|
|
301
|
+
// spawning git in a loop. Either bound can end the walk, so report what actually
|
|
302
|
+
// happened rather than assuming we spent the whole budget.
|
|
303
|
+
const MAX_TRIES = 100;
|
|
304
|
+
let tries = 0;
|
|
305
|
+
for (const cocktail of cocktailPool.candidates(repoPath)) {
|
|
306
|
+
if (tries >= MAX_TRIES) break;
|
|
307
|
+
tries++;
|
|
308
|
+
const result = await attempt(cocktail);
|
|
309
|
+
if (result.branchTaken) { cocktailPool.reject(repoPath, cocktail); continue; }
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
return { error: `Could not find a free branch name after ${tries} attempts` };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// discardChanges: uncommitted and untracked files are not worth keeping, for
|
|
316
|
+
// a caller that knows the tree is scratch. Commits the remote does not have
|
|
317
|
+
// are still protected; only the status check is skipped.
|
|
318
|
+
export async function removeWorktree(session, { discardChanges = false } = {}) {
|
|
319
|
+
if (!session.worktreePath || !session.repoPath) return { orphaned: false };
|
|
320
|
+
if (!existsSync(join(session.worktreePath, '.git'))) {
|
|
321
|
+
return existsSync(session.worktreePath) ? { orphaned: true, reason: 'broken-worktree' } : { orphaned: false };
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
let reason = null;
|
|
325
|
+
if (!discardChanges) {
|
|
326
|
+
try {
|
|
327
|
+
const status = await gitExec(['-C', session.worktreePath, 'status', '--porcelain']);
|
|
328
|
+
if (status.trim()) reason = 'uncommitted';
|
|
329
|
+
} catch { reason = 'uncommitted'; }
|
|
330
|
+
}
|
|
331
|
+
// Fully pushed to its upstream? Then nothing is at risk locally — the
|
|
332
|
+
// commits are on the remote. This is exactly the state right after
|
|
333
|
+
// `gh pr create`, so a finished job's worktree and local branch can be
|
|
334
|
+
// removed even though the branch is not merged into the base branch. The
|
|
335
|
+
// pull request is unaffected: it references the remote branch, not this
|
|
336
|
+
// worktree. Without this check the base-branch test below calls every
|
|
337
|
+
// PR-ready branch "unpushed" and orphans it, which would leave one stale
|
|
338
|
+
// worktree per completed job.
|
|
339
|
+
let fullyPushed = false;
|
|
340
|
+
if (!reason) {
|
|
341
|
+
try {
|
|
342
|
+
const local = (await gitExec(['-C', session.worktreePath, 'rev-parse', 'HEAD'])).trim();
|
|
343
|
+
const upstream = (await gitExec(['-C', session.worktreePath, 'rev-parse', '@{u}'])).trim();
|
|
344
|
+
fullyPushed = !!local && local === upstream;
|
|
345
|
+
} catch {
|
|
346
|
+
// No upstream, or one git cannot resolve locally — typically a branch
|
|
347
|
+
// pushed to a raw URL (`git push -u https://…`), which records the URL
|
|
348
|
+
// as branch.<name>.remote and creates no remote-tracking ref. Ask the
|
|
349
|
+
// remote itself; anything short of a matching SHA stays not-pushed.
|
|
350
|
+
fullyPushed = await matchesRemote(session);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (!reason && !fullyPushed) {
|
|
354
|
+
const baseBranch = await resolveBaseBranch(session.repoPath);
|
|
355
|
+
// No base branch to compare against means no way to know whether the
|
|
356
|
+
// branch holds commits nobody else has. Keep it: `branch -D` below is
|
|
357
|
+
// the one step here that can destroy work.
|
|
358
|
+
if (!baseBranch) reason = 'unpushed';
|
|
359
|
+
else {
|
|
360
|
+
try {
|
|
361
|
+
const log = await gitExec(['-C', session.repoPath, 'log', `${baseBranch}..${session.branchName}`, '--oneline']);
|
|
362
|
+
if (log.trim()) reason = 'unpushed';
|
|
363
|
+
} catch { reason = 'unpushed'; }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (reason) return { orphaned: true, reason };
|
|
367
|
+
// Deleting a worktree with node_modules in it can outlast the auto timeout;
|
|
368
|
+
// a SIGTERM mid-rm leaves a half-deleted directory and a cleanup-failed
|
|
369
|
+
// orphan, which is worse than waiting.
|
|
370
|
+
const slow = { timeout: GIT_USER_TIMEOUT };
|
|
371
|
+
try { await gitExec(['-C', session.repoPath, 'worktree', 'remove', session.worktreePath], slow); } catch {
|
|
372
|
+
try { await gitExec(['-C', session.repoPath, 'worktree', 'remove', '--force', session.worktreePath], slow); } catch (err) {
|
|
373
|
+
console.error('Force worktree remove failed:', err.message);
|
|
374
|
+
return { orphaned: true, reason: 'cleanup-failed' };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
await deleteBranch(session.repoPath, session.branchName);
|
|
378
|
+
return { orphaned: false };
|
|
379
|
+
} catch (err) {
|
|
380
|
+
console.error('Worktree cleanup error:', err.message);
|
|
381
|
+
return { orphaned: true, reason: 'cleanup-failed' };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// HEAD equals the branch's ref on its configured remote (origin if none).
|
|
386
|
+
// Only a definite match counts: offline, a timeout, an auth failure or a
|
|
387
|
+
// different SHA all return false, which keeps the branch. A remote URL with
|
|
388
|
+
// credentials in it is repointed at origin on the way, so the token stops
|
|
389
|
+
// sitting in .git/config; the URL itself is never logged.
|
|
390
|
+
async function matchesRemote({ worktreePath, repoPath, branchName }) {
|
|
391
|
+
if (!branchName) return false;
|
|
392
|
+
const cfg = async key => {
|
|
393
|
+
try { return (await gitExec(['-C', repoPath, 'config', '--get', key])).trim(); } catch { return ''; }
|
|
394
|
+
};
|
|
395
|
+
const remote = await cfg(`branch.${branchName}.remote`) || 'origin';
|
|
396
|
+
const ref = await cfg(`branch.${branchName}.merge`) || `refs/heads/${branchName}`;
|
|
397
|
+
// Values from config reach argv: never let one parse as an option.
|
|
398
|
+
if (remote.startsWith('-') || ref.startsWith('-')) return false;
|
|
399
|
+
let matched = false;
|
|
400
|
+
try {
|
|
401
|
+
const local = (await gitExec(['-C', worktreePath, 'rev-parse', 'HEAD'])).trim();
|
|
402
|
+
const out = await gitExec(['-C', repoPath, 'ls-remote', remote, ref]);
|
|
403
|
+
const line = out.split('\n').find(l => l.endsWith(`\t${ref}`));
|
|
404
|
+
matched = !!local && line?.split('\t')[0] === local;
|
|
405
|
+
} catch {}
|
|
406
|
+
if (/^[a-z][a-z0-9+.-]*:\/\/[^/@:]+:[^/@]+@/i.test(remote)) {
|
|
407
|
+
try { await gitExec(['-C', repoPath, 'config', `branch.${branchName}.remote`, 'origin']); } catch {}
|
|
408
|
+
}
|
|
409
|
+
return matched;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Delete an agent's branch, cleaning up first so it actually succeeds. If the
|
|
413
|
+
// worktree directory was removed but git still registers it (a "prunable"
|
|
414
|
+
// worktree), `git branch -D` refuses with "branch is checked out at <path>" and
|
|
415
|
+
// the branch leaks. Pruning stale registrations first clears that, so the branch
|
|
416
|
+
// is really deleted instead of accumulating until every cocktail name is taken.
|
|
417
|
+
export async function deleteBranch(repoPath, branchName) {
|
|
418
|
+
if (!repoPath || !branchName) return;
|
|
419
|
+
try { await gitExec(['-C', repoPath, 'worktree', 'prune']); } catch {}
|
|
420
|
+
try {
|
|
421
|
+
await gitExec(['-C', repoPath, 'branch', '-D', branchName]);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
console.error(`Failed to delete branch ${branchName}:`, err.stderr || err.message);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function pruneWorktrees() {
|
|
428
|
+
for (const repo of config.repos) {
|
|
429
|
+
if (!existsSync(repo.path)) continue;
|
|
430
|
+
try { await gitExec(['-C', repo.path, 'worktree', 'prune']); } catch (err) {
|
|
431
|
+
console.warn(`Worktree prune failed for ${repo.path}:`, err.message);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function scanForOrphanedWorktrees(broadcast) {
|
|
437
|
+
if (!existsSync(WORKTREE_DIR)) return;
|
|
438
|
+
try {
|
|
439
|
+
for (const repoDir of readdirSync(WORKTREE_DIR)) {
|
|
440
|
+
const repoWorktreePath = join(WORKTREE_DIR, repoDir);
|
|
441
|
+
let stat;
|
|
442
|
+
try { stat = statSync(repoWorktreePath); } catch { continue; }
|
|
443
|
+
if (!stat.isDirectory()) continue;
|
|
444
|
+
for (const agentDir of readdirSync(repoWorktreePath)) {
|
|
445
|
+
const worktreePath = join(repoWorktreePath, agentDir);
|
|
446
|
+
try { if (!statSync(worktreePath).isDirectory()) continue; } catch { continue; }
|
|
447
|
+
if ([...orphans.values()].some(o => o.worktreePath === worktreePath)) continue;
|
|
448
|
+
if ([...sessions.values()].some(s => s.worktreePath === worktreePath)) continue;
|
|
449
|
+
let repoPath = null;
|
|
450
|
+
try {
|
|
451
|
+
const gitDir = await gitExec(['-C', worktreePath, 'rev-parse', '--git-dir']);
|
|
452
|
+
const match = gitDir.trim().match(/^(.+)\/\.git\/worktrees\//);
|
|
453
|
+
if (match) repoPath = match[1];
|
|
454
|
+
} catch { continue; }
|
|
455
|
+
if (!repoPath) continue;
|
|
456
|
+
let branchName = null;
|
|
457
|
+
try {
|
|
458
|
+
const head = await gitExec(['-C', worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']);
|
|
459
|
+
branchName = head.trim();
|
|
460
|
+
} catch { branchName = 'unknown'; }
|
|
461
|
+
const orphanId = `orphan-${Date.now()}-${agentDir}`;
|
|
462
|
+
const orphan = {
|
|
463
|
+
id: orphanId, name: agentDir, repoPath, repoSlug: basename(repoPath),
|
|
464
|
+
worktreePath, branchName, color: colorCycler.next(),
|
|
465
|
+
// Nothing on disk says which CLI ran here or with what flags; the
|
|
466
|
+
// same shape as the other orphan sites, with nothing in it.
|
|
467
|
+
agent: null, permissionFlags: [], origin: 'user',
|
|
468
|
+
reason: 'discovered', createdAt: new Date().toISOString(),
|
|
469
|
+
};
|
|
470
|
+
orphans.set(orphanId, orphan);
|
|
471
|
+
codenamePool.addUsed(agentDir);
|
|
472
|
+
console.log(`Discovered orphaned worktree: ${agentDir} in ${repoPath}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (orphans.size > 0) syncOrphansToConfig(broadcast);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
console.error('Orphan scan error:', err.message);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// --- File Tree Scanning ---
|
|
482
|
+
export async function scanFileTree(session, broadcast) {
|
|
483
|
+
if (!session.worktreePath) return;
|
|
484
|
+
if (!existsSync(session.worktreePath)) return;
|
|
485
|
+
try {
|
|
486
|
+
try {
|
|
487
|
+
const head = await gitExec(['-C', session.worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']);
|
|
488
|
+
const currentBranch = head.trim();
|
|
489
|
+
if (currentBranch && currentBranch !== session.branchName) {
|
|
490
|
+
session.branchName = currentBranch;
|
|
491
|
+
if (broadcast) broadcast({ type: 'branch-changed', sessionId: session.id, branchName: currentBranch });
|
|
492
|
+
}
|
|
493
|
+
} catch (_) {}
|
|
494
|
+
const stdout = await gitExec(['-C', session.worktreePath, 'status', '--porcelain=v1']);
|
|
495
|
+
const hash = createHash('md5').update(stdout).digest('hex');
|
|
496
|
+
if (hash === session.lastTreeHash) return;
|
|
497
|
+
session.lastTreeHash = hash;
|
|
498
|
+
const files = parseGitStatus(stdout);
|
|
499
|
+
const tree = buildFileTree(files, basename(session.repoPath));
|
|
500
|
+
session.fileTree = files;
|
|
501
|
+
session.changedCount = files.length;
|
|
502
|
+
let additions = 0, removals = 0;
|
|
503
|
+
try {
|
|
504
|
+
const diffStat = await gitExec(['-C', session.worktreePath, 'diff', '--numstat']);
|
|
505
|
+
for (const line of diffStat.split('\n')) {
|
|
506
|
+
const parts = line.split('\t');
|
|
507
|
+
if (parts.length >= 2) {
|
|
508
|
+
const a = parseInt(parts[0], 10);
|
|
509
|
+
const r = parseInt(parts[1], 10);
|
|
510
|
+
if (!isNaN(a)) additions += a;
|
|
511
|
+
if (!isNaN(r)) removals += r;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch (_) {}
|
|
515
|
+
session.additions = additions;
|
|
516
|
+
session.removals = removals;
|
|
517
|
+
if (broadcast) broadcast({ type: 'file-tree', sessionId: session.id, tree, files, changedCount: files.length, additions, removals });
|
|
518
|
+
const conflicts = detectConflicts(session.repoPath);
|
|
519
|
+
if (broadcast) broadcast({ type: 'conflicts-update', conflicts });
|
|
520
|
+
} catch (err) {
|
|
521
|
+
console.error(`File tree scan failed for ${session.id}:`, err.message);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Poll cadence for the recurring file-tree/branch scan. The branch shown in the
|
|
526
|
+
// UI is refreshed on every cycle (see DESIGN.md "Branch sync"), so this also
|
|
527
|
+
// governs how quickly a `git checkout -b` inside the terminal is reflected.
|
|
528
|
+
export const SCAN_INTERVAL_MS = 1500;
|
|
529
|
+
|
|
530
|
+
// Start (or restart) the recurring scan loop for a session. Self-reschedules
|
|
531
|
+
// after each scan completes rather than using setInterval, so slow git calls
|
|
532
|
+
// never overlap. Stops once the session has exited. `session.scanTimer` holds
|
|
533
|
+
// the pending timer so killSession/onExit can cancel it.
|
|
534
|
+
//
|
|
535
|
+
// Idle gate: a session's branch/tree/diff only changes as a result of terminal
|
|
536
|
+
// activity (the agent running git/edit commands), which bumps lastOutputAt in
|
|
537
|
+
// the PTY onData handler. So we skip the git scan on cycles where there's been
|
|
538
|
+
// no new output since the last scan — an idle session costs zero git calls.
|
|
539
|
+
// The first cycle always scans so a fresh session shows its initial tree. This
|
|
540
|
+
// gate lives in the loop, not scanFileTree, so manual refresh-tree still scans
|
|
541
|
+
// unconditionally. Tradeoff: worktree changes made outside this terminal aren't
|
|
542
|
+
// picked up until the next terminal activity or a manual refresh.
|
|
543
|
+
export function startTreeScanLoop(session, broadcast) {
|
|
544
|
+
clearTimeout(session.scanTimer);
|
|
545
|
+
const tick = async () => {
|
|
546
|
+
const hasNewOutput = session.lastOutputAt !== session.lastScanOutputAt;
|
|
547
|
+
if (!session.scannedOnce || hasNewOutput) {
|
|
548
|
+
session.scannedOnce = true;
|
|
549
|
+
// Capture before the await: output arriving mid-scan advances lastOutputAt
|
|
550
|
+
// past this value, so the next cycle rescans instead of dropping the change.
|
|
551
|
+
session.lastScanOutputAt = session.lastOutputAt;
|
|
552
|
+
await scanFileTree(session, broadcast);
|
|
553
|
+
}
|
|
554
|
+
if (!session.exited) session.scanTimer = setTimeout(tick, SCAN_INTERVAL_MS);
|
|
555
|
+
};
|
|
556
|
+
session.scanTimer = setTimeout(tick, SCAN_INTERVAL_MS);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// --- Conflict Detection ---
|
|
560
|
+
export function detectConflicts(repoPath) {
|
|
561
|
+
const repoSessions = [...sessions.values()].filter(s => s.repoPath === repoPath && s.fileTree);
|
|
562
|
+
const conflicts = [];
|
|
563
|
+
const currentKeys = new Set();
|
|
564
|
+
for (let i = 0; i < repoSessions.length; i++) {
|
|
565
|
+
for (let j = i + 1; j < repoSessions.length; j++) {
|
|
566
|
+
const a = repoSessions[i], b = repoSessions[j];
|
|
567
|
+
const pathsA = a.fileTree.map(f => f.path);
|
|
568
|
+
const pathsB = b.fileTree.map(f => f.path);
|
|
569
|
+
const overlap = pathsA.filter(p => pathsB.includes(p));
|
|
570
|
+
if (overlap.length > 0) {
|
|
571
|
+
conflicts.push({ sessionIds: [a.id, b.id], files: overlap });
|
|
572
|
+
for (const file of overlap) {
|
|
573
|
+
const key = [a.id, b.id].sort().join('\0') + '\0' + file;
|
|
574
|
+
if (!knownConflictKeys.has(key)) {
|
|
575
|
+
// broadcast handled by caller
|
|
576
|
+
}
|
|
577
|
+
currentKeys.add(key);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
knownConflictKeys.clear();
|
|
583
|
+
for (const key of currentKeys) knownConflictKeys.add(key);
|
|
584
|
+
return conflicts;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// --- Diff ---
|
|
588
|
+
// Read a file inside the worktree, refusing paths that escape it. `filePath` is
|
|
589
|
+
// client-controlled and get-diff is open to non-owners (phase 2 read surface), so
|
|
590
|
+
// without this a `../../../etc/passwd` reads arbitrary host files. realpathSync
|
|
591
|
+
// also collapses symlinks, blocking symlink-based escapes.
|
|
592
|
+
function readInsideWorktree(worktreePath, filePath) {
|
|
593
|
+
const root = realpathSync(worktreePath);
|
|
594
|
+
const full = realpathSync(join(worktreePath, filePath));
|
|
595
|
+
if (full !== root && !full.startsWith(root + sep)) {
|
|
596
|
+
throw new Error('path escapes worktree');
|
|
597
|
+
}
|
|
598
|
+
return readFileSync(full, 'utf8');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export async function getDiff(session, filePath, status) {
|
|
602
|
+
if (!session.worktreePath) return '';
|
|
603
|
+
try {
|
|
604
|
+
switch (status) {
|
|
605
|
+
case 'M':
|
|
606
|
+
return await gitExec(['-C', session.worktreePath, 'diff', '--', filePath], { timeout: GIT_USER_TIMEOUT });
|
|
607
|
+
case 'A': {
|
|
608
|
+
try {
|
|
609
|
+
const cached = await gitExec(['-C', session.worktreePath, 'diff', '--cached', '--', filePath], { timeout: GIT_USER_TIMEOUT });
|
|
610
|
+
if (cached.trim()) return cached;
|
|
611
|
+
} catch {}
|
|
612
|
+
try { return await gitExec(['-C', session.worktreePath, 'show', `:${filePath}`], { timeout: GIT_USER_TIMEOUT }); } catch {
|
|
613
|
+
return readInsideWorktree(session.worktreePath, filePath);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
case 'D':
|
|
617
|
+
return await gitExec(['-C', session.worktreePath, 'diff', '--', filePath], { timeout: GIT_USER_TIMEOUT });
|
|
618
|
+
case '?': {
|
|
619
|
+
try {
|
|
620
|
+
const content = readInsideWorktree(session.worktreePath, filePath);
|
|
621
|
+
return content.split('\n').map((line) => `+${line}`).join('\n');
|
|
622
|
+
} catch (err) { return `Error reading file: ${err.message}`; }
|
|
623
|
+
}
|
|
624
|
+
case 'R':
|
|
625
|
+
return await gitExec(['-C', session.worktreePath, 'diff', 'HEAD', '--', filePath], { timeout: GIT_USER_TIMEOUT });
|
|
626
|
+
default:
|
|
627
|
+
return await gitExec(['-C', session.worktreePath, 'diff', '--', filePath], { timeout: GIT_USER_TIMEOUT });
|
|
628
|
+
}
|
|
629
|
+
} catch (err) { return `Error loading diff: ${err.message}`; }
|
|
630
|
+
}
|