@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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. 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
+ }