fleet-agents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/fleet.js ADDED
@@ -0,0 +1,1018 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * fleet — run multiple Claude Code agents in parallel, each in its own git worktree.
6
+ *
7
+ * Backends:
8
+ * tmux (default on macOS/Linux/WSL) — manager + worker panes in one session, attach/detach.
9
+ * windows (default on Windows, or when tmux is absent) — each agent in its own OS terminal window.
10
+ *
11
+ * See `fleet help`.
12
+ */
13
+
14
+ const { execFileSync, spawn, spawnSync } = require('child_process');
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+
19
+ // ----------------------------------------------------------------------------- config
20
+ const HOME = os.homedir();
21
+ const PROJECTS_ROOT = process.env.PROJECTS_ROOT || path.join(HOME, 'Projects');
22
+ const WT_ROOT = process.env.WT_ROOT || path.join(PROJECTS_ROOT, '.worktrees');
23
+ let SESSION = process.env.FLEET_SESSION || 'fleet'; // overridable per-command via --name
24
+ const MODE = process.env.FLEET_MODE || 'pane'; // pane | window (tmux only)
25
+ // Flags handed to each launched `claude`. Default skips permission prompts so agents run
26
+ // unattended. Set FLEET_CLAUDE_FLAGS="" to restore prompts. (?? keeps an explicit "".)
27
+ const CLAUDE_FLAGS = process.env.FLEET_CLAUDE_FLAGS ?? '--dangerously-skip-permissions';
28
+
29
+ function die(msg) { console.error(`fleet: ${msg}`); process.exit(1); }
30
+ function have(bin) {
31
+ try { execFileSync(bin, ['--version'], { stdio: 'ignore' }); return true; }
32
+ catch { return false; }
33
+ }
34
+ function hasTmux() {
35
+ try { execFileSync('tmux', ['-V'], { stdio: 'ignore' }); return true; } catch { return false; }
36
+ }
37
+
38
+ const BACKEND = process.env.FLEET_BACKEND ||
39
+ (process.platform === 'win32' ? 'windows' : (hasTmux() ? 'tmux' : 'windows'));
40
+
41
+ // ----------------------------------------------------------------------------- helpers
42
+ function git(repo, args, opts = {}) {
43
+ return execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', ...opts });
44
+ }
45
+ function gitQuiet(repo, args) {
46
+ // capture stdout, swallow stderr (probes like show-ref print "not a valid ref")
47
+ try {
48
+ return execFileSync('git', ['-C', repo, ...args],
49
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
50
+ } catch { return ''; }
51
+ }
52
+ function tmux(args, opts = {}) {
53
+ const out = execFileSync('tmux', args, { encoding: 'utf8', ...opts });
54
+ return out == null ? '' : out.trim();
55
+ }
56
+ function tmuxHas() {
57
+ try { execFileSync('tmux', ['has-session', '-t', SESSION], { stdio: 'ignore' }); return true; }
58
+ catch { return false; }
59
+ }
60
+ function tmuxHasName(name) {
61
+ try { execFileSync('tmux', ['has-session', '-t', name], { stdio: 'ignore' }); return true; }
62
+ catch { return false; }
63
+ }
64
+
65
+ function resolveRepo(name) {
66
+ const candidates = [name, path.join(PROJECTS_ROOT, name)];
67
+ for (const c of candidates) {
68
+ if (fs.existsSync(path.join(c, '.git'))) return path.resolve(c);
69
+ }
70
+ die(`no git repo found for '${name}' (looked in ./ and ${PROJECTS_ROOT}/)`);
71
+ }
72
+
73
+ function slug(s) {
74
+ return s.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9._-]/g, '');
75
+ }
76
+
77
+ // Pull `--name <v>` / `-n <v>` out of an args array; returns [value|undefined, remainingArgs].
78
+ function takeFlag(args, names) {
79
+ const rest = [];
80
+ let val;
81
+ for (let i = 0; i < args.length; i++) {
82
+ if (names.includes(args[i])) { val = args[i + 1]; i++; }
83
+ else rest.push(args[i]);
84
+ }
85
+ return [val, rest];
86
+ }
87
+
88
+ // ----------------------------------------------------------------------------- session state
89
+ // Persist enough to rebuild a session after a reboot/kill: the manager's dir + its tasks.
90
+ // One JSON file per session under ~/.fleet (override with FLEET_STATE_DIR).
91
+ const STATE_DIR = process.env.FLEET_STATE_DIR || path.join(HOME, '.fleet');
92
+ const statePath = (session) => path.join(STATE_DIR, `${session}.json`);
93
+ function loadState(session) {
94
+ try { return JSON.parse(fs.readFileSync(statePath(session), 'utf8')); }
95
+ catch { return { session, managerDir: null, tasks: [] }; }
96
+ }
97
+ function saveState(state) {
98
+ try {
99
+ fs.mkdirSync(STATE_DIR, { recursive: true });
100
+ fs.writeFileSync(statePath(state.session), JSON.stringify(state, null, 2));
101
+ } catch {}
102
+ }
103
+ // If this session was created from INSIDE another fleet session (env FLEET_SESSION differs),
104
+ // record that parent — so removing the parent can cascade to child/sub-child sessions.
105
+ function recordParent(session) {
106
+ const env = process.env.FLEET_SESSION;
107
+ if (!env || env === session) return;
108
+ const s = loadState(session);
109
+ if (!s.parent) { s.parent = env; saveState(s); }
110
+ }
111
+ function recordManagerDir(session, dir) {
112
+ recordParent(session);
113
+ const s = loadState(session); s.managerDir = dir; saveState(s);
114
+ }
115
+ function recordTask(session, repo, task, wt, opts = {}) {
116
+ recordParent(session);
117
+ const s = loadState(session);
118
+ let entry = s.tasks.find((t) => t.repo === repo && t.task === task);
119
+ if (!entry) { entry = { repo, task, wt }; s.tasks.push(entry); }
120
+ // If spawned from inside a worker pane, FLEET_TASK identifies the parent worker.
121
+ const parentTask = process.env.FLEET_TASK;
122
+ if (parentTask && parentTask !== `${repo}/${task}`) entry.parent = parentTask;
123
+ if (opts.paneId) entry.paneId = opts.paneId; // for kill-by-pane (no-worktree workers share cwd)
124
+ if (opts.noWorktree) entry.noWorktree = true; // its wt is the repo — never remove it
125
+ saveState(s);
126
+ }
127
+ // Drop a task from whichever session(s) recorded it (rm may run from a plain terminal).
128
+ function unrecordTaskEverywhere(repo, task) {
129
+ if (!fs.existsSync(STATE_DIR)) return;
130
+ for (const f of fs.readdirSync(STATE_DIR)) {
131
+ if (!f.endsWith('.json')) continue;
132
+ const s = loadState(f.replace(/\.json$/, ''));
133
+ const before = s.tasks.length;
134
+ s.tasks = s.tasks.filter((t) => !(t.repo === repo && t.task === task));
135
+ if (s.tasks.length !== before) saveState(s);
136
+ }
137
+ }
138
+
139
+ // POSIX single-quote a string for a remote shell (used with tmux send-keys).
140
+ function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
141
+
142
+ // Build the shell command string that launches claude with flags + prompt.
143
+ function claudeCmd(prompt) {
144
+ const flags = CLAUDE_FLAGS.trim();
145
+ return ['claude', flags, shq(prompt)].filter(Boolean).join(' ');
146
+ }
147
+
148
+ // Resolve the prompt arg: if it points at a readable file, load its contents.
149
+ function loadPrompt(arg) {
150
+ if (arg && fs.existsSync(arg) && fs.statSync(arg).isFile()) {
151
+ const text = fs.readFileSync(arg, 'utf8');
152
+ if (!text.trim()) die(`task file is empty: ${arg}`);
153
+ const lines = text.split('\n').length;
154
+ console.log(`fleet: task loaded from ${arg} (${lines} lines)`);
155
+ return text;
156
+ }
157
+ return arg;
158
+ }
159
+
160
+ // Create the worktree for a task; returns { repo, branch, wt, base, reponame }.
161
+ function makeWorktree(repoArg, taskArg, baseArg) {
162
+ const repo = resolveRepo(repoArg);
163
+ const task = slug(taskArg);
164
+ if (!task) die('task name slugified to empty; use letters/numbers');
165
+ const reponame = path.basename(repo);
166
+ const branch = task;
167
+ const wt = path.join(WT_ROOT, reponame, task);
168
+ const base = baseArg || gitQuiet(repo, ['symbolic-ref', '--short', 'HEAD']) || 'HEAD';
169
+
170
+ if (fs.existsSync(wt)) {
171
+ console.log(`fleet: worktree already exists, reusing -> ${wt}`);
172
+ } else {
173
+ fs.mkdirSync(path.join(WT_ROOT, reponame), { recursive: true });
174
+ const exists = gitQuiet(repo, ['show-ref', '--verify', `refs/heads/${branch}`]);
175
+ if (exists) git(repo, ['worktree', 'add', wt, branch], { stdio: 'inherit' });
176
+ else git(repo, ['worktree', 'add', '-b', branch, wt, base], { stdio: 'inherit' });
177
+ }
178
+ return { repo, branch, wt, base, reponame, task };
179
+ }
180
+
181
+ // ----------------------------------------------------------------------------- tmux backend
182
+ const tmuxBackend = {
183
+ ensureSession(cwd = PROJECTS_ROOT) {
184
+ if (tmuxHas()) return;
185
+ tmux(['new-session', '-d', '-s', SESSION, '-c', cwd, '-n', 'home']);
186
+ try { tmux(['set-option', '-t', SESSION, 'pane-border-status', 'top']); } catch {}
187
+ // claude overwrites pane_title, so label borders by worktree folder (the task slug).
188
+ try { tmux(['set-option', '-t', SESSION, 'pane-border-format', ' #{b:pane_current_path} ']); } catch {}
189
+ // Stamp FLEET_SESSION into the session env so EVERY pane spawned in it (manager → worker
190
+ // → sub-worker …) inherits it — so a worker that spawns its own worker still records under
191
+ // and lands in THIS session, keeping the whole chain intact for resume.
192
+ try { tmux(['set-environment', '-t', SESSION, 'FLEET_SESSION', SESSION]); } catch {}
193
+ // macOS idle-sleeps on battery, which suspends agents and drops their API connections.
194
+ // Hold a caffeinate assertion for the life of the tmux server so sleep can't kill them.
195
+ // Opt out with FLEET_NO_CAFFEINATE=1.
196
+ if (process.platform === 'darwin' && process.env.FLEET_NO_CAFFEINATE !== '1'
197
+ && fs.existsSync('/usr/bin/caffeinate')) {
198
+ try {
199
+ const srvPid = tmux(['display-message', '-p', '#{pid}']);
200
+ spawn('caffeinate', ['-i', '-m', '-s', '-w', srvPid], { detached: true, stdio: 'ignore' }).unref();
201
+ } catch {}
202
+ }
203
+ },
204
+ spawn({ wt, cmd, taskId }) {
205
+ this.ensureSession();
206
+ let pane;
207
+ if (MODE === 'window') {
208
+ pane = tmux(['new-window', '-P', '-F', '#{pane_id}', '-t', SESSION, '-c', wt, process.env.SHELL || '/bin/sh']);
209
+ } else {
210
+ pane = tmux(['split-window', '-P', '-F', '#{pane_id}', '-t', SESSION, '-c', wt, process.env.SHELL || '/bin/sh']);
211
+ tmux(['select-layout', '-t', SESSION, 'tiled']);
212
+ }
213
+ // Prefix FLEET_SESSION (keeps the agent on this session) and FLEET_TASK (so a sub-worker
214
+ // it spawns records THIS worker as its parent → removable as a chain).
215
+ const env = [`FLEET_SESSION=${shq(SESSION)}`];
216
+ if (taskId) env.push(`FLEET_TASK=${shq(taskId)}`);
217
+ tmux(['send-keys', '-t', pane, `${env.join(' ')} ${cmd}`, 'Enter']);
218
+ return pane;
219
+ },
220
+ // Launch the orchestrator claude in pane 0 (if it's an idle shell). cont=true continues
221
+ // the manager's saved conversation. Exports FLEET_SESSION so its `fleet add`s target here.
222
+ launchManager(cwd, cont = false) {
223
+ const info = tmux(['list-panes', '-t', SESSION, '-F', '#{pane_id} #{pane_current_command}']).split('\n')[0];
224
+ const sp = info.indexOf(' ');
225
+ const pid = info.slice(0, sp), pcmd = info.slice(sp + 1);
226
+ if (!/^-?(zsh|bash|sh|fish)$/.test(pcmd)) return false;
227
+ const claudeArgs = ['claude', cont ? '--continue' : '', CLAUDE_FLAGS.trim()].filter(Boolean).join(' ');
228
+ const launch = `cd ${shq(cwd)} && FLEET_SESSION=${shq(SESSION)} ` + claudeArgs;
229
+ tmux(['send-keys', '-t', pid, launch, 'Enter']);
230
+ return true;
231
+ },
232
+ manager({ cwd = PROJECTS_ROOT } = {}) {
233
+ this.ensureSession(cwd);
234
+ if (this.launchManager(cwd, false)) {
235
+ // Only record the dir we ACTUALLY launched at — avoids drift when a manager is
236
+ // already running (the live pane didn't move, so don't rewrite its recorded dir).
237
+ recordManagerDir(SESSION, cwd);
238
+ console.log(`fleet: manager started in session '${SESSION}' at ${cwd} (type /fleet inside it)`);
239
+ } else {
240
+ const cur = loadState(SESSION).managerDir;
241
+ console.log(`fleet: manager already running in session '${SESSION}'${cur ? ` at ${cur}` : ''} (kept its dir)`);
242
+ }
243
+ this.attach();
244
+ },
245
+ // Open a manager as a NEW WINDOW in the CURRENT tmux session (must be run from inside tmux).
246
+ // Its workers join that same session, so everything stays in one session as switchable windows.
247
+ managerWindow(cwd) {
248
+ const sess = tmux(['display-message', '-p', '#S']);
249
+ try { tmux(['set-option', '-t', sess, 'pane-border-status', 'top']); } catch {}
250
+ try { tmux(['set-option', '-t', sess, 'pane-border-format', ' #{b:pane_current_path} ']); } catch {}
251
+ try { tmux(['set-environment', '-t', sess, 'FLEET_SESSION', sess]); } catch {}
252
+ const pid = tmux(['new-window', '-P', '-F', '#{pane_id}', '-c', cwd,
253
+ '-n', path.basename(cwd) || 'manager', process.env.SHELL || '/bin/sh']);
254
+ const launch = `cd ${shq(cwd)} && FLEET_SESSION=${shq(sess)} ` +
255
+ ['claude', CLAUDE_FLAGS.trim()].filter(Boolean).join(' ');
256
+ tmux(['send-keys', '-t', pid, launch, 'Enter']);
257
+ recordManagerDir(sess, cwd);
258
+ console.log(`fleet: manager opened as a new window in session '${sess}' at ${cwd}`);
259
+ },
260
+ attach() {
261
+ if (!tmuxHas()) die('no fleet session running (try: fleet manager)');
262
+ const sub = process.env.TMUX ? 'switch-client' : 'attach';
263
+ spawnSync('tmux', [sub, '-t', SESSION], { stdio: 'inherit' });
264
+ },
265
+ kill() {
266
+ if (!tmuxHas()) { console.log('fleet: no session'); return; }
267
+ execFileSync('tmux', ['kill-session', '-t', SESSION], { stdio: 'ignore' });
268
+ console.log('fleet: killed session');
269
+ },
270
+ };
271
+
272
+ // ----------------------------------------------------------------------------- windows / separate-window backend
273
+ function openTerminal(wt, cmd) {
274
+ const plat = process.platform;
275
+ if (plat === 'win32') {
276
+ // Prefer Windows Terminal; fall back to a classic console window.
277
+ if (have('wt.exe') || have('wt')) {
278
+ spawn('wt.exe', ['-w', '0', 'nt', '-d', wt, 'cmd', '/k', cmd], { detached: true, stdio: 'ignore' }).unref();
279
+ } else {
280
+ spawn('cmd.exe', ['/c', 'start', 'cmd', '/k', `cd /d "${wt}" && ${cmd}`], { detached: true, stdio: 'ignore' }).unref();
281
+ }
282
+ } else if (plat === 'darwin') {
283
+ const script = `cd ${shq(wt)} && ${cmd}`;
284
+ const osa = `tell application "Terminal" to do script ${shq(script)}`;
285
+ spawn('osascript', ['-e', osa], { detached: true, stdio: 'ignore' }).unref();
286
+ } else {
287
+ // Linux: try common terminal emulators in order.
288
+ const inner = `cd ${shq(wt)}; ${cmd}; exec ${process.env.SHELL || 'bash'}`;
289
+ const terms = [
290
+ ['x-terminal-emulator', ['-e', 'bash', '-lc', inner]],
291
+ ['gnome-terminal', ['--', 'bash', '-lc', inner]],
292
+ ['konsole', ['-e', 'bash', '-lc', inner]],
293
+ ['xfce4-terminal', ['-e', `bash -lc ${shq(inner)}`]],
294
+ ['xterm', ['-e', 'bash', '-lc', inner]],
295
+ ];
296
+ for (const [bin, args] of terms) {
297
+ if (have(bin)) { spawn(bin, args, { detached: true, stdio: 'ignore' }).unref(); return; }
298
+ }
299
+ die('no supported terminal emulator found (install gnome-terminal/konsole/xterm, or use FLEET_BACKEND=tmux)');
300
+ }
301
+ }
302
+
303
+ const windowsBackend = {
304
+ spawn({ wt, cmd }) { openTerminal(wt, cmd); },
305
+ manager({ cwd = PROJECTS_ROOT } = {}) {
306
+ // No multiplexer: the manager just runs claude in the current terminal, rooted at cwd.
307
+ recordManagerDir(SESSION, cwd);
308
+ console.log(`fleet: launching manager in this terminal at ${cwd} — type /fleet inside it.`);
309
+ const r = spawnSync('claude', CLAUDE_FLAGS.split(/\s+/).filter(Boolean),
310
+ { stdio: 'inherit', shell: true, cwd });
311
+ process.exit(r.status || 0);
312
+ },
313
+ attach() {
314
+ console.log('fleet: separate-window backend has no shared session to attach to.');
315
+ console.log(' Each agent runs in its own terminal window. Use `fleet ls` to see worktrees.');
316
+ },
317
+ kill() {
318
+ console.log('fleet: separate-window backend — close the agent terminal windows manually.');
319
+ console.log(' Use `fleet ls` / `fleet rm <repo> <task>` to clean up worktrees.');
320
+ },
321
+ };
322
+
323
+ const backend = BACKEND === 'tmux' ? tmuxBackend : windowsBackend;
324
+
325
+ // ----------------------------------------------------------------------------- commands
326
+ // Shared task launcher: spawn the agent pane with the prompt. With noWorktree, it runs in
327
+ // the repo's own working tree (no branch/worktree, not recorded); otherwise it makes and
328
+ // records an isolated worktree.
329
+ function launchTask(repoArg, taskArg, prompt, baseArg, opts = {}) {
330
+ const { kind, noWorktree } = opts;
331
+ if (!have('claude')) die('claude not found on PATH');
332
+ if (BACKEND === 'tmux' && !hasTmux()) die('tmux not found');
333
+ const label = kind ? `[${kind}] ` : '';
334
+
335
+ if (noWorktree) {
336
+ const repo = resolveRepo(repoArg);
337
+ const reponame = path.basename(repo);
338
+ const task = slug(taskArg) || 'work';
339
+ const paneId = backend.spawn({ wt: repo, cmd: claudeCmd(prompt), taskId: `${reponame}/${task}` });
340
+ recordTask(SESSION, reponame, task, repo, { paneId, noWorktree: true });
341
+ console.log(`fleet: launched ${label}[${reponame}/${task}] in repo (no worktree)`);
342
+ if (BACKEND === 'tmux' && !process.env.TMUX) console.log(' attach: fleet attach');
343
+ return;
344
+ }
345
+
346
+ const { wt, branch, base, reponame, task } = makeWorktree(repoArg, taskArg, baseArg);
347
+ const paneId = backend.spawn({ wt, cmd: claudeCmd(prompt), taskId: `${reponame}/${task}` });
348
+ recordTask(SESSION, reponame, task, wt, { paneId });
349
+ console.log(`fleet: launched ${label}[${reponame}/${task}]`);
350
+ console.log(` worktree: ${wt}`);
351
+ console.log(` branch: ${branch} (base: ${base})`);
352
+ if (BACKEND === 'tmux' && !process.env.TMUX) console.log(' attach: fleet attach');
353
+ }
354
+
355
+ // Strip --no-worktree from args; returns [bool, remainingArgs].
356
+ function takeNoWorktree(args) {
357
+ let no = false;
358
+ const rest = args.filter((a) => ((a === '--no-worktree' || a === '-W') ? ((no = true), false) : true));
359
+ return [no, rest];
360
+ }
361
+
362
+ function cmdAdd(args) {
363
+ const [noWorktree, a1] = takeNoWorktree(args);
364
+ const [skill, rest] = takeFlag(a1, ['--skill', '-k']);
365
+ if (rest.length < 3) die('usage: fleet add <repo> <task> "<prompt>|<file.md>" [base] [--no-worktree] [--skill <name>]');
366
+ const [repoArg, taskArg, promptArg, baseArg] = rest;
367
+ let prompt = loadPrompt(promptArg);
368
+ if (skill) prompt = applySkill(skill, prompt);
369
+ launchTask(repoArg, taskArg, prompt, baseArg, { noWorktree, kind: skill || undefined });
370
+ }
371
+
372
+ // `fleet research …` is just the built-in 'research' skill (read-only debug methodology).
373
+ function cmdResearch(args) {
374
+ cmdAdd([...args, '--skill', 'research']);
375
+ }
376
+
377
+ // ----------------------------------------------------------------------------- skills
378
+ // A "skill" is a named prompt template prepended to a task. Built-ins ship in prompts/;
379
+ // user skills live in ~/.fleet/skills/<name>.md (override dir with FLEET_SKILLS_DIR).
380
+ const SKILLS_DIR = process.env.FLEET_SKILLS_DIR || path.join(HOME, '.fleet', 'skills');
381
+ function skillPath(name) {
382
+ const user = path.join(SKILLS_DIR, `${name}.md`);
383
+ if (fs.existsSync(user)) return user;
384
+ const builtin = path.join(__dirname, '..', 'prompts', `${name}.md`);
385
+ if (fs.existsSync(builtin)) return builtin;
386
+ return null;
387
+ }
388
+ function applySkill(name, prompt) {
389
+ const p = skillPath(name);
390
+ if (!p) die(`unknown skill '${name}' (see: fleet skill ls)`);
391
+ return `${fs.readFileSync(p, 'utf8')}\n\n# Task\n\n${prompt}`;
392
+ }
393
+ function listSkills() {
394
+ const read = (dir) => { try { return fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, '')); } catch { return []; } };
395
+ return { builtins: read(path.join(__dirname, '..', 'prompts')), users: read(SKILLS_DIR) };
396
+ }
397
+
398
+ function cmdSkill(args) {
399
+ const sub = args[0] || 'ls';
400
+ if (sub === 'ls' || sub === 'list') {
401
+ const { builtins, users } = listSkills();
402
+ console.log('built-in skills:');
403
+ builtins.forEach((s) => console.log(` ${s}`));
404
+ console.log('your skills:');
405
+ if (users.length) users.forEach((s) => console.log(` ${s}`));
406
+ else console.log(' (none — register with: fleet skill add <name> <file.md>)');
407
+ return;
408
+ }
409
+ if (sub === 'add') {
410
+ const [name, file] = args.slice(1);
411
+ if (!name || !file) die('usage: fleet skill add <name> <file.md>');
412
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) die(`no such file: ${file}`);
413
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
414
+ const sname = slug(name);
415
+ const dest = path.join(SKILLS_DIR, `${sname}.md`);
416
+ fs.copyFileSync(path.resolve(file), dest);
417
+ console.log(`fleet: registered skill '${sname}' -> ${dest}`);
418
+ console.log(` use it: fleet add <repo> <task> "<prompt>" --skill ${sname} (or /fleet ${sname} <prompt>)`);
419
+ return;
420
+ }
421
+ if (sub === 'rm' || sub === 'remove') {
422
+ const name = args[1];
423
+ if (!name) die('usage: fleet skill rm <name>');
424
+ const p = path.join(SKILLS_DIR, `${name}.md`);
425
+ if (!fs.existsSync(p)) die(`no user skill '${name}' (built-ins can't be removed)`);
426
+ fs.unlinkSync(p);
427
+ console.log(`fleet: removed skill '${name}'`);
428
+ return;
429
+ }
430
+ if (sub === 'show' || sub === 'cat') {
431
+ const name = args[1];
432
+ if (!name) die('usage: fleet skill show <name>');
433
+ const p = skillPath(name);
434
+ if (!p) die(`unknown skill '${name}'`);
435
+ process.stdout.write(fs.readFileSync(p, 'utf8'));
436
+ return;
437
+ }
438
+ die(`unknown skill subcommand '${sub}' (ls | add | rm | show)`);
439
+ }
440
+
441
+ // List all fleet SESSIONS (the containers), with their manager (if any) + task/pane counts.
442
+ // A session may have no manager — managers are just pane 0 of a session, when present.
443
+ function cmdLsSessions() {
444
+ if (!fs.existsSync(STATE_DIR)) { console.log('no fleet sessions recorded'); return; }
445
+ const names = fs.readdirSync(STATE_DIR).filter((f) => f.endsWith('.json')).map((f) => f.replace(/\.json$/, ''));
446
+ if (!names.length) { console.log('no fleet sessions recorded'); return; }
447
+ console.log(' SESSION STATE MANAGER TASKS LIVE-PANES');
448
+ for (const name of names.sort()) {
449
+ const s = loadState(name);
450
+ const live = BACKEND === 'tmux' && tmuxHasName(name);
451
+ let panes = '';
452
+ if (live) {
453
+ try { panes = String(tmux(['list-panes', '-s', '-t', name, '-F', 'x']).split('\n').filter(Boolean).length); }
454
+ catch { panes = '?'; }
455
+ }
456
+ const mgr = s.managerDir ? path.basename(s.managerDir) : '—';
457
+ console.log(` ${name.padEnd(20)} ${(live ? '● live' : '○ saved').padEnd(7)} ${mgr.padEnd(22)} ${String(s.tasks.length).padEnd(6)} ${live ? panes : '-'}`);
458
+ }
459
+ }
460
+
461
+ // Remove a worktree by its path (resolve its main repo from the worktree itself).
462
+ function removeWorktreeByPath(wt, branch, delBranch) {
463
+ if (!fs.existsSync(wt)) return;
464
+ // SAFETY: only ever delete paths under WT_ROOT — never a real repo (e.g. a no-worktree
465
+ // task's dir IS the repo root). This guards against catastrophic fs.rmSync on a repo.
466
+ if (!path.resolve(wt).startsWith(path.resolve(WT_ROOT) + path.sep)) return;
467
+ let mainRepo = null;
468
+ try {
469
+ const cd = execFileSync('git', ['-C', wt, 'rev-parse', '--git-common-dir'],
470
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
471
+ mainRepo = path.dirname(path.resolve(wt, cd));
472
+ } catch {}
473
+ try {
474
+ if (mainRepo) execFileSync('git', ['-C', mainRepo, 'worktree', 'remove', '--force', wt], { stdio: 'ignore' });
475
+ else fs.rmSync(wt, { recursive: true, force: true });
476
+ } catch { try { fs.rmSync(wt, { recursive: true, force: true }); } catch {} }
477
+ if (delBranch && mainRepo && branch) {
478
+ try { execFileSync('git', ['-C', mainRepo, 'branch', '-D', branch], { stdio: 'ignore' }); } catch {}
479
+ }
480
+ }
481
+
482
+ // Build parent → [children] from recorded session state.
483
+ function sessionChildrenMap() {
484
+ const children = {};
485
+ if (!fs.existsSync(STATE_DIR)) return children;
486
+ for (const f of fs.readdirSync(STATE_DIR)) {
487
+ if (!f.endsWith('.json')) continue;
488
+ const name = f.replace(/\.json$/, '');
489
+ const p = loadState(name).parent;
490
+ if (p) (children[p] = children[p] || []).push(name);
491
+ }
492
+ return children;
493
+ }
494
+
495
+ function removeOneSession(name, delBranch) {
496
+ if (BACKEND === 'tmux') { try { execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' }); } catch {} }
497
+ const s = loadState(name);
498
+ for (const t of s.tasks) removeWorktreeByPath(t.wt, t.task, delBranch);
499
+ try { fs.unlinkSync(statePath(name)); } catch {}
500
+ console.log(` removed session '${name}' — killed + ${s.tasks.length} worktree(s)${delBranch ? ' + branches' : ''}`);
501
+ }
502
+
503
+ // Remove a session by name AND every child/sub-child session it spawned.
504
+ function cmdRemoveSession(args) {
505
+ const delBranch = args.includes('--branch');
506
+ const dry = args.includes('--dry-run') || args.includes('-n');
507
+ const name = args.find((a) => !a.startsWith('-'));
508
+ if (!name) die('usage: fleet sessions rm <session> [--branch] [--dry-run]');
509
+ const exists = fs.existsSync(statePath(name)) || (BACKEND === 'tmux' && tmuxHasName(name));
510
+ if (!exists) die(`no session '${name}' (see: fleet sessions)`);
511
+
512
+ // Collect the subtree (depth-first), then remove children before their parent.
513
+ const children = sessionChildrenMap();
514
+ const order = [];
515
+ (function walk(n) { (children[n] || []).forEach(walk); order.push(n); })(name);
516
+
517
+ if (dry) {
518
+ console.log(`fleet: would remove session '${name}'${order.length > 1 ? ` + ${order.length - 1} descendant session(s)` : ''}:`);
519
+ for (const s of order) console.log(` - ${s} (${loadState(s).tasks.length} worktree(s))`);
520
+ return;
521
+ }
522
+ console.log(`fleet: removing session '${name}'${order.length > 1 ? ` + ${order.length - 1} descendant session(s)` : ''}`);
523
+ for (const s of order) removeOneSession(s, delBranch);
524
+ }
525
+
526
+ function cmdSessions(args) {
527
+ const sub = args[0];
528
+ if (sub === 'rm' || sub === 'remove') return cmdRemoveSession(args.slice(1));
529
+ return cmdLsSessions();
530
+ }
531
+
532
+ // Set of currently-live tmux pane ids (for status).
533
+ function livePaneIds() {
534
+ if (BACKEND !== 'tmux') return new Set();
535
+ try { return new Set(tmux(['list-panes', '-a', '-F', '#{pane_id}']).split('\n').filter(Boolean)); }
536
+ catch { return new Set(); }
537
+ }
538
+
539
+ // One-glance view of a session: manager + worker tree (with live/dead, no-worktree markers).
540
+ function cmdStatus(args) {
541
+ const target = args.find((a) => !a.startsWith('-'))
542
+ || process.env.FLEET_SESSION || mostRecentManagerSession() || SESSION;
543
+ if (!fs.existsSync(statePath(target)) && !(BACKEND === 'tmux' && tmuxHasName(target))) {
544
+ console.log(`fleet: no session '${target}' (see: fleet sessions)`);
545
+ return;
546
+ }
547
+ const s = loadState(target);
548
+ const live = BACKEND === 'tmux' && tmuxHasName(target);
549
+ const panes = livePaneIds();
550
+ console.log(`session: ${target} [${live ? '● live' : '○ saved (resume to reopen)'}]`);
551
+ console.log(`manager: ${s.managerDir || '(none)'}`);
552
+
553
+ if (!s.tasks.length) { console.log('workers: (none)'); }
554
+ else {
555
+ console.log(`workers (${s.tasks.length}):`);
556
+ const byParent = {};
557
+ for (const t of s.tasks) (byParent[t.parent || ''] = byParent[t.parent || ''] || []).push(t);
558
+ const walk = (parentId, depth) => {
559
+ for (const t of byParent[parentId] || []) {
560
+ const id = `${t.repo}/${t.task}`;
561
+ const tags = [];
562
+ if (t.noWorktree) tags.push('no-worktree');
563
+ if (live && t.paneId) tags.push(panes.has(t.paneId) ? 'running' : 'pane closed');
564
+ console.log(` ${' '.repeat(depth)}• ${id}${tags.length ? ` [${tags.join(', ')}]` : ''}`);
565
+ walk(id, depth + 1);
566
+ }
567
+ };
568
+ walk('', 0);
569
+ }
570
+
571
+ const others = fs.existsSync(STATE_DIR)
572
+ ? fs.readdirSync(STATE_DIR).filter((f) => f.endsWith('.json')).map((f) => f.replace(/\.json$/, '')).filter((n) => n !== target)
573
+ : [];
574
+ if (others.length) console.log(`\nother sessions: ${others.join(', ')} (fleet sessions)`);
575
+ }
576
+
577
+ // Check prerequisites and report.
578
+ function cmdDoctor() {
579
+ const row = (label, ok, hint) => console.log(` ${ok ? '✓' : '✗'} ${label}${ok ? '' : ` — ${hint}`}`);
580
+ console.log('fleet doctor\n');
581
+ row(`node ${process.version}`, true);
582
+ row('git', have('git'), 'install git');
583
+ row('claude CLI', have('claude'), 'install the Claude Code CLI and put it on PATH');
584
+ if (process.platform !== 'win32') row('tmux', hasTmux(), 'install tmux, or set FLEET_BACKEND=windows');
585
+ row('gh (for fleet pr …)', have('gh'), 'install the GitHub CLI');
586
+ row('jq (for fleet pr …)', have('jq'), 'install jq');
587
+ if (process.platform === 'darwin') row('caffeinate', fs.existsSync('/usr/bin/caffeinate'), 'built-in on macOS — unexpected if missing');
588
+ row('/fleet command installed', fs.existsSync(path.join(HOME, '.claude', 'commands', 'fleet.md')), 'run: fleet install-claude');
589
+ console.log(`\nbackend: ${BACKEND} projects: ${PROJECTS_ROOT} worktrees: ${WT_ROOT} session: ${SESSION}`);
590
+ }
591
+
592
+ // PR review toolkit, namespaced: `fleet pr <sync|review|fix|coverage|merge> <pr> [...]`.
593
+ function cmdPr(args) {
594
+ const sub = args[0];
595
+ const map = { sync: 'sync.sh', review: 'review.sh', fix: 'fix.sh', coverage: 'coverage.sh', merge: 'merge.sh', approve: 'merge.sh' };
596
+ if (!sub || !map[sub]) die('usage: fleet pr <sync|review|fix|coverage|merge> <pr> [extra]');
597
+ cmdReview(map[sub], args.slice(1));
598
+ }
599
+
600
+ function cmdLs(args = []) {
601
+ if (args.includes('--sessions') || args.includes('-s')) return cmdLsSessions();
602
+ if (!fs.existsSync(WT_ROOT)) { console.log('no worktrees yet'); return; }
603
+ let any = false;
604
+ for (const repo of fs.readdirSync(WT_ROOT)) {
605
+ const repoDir = path.join(WT_ROOT, repo);
606
+ if (!fs.statSync(repoDir).isDirectory()) continue;
607
+ for (const task of fs.readdirSync(repoDir)) {
608
+ const wt = path.join(repoDir, task);
609
+ if (!fs.existsSync(path.join(wt, '.git'))) continue;
610
+ any = true;
611
+ const br = gitQuiet(wt, ['symbolic-ref', '--short', 'HEAD']) || '?';
612
+ const dirty = gitQuiet(wt, ['status', '--porcelain']).split('\n').filter(Boolean).length;
613
+ console.log(` ${(`${repo}/${task}`).padEnd(40)} ${br} (${dirty} changes)`);
614
+ }
615
+ }
616
+ if (!any) console.log('no worktrees yet');
617
+ }
618
+
619
+ // Rebuild a whole session after a reboot/kill: the manager pane (its conversation continued)
620
+ // plus a worker pane per task — using recorded session state. claude saves history per
621
+ // directory, so `claude --continue` picks each agent up where it left off.
622
+ function cmdResume(args) {
623
+ const dry = args.includes('--dry-run') || args.includes('-n');
624
+ if (!have('claude')) die('claude not found on PATH');
625
+ if (!dry && BACKEND === 'tmux' && !hasTmux()) die('tmux not found');
626
+
627
+ const state = loadState(SESSION);
628
+ // Prefer recorded tasks (faithful + session-scoped); fall back to scanning WT_ROOT for
629
+ // legacy worktrees created before state tracking existed.
630
+ let tasks = state.tasks.slice();
631
+ if (!tasks.length && fs.existsSync(WT_ROOT)) {
632
+ for (const repo of fs.readdirSync(WT_ROOT)) {
633
+ const repoDir = path.join(WT_ROOT, repo);
634
+ if (!fs.statSync(repoDir).isDirectory()) continue;
635
+ for (const task of fs.readdirSync(repoDir)) {
636
+ const wt = path.join(repoDir, task);
637
+ if (fs.existsSync(path.join(wt, '.git'))) tasks.push({ repo, task, wt });
638
+ }
639
+ }
640
+ }
641
+ tasks = tasks.filter((t) => fs.existsSync(t.wt));
642
+
643
+ if (!tasks.length && !state.managerDir) {
644
+ console.log(`fleet: nothing to resume for session '${SESSION}'`);
645
+ return;
646
+ }
647
+
648
+ if (dry) {
649
+ console.log(`fleet: resume plan for session '${SESSION}'`);
650
+ console.log(` manager: ${state.managerDir || '(none)'}`);
651
+ console.log(` panes to restore (${tasks.length}):`);
652
+ for (const t of tasks) console.log(` - ${t.repo}/${t.task}`);
653
+ return;
654
+ }
655
+
656
+ const cmd = ['claude', '--continue', CLAUDE_FLAGS.trim()].filter(Boolean).join(' ');
657
+
658
+ if (BACKEND === 'tmux') {
659
+ const managerDir = state.managerDir || PROJECTS_ROOT;
660
+ tmuxBackend.ensureSession(managerDir);
661
+ if (state.managerDir && tmuxBackend.launchManager(state.managerDir, true))
662
+ console.log(`fleet: restored manager at ${state.managerDir}`);
663
+ for (const t of tasks) {
664
+ backend.spawn({ wt: t.wt, cmd });
665
+ console.log(`fleet: resumed ${t.repo}/${t.task}`);
666
+ }
667
+ console.log(`fleet: session '${SESSION}' restored (${tasks.length} agent${tasks.length === 1 ? '' : 's'}) — fleet attach to watch`);
668
+ tmuxBackend.attach();
669
+ } else {
670
+ for (const t of tasks) {
671
+ backend.spawn({ wt: t.wt, cmd });
672
+ console.log(`fleet: resumed ${t.repo}/${t.task}`);
673
+ }
674
+ }
675
+ }
676
+
677
+ // Drop recorded tasks whose worktree no longer exists (merged/removed), so session state
678
+ // stops accumulating. `fleet prune [session] [--dry-run]`.
679
+ function cmdPrune(args) {
680
+ const dry = args.includes('--dry-run') || args.includes('-n');
681
+ const target = args.find((a) => !a.startsWith('-'));
682
+ if (!fs.existsSync(STATE_DIR)) { console.log('fleet: no state to prune'); return; }
683
+ const files = fs.readdirSync(STATE_DIR)
684
+ .filter((f) => f.endsWith('.json') && (!target || f === `${target}.json`));
685
+ if (!files.length) { console.log(`fleet: no state for ${target || 'any session'}`); return; }
686
+
687
+ let dropped = 0;
688
+ for (const f of files.sort()) {
689
+ const name = f.replace(/\.json$/, '');
690
+ const s = loadState(name);
691
+ const dead = s.tasks.filter((t) => !fs.existsSync(t.wt));
692
+ if (!dead.length) continue;
693
+ dropped += dead.length;
694
+ console.log(` ${name}: ${dry ? 'would drop' : 'dropped'} ${dead.length} stale (${s.tasks.length - dead.length} kept)`);
695
+ for (const t of dead) console.log(` - ${t.repo}/${t.task}`);
696
+ if (!dry) { s.tasks = s.tasks.filter((t) => fs.existsSync(t.wt)); saveState(s); }
697
+ }
698
+ if (!dropped) console.log('fleet: nothing to prune (all recorded worktrees still exist)');
699
+ else console.log(`fleet: ${dry ? 'would prune' : 'pruned'} ${dropped} stale task(s)${dry ? ' (run without --dry-run to apply)' : ''}`);
700
+ }
701
+
702
+ // Kill the tmux pane(s) whose cwd is this worktree.
703
+ function killPaneForWt(wt) {
704
+ if (BACKEND !== 'tmux') return;
705
+ try {
706
+ for (const ln of tmux(['list-panes', '-a', '-F', '#{pane_id} #{pane_current_path}']).split('\n')) {
707
+ const i = ln.indexOf(' ');
708
+ if (i > 0 && ln.slice(i + 1) === wt) tmux(['kill-pane', '-t', ln.slice(0, i)]);
709
+ }
710
+ } catch {}
711
+ }
712
+ // All recorded tasks across every session.
713
+ function allTasks() {
714
+ const out = [];
715
+ if (!fs.existsSync(STATE_DIR)) return out;
716
+ for (const f of fs.readdirSync(STATE_DIR)) {
717
+ if (!f.endsWith('.json')) continue;
718
+ for (const t of loadState(f.replace(/\.json$/, '')).tasks) out.push(t);
719
+ }
720
+ return out;
721
+ }
722
+ // Descendant tasks of <repo>/<task>, deepest-first (sub-workers before the worker).
723
+ function taskDescendants(rootId, tasks) {
724
+ const byParent = {};
725
+ for (const t of tasks) if (t.parent) (byParent[t.parent] = byParent[t.parent] || []).push(t);
726
+ const out = [];
727
+ (function walk(id) { (byParent[id] || []).forEach((c) => { walk(`${c.repo}/${c.task}`); out.push(c); }); })(rootId);
728
+ return out;
729
+ }
730
+
731
+ // Close a worker's pane: by recorded pane id (robust for no-worktree workers that share the
732
+ // repo cwd), else by cwd for real worktrees (unique dirs). Never guesses by cwd for
733
+ // no-worktree workers — that would also match the manager.
734
+ function killPane(t) {
735
+ if (BACKEND !== 'tmux') return;
736
+ if (t.paneId) { try { tmux(['kill-pane', '-t', t.paneId]); return; } catch {} }
737
+ if (!t.noWorktree) killPaneForWt(t.wt);
738
+ }
739
+
740
+ // Remove a worker AND every sub-worker it spawned (chain), closing panes + worktrees.
741
+ function cmdRm(args) {
742
+ const delBranch = args.includes('--branch');
743
+ const dry = args.includes('--dry-run') || args.includes('-n');
744
+ const self = args.includes('--self');
745
+ let reponame, task;
746
+ if (self) {
747
+ const id = process.env.FLEET_TASK;
748
+ if (id && id.includes('/')) {
749
+ reponame = id.slice(0, id.indexOf('/'));
750
+ task = id.slice(id.indexOf('/') + 1);
751
+ } else if (process.env.TMUX_PANE) {
752
+ // No task identity, but we can still self-destruct the current pane.
753
+ try { tmux(['kill-pane', '-t', process.env.TMUX_PANE]); } catch {}
754
+ console.log('fleet: closed current worker pane (no FLEET_TASK — chain not resolved)');
755
+ return;
756
+ } else {
757
+ die('--self requires being inside a fleet worker (FLEET_TASK / TMUX_PANE unset)');
758
+ }
759
+ } else {
760
+ const pos = args.filter((a) => !a.startsWith('-'));
761
+ if (pos.length < 2) die('usage: fleet rm <repo> <task> [--branch] (or --self inside a worker)');
762
+ reponame = path.basename(pos[0].replace(/\/+$/, ''));
763
+ task = slug(pos[1]);
764
+ }
765
+
766
+ const rootId = `${reponame}/${task}`;
767
+ const rootWt = path.join(WT_ROOT, reponame, task);
768
+ const tasks = allTasks();
769
+ const recordedRoot = tasks.find((t) => `${t.repo}/${t.task}` === rootId);
770
+ const rootEntry = recordedRoot || { repo: reponame, task, wt: rootWt };
771
+ // For --self, fall back to the live pane id if state didn't record one.
772
+ if (self && !rootEntry.paneId && process.env.TMUX_PANE) rootEntry.paneId = process.env.TMUX_PANE;
773
+ const descendants = taskDescendants(rootId, tasks); // deepest-first
774
+ if (!recordedRoot && !fs.existsSync(rootWt) && !descendants.length && !rootEntry.paneId)
775
+ die(`no worktree or recorded worker for ${rootId}`);
776
+
777
+ const targets = [...descendants, rootEntry]; // children first, root last
778
+ if (dry) {
779
+ console.log(`fleet: would remove ${targets.length} worker(s)${descendants.length ? ` (incl. ${descendants.length} sub-worker(s))` : ''}${delBranch ? ' + branches' : ''}:`);
780
+ for (const t of targets) console.log(` - ${t.repo}/${t.task}${t.noWorktree ? ' (pane only)' : ''}`);
781
+ return;
782
+ }
783
+ for (const t of targets) {
784
+ // Clean up state + worktree BEFORE killing the pane — for `--self`, killing our own pane
785
+ // terminates this process, so the cleanup must already be done by then.
786
+ if (!t.noWorktree) removeWorktreeByPath(t.wt, t.task, delBranch);
787
+ unrecordTaskEverywhere(t.repo, t.task);
788
+ console.log(` removed ${t.repo}/${t.task}${t.noWorktree ? ' (pane only)' : ''}`);
789
+ killPane(t);
790
+ }
791
+ console.log(`fleet: removed ${targets.length} worker(s)${descendants.length ? ` (incl. ${descendants.length} sub-worker(s))` : ''}${delBranch ? ' + branches' : ''}`);
792
+ }
793
+
794
+ function cmdInstallClaude() {
795
+ const dir = path.join(HOME, '.claude', 'commands');
796
+ const src = path.join(__dirname, '..', 'commands', 'fleet.md');
797
+ if (!fs.existsSync(src)) die(`bundled command file missing: ${src}`);
798
+ fs.mkdirSync(dir, { recursive: true });
799
+ const dest = path.join(dir, 'fleet.md');
800
+ fs.copyFileSync(src, dest);
801
+ console.log(`fleet: installed /fleet command -> ${dest}`);
802
+ console.log(' Open Claude Code and type /fleet to use it.');
803
+ }
804
+
805
+ // ----------------------------------------------------------------------------- review family
806
+ // Faithful wrappers around the bundled PR review toolkit (review/*.sh).
807
+ // They operate on a PR number in a real git repo (gh + jq + bash required).
808
+ // sync <pr> checkout PR as pr/<num>, merge main, wire push remote
809
+ // review <pr> [extra] sync + CodeRabbit-style review agent
810
+ // fix <pr> [extra] sync + review-and-fix agent (commit & push)
811
+ // coverage <pr> [extra] sync + fix the coverage gate
812
+ // approve/merge <pr> [merge-flags] gate 8 checks + squash-merge via gh
813
+ function cmdReview(scriptName, args) {
814
+ // -C/--repo <dir> chooses the repo working tree (default: cwd). By default these open a
815
+ // new tmux pane (like `fleet add`); `--here`/`--fg` runs in the foreground instead.
816
+ // Everything else passes through to the bundled script verbatim.
817
+ let [dir, rest] = takeFlag(args, ['-C', '--repo']);
818
+ let foreground = false;
819
+ rest = rest.filter((a) => {
820
+ if (a === '--here' || a === '--fg' || a === '--foreground') { foreground = true; return false; }
821
+ if (a === '--pane') return false; // accepted for back-compat; pane is now the default
822
+ return true;
823
+ });
824
+
825
+ const cwd = dir ? path.resolve(dir) : process.cwd();
826
+ if (process.platform === 'win32')
827
+ die('the review workflow needs bash + gh + jq — run it under WSL on Windows');
828
+ if (!fs.existsSync(path.join(cwd, '.git')))
829
+ die(`not a git repo: ${cwd} (run inside the repo, or pass -C <repo-dir>)`);
830
+ for (const t of ['bash', 'gh', 'jq', 'git'])
831
+ if (!have(t)) die(`missing required tool: ${t}`);
832
+
833
+ const script = path.join(__dirname, '..', 'review', scriptName);
834
+ if (!fs.existsSync(script)) die(`bundled review script missing: ${script}`);
835
+
836
+ // sync/review/fix/coverage check out the PR — run them in a dedicated review worktree so
837
+ // the primary checkout is never touched (and reviews can run in parallel). merge.sh never
838
+ // checks out, so it skips this.
839
+ const usesSync = ['sync.sh', 'review.sh', 'fix.sh', 'coverage.sh'].includes(scriptName);
840
+ const pr = rest.find((a) => /^\d+$/.test(a));
841
+ const wtEnv = {};
842
+ if (usesSync && pr) {
843
+ wtEnv.REVIEW_WORKTREE = '1';
844
+ wtEnv.REVIEW_WT_DIR = path.join(WT_ROOT, path.basename(cwd), `pr-${pr}`);
845
+ }
846
+
847
+ const inPane = !foreground && BACKEND === 'tmux';
848
+ if (inPane) {
849
+ tmuxBackend.ensureSession(cwd);
850
+ const envPrefix = Object.entries(wtEnv).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
851
+ const cmd = [envPrefix, 'bash', shq(script), ...rest.map(shq)].filter(Boolean).join(' ');
852
+ const pid = tmux(['split-window', '-P', '-F', '#{pane_id}', '-t', SESSION, '-c', cwd,
853
+ process.env.SHELL || '/bin/sh']);
854
+ tmux(['select-layout', '-t', SESSION, 'tiled']);
855
+ tmux(['send-keys', '-t', pid, cmd, 'Enter']);
856
+ const name = scriptName.replace('.sh', '');
857
+ const where = wtEnv.REVIEW_WT_DIR ? ` (worktree: ${path.basename(wtEnv.REVIEW_WT_DIR)})` : '';
858
+ console.log(`fleet: ${name} ${rest[0] || ''} running in a pane${where} — fleet attach to watch`);
859
+ } else {
860
+ const r = spawnSync('bash', [script, ...rest], { cwd, stdio: 'inherit', env: { ...process.env, ...wtEnv } });
861
+ process.exit(r.status == null ? 1 : r.status);
862
+ }
863
+ }
864
+
865
+ const HELP = `fleet — run multiple Claude Code agents in parallel, each in its own git worktree.
866
+
867
+ Backend: ${BACKEND}${BACKEND === 'tmux' ? ' (manager + worker panes in one tmux session)' : ' (each agent in its own terminal window)'}
868
+
869
+ SESSIONS (a tmux session = a manager + its worker panes)
870
+ fleet manager [dir] [--name X] [--window] open an orchestrator claude (rooted at dir; default cwd)
871
+ --window: new window in the CURRENT tmux session
872
+ fleet attach [--name X] attach to a session
873
+ fleet status [session] one-glance view: manager + worker tree, live/saved
874
+ fleet sessions list all sessions (manager, tasks, live panes)
875
+ fleet resume [session] [--dry-run] rebuild a session (manager + workers, conversations continued)
876
+ fleet kill [--name X] stop a session's tmux (keeps worktrees + state → resumable)
877
+
878
+ LAUNCH WORK (spawns a worker pane in its own worktree)
879
+ fleet add <repo> <task> "<prompt>|<file.md>" [base] [--skill NAME] [--no-worktree]
880
+ fleet research <repo> <task> "<issue|file.md>" [base] read-only investigation (= --skill research)
881
+ ( default repo '.' = current repo. --no-worktree runs in the repo itself, no branch. )
882
+
883
+ SKILLS (named prompt templates the worker is seeded with)
884
+ fleet skill ls | add <name> <file.md> | rm <name> | show <name>
885
+
886
+ CLEAN UP
887
+ fleet rm <repo> <task> [--branch] [--dry-run] remove a worker + every sub-worker it spawned
888
+ fleet rm --self [--branch] (inside a worker) remove itself + its chain
889
+ fleet sessions rm <session> [--branch] [--dry-run] remove a session + ALL child sessions
890
+ fleet prune [session] [--dry-run] drop recorded tasks whose worktree is gone
891
+
892
+ PR REVIEW (needs git + gh + jq; run in the repo or pass -C <repo>; open a pane, --here for foreground)
893
+ fleet pr sync <pr> checkout PR as pr/<num>, merge base, wire push
894
+ fleet pr review <pr> [extra] CodeRabbit-style review agent
895
+ fleet pr fix <pr> [extra] review-and-fix agent (commit & push)
896
+ fleet pr coverage <pr> [extra] fix the coverage gate
897
+ fleet pr merge <pr> [--squash|--merge|--rebase] [--dry-run] [--summary-llm <tool>]
898
+
899
+ SETUP
900
+ fleet install-claude install the /fleet slash command for Claude Code
901
+ fleet doctor check prerequisites (tmux/gh/jq/claude/…)
902
+ fleet help
903
+
904
+ From inside Claude: /fleet <prompt> — the manager picks a skill + a mode (once/loop/goal) and dispatches.
905
+
906
+ Examples:
907
+ fleet manager ~/code/myapp
908
+ fleet add . fix-parser "Fix the CSV parser crash and add a test"
909
+ fleet add . migrate ./tasks/migrate-db.md
910
+ fleet research . why-slow "trace why the dashboard query is slow" --no-worktree
911
+
912
+ Env:
913
+ FLEET_BACKEND tmux | windows (default: auto — tmux if available, else windows)
914
+ FLEET_MODE=window new window per task vs tiled pane (tmux only)
915
+ PROJECTS_ROOT parent dir of your repos (default: ~/Projects)
916
+ WT_ROOT where worktrees live (default: $PROJECTS_ROOT/.worktrees)
917
+ FLEET_SESSION tmux session name (default: fleet)
918
+ FLEET_CLAUDE_FLAGS flags for launched claude (default: --dangerously-skip-permissions)
919
+ FLEET_NO_CAFFEINATE=1 don't hold a macOS caffeinate assertion while the session is alive`;
920
+
921
+
922
+ // ----------------------------------------------------------------------------- dispatch
923
+ const SESSION_CMDS = new Set(['manager', 'up', 'add', 'research', 'investigate', 'attach', 'kill']);
924
+
925
+ // Pick the most recently active MANAGER session: prefer one that's currently live, else the
926
+ // most-recently-updated session that has a managerDir. Returns a session name or null.
927
+ function mostRecentManagerSession() {
928
+ if (!fs.existsSync(STATE_DIR)) return null;
929
+ const files = fs.readdirSync(STATE_DIR).filter((f) => f.endsWith('.json')).map((f) => {
930
+ const p = path.join(STATE_DIR, f);
931
+ let managerDir = null;
932
+ try { managerDir = JSON.parse(fs.readFileSync(p, 'utf8')).managerDir; } catch {}
933
+ return { name: f.replace(/\.json$/, ''), mtime: fs.statSync(p).mtimeMs, managerDir };
934
+ });
935
+ const managers = files.filter((x) => x.managerDir);
936
+ const pool = managers.length ? managers : files;
937
+ if (!pool.length) return null;
938
+ const live = pool.filter((x) => BACKEND === 'tmux' && tmuxHasName(x.name));
939
+ return (live.length ? live : pool).sort((a, b) => b.mtime - a.mtime)[0].name;
940
+ }
941
+
942
+ function main() {
943
+ let [cmd, ...rest] = process.argv.slice(2);
944
+ // `--name`/`-n` overrides the session — but ONLY for session-scoped commands, so it
945
+ // doesn't clash with review-family flags (e.g. merge.sh's `-n` = --dry-run).
946
+ if (SESSION_CMDS.has(cmd)) {
947
+ const [v, r] = takeFlag(rest, ['--name', '-n']);
948
+ rest = r; if (v) SESSION = v;
949
+ }
950
+
951
+ switch (cmd) {
952
+ case 'manager':
953
+ case 'up': {
954
+ // `fleet manager [--dir/-d <path>] [<path>] [--name X] [--window]` — root at a dir.
955
+ // Default dir: where fleet was invoked. --window opens it as a new window in the
956
+ // current tmux session instead of its own session.
957
+ let [d, r] = takeFlag(rest, ['--dir', '-d']);
958
+ let windowMode = false;
959
+ r = r.filter((a) => ((a === '--window' || a === '-w') ? ((windowMode = true), false) : true));
960
+ if (!d && r[0] && !r[0].startsWith('-')) d = r[0];
961
+ const cwd = d ? path.resolve(d) : process.cwd();
962
+ if (!fs.existsSync(cwd)) die(`manager dir does not exist: ${cwd}`);
963
+ if (windowMode) {
964
+ if (BACKEND !== 'tmux') die('--window requires the tmux backend');
965
+ if (!process.env.TMUX) die('--window must be run from inside tmux (it adds a window to the current session)');
966
+ tmuxBackend.managerWindow(cwd);
967
+ } else {
968
+ backend.manager({ cwd }); // records managerDir only when it actually launches (no drift)
969
+ }
970
+ break;
971
+ }
972
+ case 'add': cmdAdd(rest); break;
973
+ case 'research':
974
+ case 'investigate': cmdResearch(rest); break;
975
+ case 'ls':
976
+ case 'list': cmdLs(rest); break;
977
+ case 'sessions': cmdSessions(rest); break;
978
+ case 'prune': cmdPrune(rest); break;
979
+ case 'skill':
980
+ case 'skills': cmdSkill(rest); break;
981
+ case 'resume':
982
+ case 'restore': {
983
+ // Target session: --name > positional <session> > most recently active manager.
984
+ let [nm, r] = takeFlag(rest, ['--name', '-n']);
985
+ const positional = r.find((a) => !a.startsWith('-'));
986
+ const target = nm || positional || mostRecentManagerSession();
987
+ if (!target) { console.log('fleet: no recorded manager sessions to resume'); break; }
988
+ if (!nm && !positional) console.log(`fleet: resuming most recently active manager → '${target}'`);
989
+ SESSION = target;
990
+ cmdResume(r);
991
+ break;
992
+ }
993
+ case 'rm':
994
+ case 'remove': cmdRm(rest); break;
995
+ case 'attach': backend.attach(); break;
996
+ case 'kill': backend.kill(); break;
997
+ case 'pr': cmdPr(rest); break;
998
+ // deprecated top-level PR commands — prefer `fleet pr <cmd>`
999
+ case 'sync':
1000
+ case 'review':
1001
+ case 'fix':
1002
+ case 'coverage':
1003
+ case 'approve':
1004
+ case 'merge':
1005
+ console.error(`fleet: '${cmd}' is now 'fleet pr ${cmd === 'approve' ? 'merge' : cmd}' (running anyway)`);
1006
+ cmdPr([cmd, ...rest]); break;
1007
+ case 'status': cmdStatus(rest); break;
1008
+ case 'doctor': cmdDoctor(); break;
1009
+ case 'install-claude': cmdInstallClaude(); break;
1010
+ case undefined:
1011
+ case 'help':
1012
+ case '-h':
1013
+ case '--help': console.log(HELP); break;
1014
+ default: die(`unknown command '${cmd}' (try: fleet help)`);
1015
+ }
1016
+ }
1017
+
1018
+ main();