#!/usr/bin/env node // fleet.mjs — start, list and stop the fleet's sessions from fleet.config.json. // // fleet init [--spec ] [--provider name] // copy templates + tools (never overwrites). Creates the // git repo if needed. --provider (or --harness) sets harness. // Writes fleet/context.md and commits only the setup files. // Starts the session manager at a terminal, or tells the // user's agent to `fleet up` and talk to it. // fleet setup-commit commit only those setup files (same as the MCP tool) // fleet tell mail the session manager from the interpreter. // An open window is woken with a short line, not the body. // fleet hear print unread mail for the interpreter, and take it out of unread // fleet worktrees [--only a,b] create missing worktrees (one per area, branch area/) // refuses while fleet files are still uncommitted // fleet up [--only a,b] [--skip a,b] [--bg] [--fresh] [--dry-run] // start every area (dispatcher last); resume by session id // when the provider can resume it, else start fresh with the resume brief. // Skips an area whose tmux window is already open. // budgetMin is an optional limit on a working session (default // unlimited). It is not a hung-job timeout; headless jobs use timeoutMin. // fleet down [--only a,b] stop area process groups; with no --only, also the tmux // session and the lander // fleet supervise run one area's recorded command in the foreground of this // terminal. budgetMin 0 means no time limit. // fleet commit -m [--area id] [--all] [--submit] [--] [paths...] // stage and commit in the area worktree this process // is in (not inside an agent sandbox). Refuses the // main checkout and paths outside that worktree. // --submit then queues the tip (landq submit). // fleet doctor | landq | backlog | mailbox // the same tools as tools/fleet/bin/.mjs // fleet status areas, run state (running / exited / failed / timeout / stopped), lander up or down // fleet prompt print the composed system prompt for an area // fleet crontab PRINT the crontab lines for scheduled jobs (never installs) // fleet run-job one headless run of docs/agents/schedules/.md (sched.sh calls this) // fleet sched-env shell vars for sched.sh // fleet lander start|status start a drain-and-exit lander, or print its status // // Launch modes for `up`: --bg (harness background sessions, if the adapter has them); // otherwise one tmux window per area in session "fleet-" when tmux exists; // otherwise the commands are printed for you to run. import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { KIT, loadConfig, loadAdapter, area as getArea, dispatcher, sessionName, flag, die, writeAtomic, tryGit, mainRef, today, runProcessGroup, runForeground, finishHeadless, withHandoff, applyHeadlessSession, assignLaunchSession, areaBudgetMin, readRun, writeRun, outcomeOfRun, pidAlive, killProcessGroup, landerIsUp, landerPid, startLander, writableRoots } from './lib.mjs'; import { commitWork, setupCommit, tellDispatch, hearInterpreter, fleetStatus } from './core/ops.mjs'; const argv = process.argv.slice(2); const cmd = argv[0]; const has = (f) => argv.includes(f); const list = (f) => (flag(argv, f) || '').split(',').filter(Boolean); const q = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`); if (!cmd || cmd === 'help' || cmd === '--help') { console.log(fs.readFileSync(new URL(import.meta.url), 'utf8').split('\n').filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n')); process.exit(0); } if (cmd === 'init') { init(argv[1], flag(argv, '--provider') || flag(argv, '--harness'), flag(argv, '--spec')); process.exit(0); } const c = loadConfig(); const SESSIONS = path.join(c.stateDir, 'sessions.json'); // The lander and the scheduler run the tools and config LANDED on main (the main checkout), // never a copy inside some area's worktree. const MAIN_CFG = fs.existsSync(path.join(c.repo, 'fleet.config.json')) ? path.join(c.repo, 'fleet.config.json') : c.file; const MAIN_BIN = fs.existsSync(path.join(c.repo, 'tools/fleet/bin/landq.mjs')) ? path.join(c.repo, 'tools/fleet/bin') : path.join(KIT, 'bin'); const readSessions = () => { try { return JSON.parse(fs.readFileSync(SESSIONS, 'utf8')); } catch { return {}; } }; const pick = () => { const only = list('--only'), skip = list('--skip'); for (const id of [...only, ...skip]) getArea(c, id); return c.areas.filter((a) => (!only.length || only.includes(a.id)) && !skip.includes(a.id)); }; function composePrompt(a, adapter) { const dir = path.join(c.repo, c.paths.prompts); const commonPath = path.join(dir, 'COMMON.md'); const common = fs.existsSync(commonPath) ? fs.readFileSync(commonPath, 'utf8') : die(`missing ${dir}/COMMON.md`); const own = ownPrompt(a); const peers = c.areas.filter((p) => p.id !== a.id).map((p) => { const owns = p.owns.length ? `owns ${p.owns.join(', ')}` : 'owns nothing listed'; const not = p.doesNotOwn.length ? `; does not own ${p.doesNotOwn.join(', ')}` : ''; const neigh = p.neighbours.length ? `; neighbours ${p.neighbours.join(', ')}` : ''; return `- ${p.id} — ${p.title} (${owns}${not}${neigh})`; }).join('\n'); const vars = { project: c.project, 'area.id': a.id, 'area.title': a.title, 'area.role': a.role, 'area.owns': a.owns.join(', ') || '(none listed)', 'area.doesNotOwn': a.doesNotOwn.join(', ') || '(none listed)', 'area.neighbours': a.neighbours.join(', ') || '(none listed)', 'area.branch': a.branch, 'area.worktree': a.worktree, mainBranch: c.mainBranch, peers, dispatcher: dispatcher(c)?.id ?? '(none)', gate: c.gates.land ?? '(no gate configured)', quietHours: c.clock.quietHours.join('–'), messaging: adapter.messaging?.instructions ?? '', }; return `${common}\n\n${own}`.replace(/\{\{([\w.]+)\}\}/g, (m, k) => vars[k] ?? m); } function ownPrompt(a) { const dir = path.join(c.repo, c.paths.prompts); const names = a.role === 'dispatcher' ? ['session-manager.md', `${a.id}.md`] : [`${a.id}.md`]; for (const name of names) { const file = path.join(dir, name); if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8'); } if (a.role === 'dispatcher') { const kit = path.join(KIT, 'templates/prompts/session-manager.md'); if (fs.existsSync(kit)) return fs.readFileSync(kit, 'utf8'); } die(`missing ${dir}/${a.id}.md (copy area.md and fill it in)`); } function ruleChangeNote() { return '\n\nRead fleet/RULE-CHANGES.md newest first now and before every item. It overrides this prompt.'; } function resumePrompt(a) { return `${c.project} fleet start. You are ${a.title} (area "${a.id}"). Read ${c.paths.resume}, your area's entry in ${c.paths.todo}, ` + `your .task and your mailbox. \`node tools/fleet/bin/backlog.mjs next --area ${a.id}\` lists open items; pick by PROGRAM.md, then claim that id. ` + `Before starting an item, check it still applies at HEAD (filedAt is the commit it was filed against). Report to the session manager only if blocked.` + ruleChangeNote(); } function dispatcherWake(a) { return `${c.project} fleet start. You are the session manager (${a.id}). Read fleet/context.md and fleet/GOAL.md. ` + 'If GOAL.md is missing, ask the goal questions and write fleet/GOAL.md yourself. ' + 'The user talks to their own agent, the interpreter. A line that says you have new fleet mail is only a wake. Read the mailbox; that takes the messages out of unread. Answer once, by mailbox, with your next action. ' + 'Read your mailbox at the start of every turn. The interpreter\'s yes counts for a routine decision. ' + 'Money, pushes, deploys, deletes, secrets, outgoing messages, and a bigger structural or directional change go to the real user. ' + 'Do not ask the user to edit a file, attach to tmux, or run a command. On the first loop, show the proposed team and ask before you start anyone. ' + `Read ${c.paths.resume} and ${c.paths.program}.` + ruleChangeNote(); } const adapterFor = (name) => loadAdapter(name, c.harnesses?.[name]); async function launch(a, { fresh, bg, dry, sessions }) { const ad = await adapterFor(a.harness); if (!ensureWorktree(a)) return true; const windowNames = () => { const listed = spawnSync('tmux', ['list-windows', '-t', tmuxSession(), '-F', '#{window_name}'], { encoding: 'utf8' }); return listed.status === 0 ? listed.stdout.split('\n').filter(Boolean) : []; }; const open = windowNames().includes(a.id); if (open && !dry) { console.error(`warn: ${a.id} already has tmux window ${tmuxSession()}:${a.id}; skipping. \`fleet down --only ${a.id}\` closes it`); return true; } const promptText = composePrompt(a, ad); const promptFile = path.join(c.stateDir, 'prompts', `${a.id}.md`); if (!dry) writeAtomic(promptFile, promptText); let first = a.role === 'dispatcher' ? dispatcherWake(a) : resumePrompt(a); if (ad.experimental) console.error(`[${a.id}] provider "${ad.name}" is experimental: flags checked against its CLI help, not a live model turn`); if (!ad.start) { // stub harness: hand it to the human const p = path.join(a.worktree, '.fleet', 'PROMPT.md'); if (!dry) writeAtomic(p, `${promptText}\n\n---\nFIRST MESSAGE:\n${first}\n`); console.log(`[${a.id}] harness "${a.harness}" adapter is a stub: start it by hand in ${a.worktree} with model tier ${a.tier}${a.context ? `, context ${a.context}` : ''}, and give it ${p}`); return true; } const opts = { model: a.model, context: a.context, remoteControl: a.remoteControl, cwd: a.worktree, promptFile, writableDirs: writableRoots(c, { mkdir: !dry }) }; let argvL; let sid = sessions[a.id]; const resumable = !fresh && sid && ad.resume && ad.canResume(sid, { cwd: a.worktree }); if (resumable) argvL = ad.resume({ ...opts, sessionId: sid }); if (!argvL) { first = withHandoff(ad.name, first, { stored: fresh ? null : sid, resumable: false }); sid = assignLaunchSession(sessions, a.id, ad); argvL = ad.start({ ...opts, sessionId: sid, sessionName: sessionName(c, a), firstPrompt: first }); } const env = ad.cleanEnv(process.env); const budgetMin = areaBudgetMin(c, a); const budgetLabel = budgetMin > 0 ? `${budgetMin}m` : 'unlimited'; const shown = argvL.map((s) => (s.length > 80 ? `${q(s.slice(0, 60))}…` : q(s))).join(' '); if (dry) { console.log(`[dry] ${a.id.padEnd(16)} ${a.tier}/${a.model ?? '-'} ctx=${a.context ?? '-'} rc=${a.remoteControl} budget=${budgetLabel} (cd ${a.worktree} && ${shown})${open ? ' WINDOW OPEN' : ''}`); return true; } if (bg) { if (!ad.background) die(`harness ${a.harness} has no background mode; drop --bg`); const r = spawnSync(ad.background(argvL)[0], ad.background(argvL).slice(1), { cwd: a.worktree, env, stdio: 'inherit' }); console.log(`[${a.id}] background start exit ${r.status} (background mode is not under the area budget)`); return true; } writeRun(c, a.id, { areaId: a.id, sessionId: sid ?? null, argv: argvL, cwd: a.worktree, budgetMin, status: 'running', startedAt: new Date().toISOString(), pid: null, }); const sup = [process.execPath, process.argv[1], 'supervise', a.id, '--config', c.file].map(q).join(' '); if (spawnSync('tmux', ['-V']).status === 0) { const s = tmuxSession(); if (spawnSync('tmux', ['has-session', '-t', s]).status !== 0) spawnSync('tmux', ['new-session', '-d', '-s', s, '-n', 'shell']); spawnSync('tmux', ['new-window', '-t', s, '-n', a.id, '-c', a.worktree, sup], { stdio: 'inherit' }); console.log(`[${a.id}] tmux window ${s}:${a.id} budget=${budgetLabel}`); } else { console.log(`[${a.id}] no tmux: run this yourself\n ${sup}`); } return true; } const tmuxSession = () => `fleet-${c.project}`.replace(/[.:]/g, '-'); async function supervise(id) { if (!id) die('usage: fleet supervise '); const a = getArea(c, id); const run = readRun(c, id); if (!run?.argv?.length) die(`no recorded command for ${id} (fleet up writes it)`); const ad = await adapterFor(a.harness); const budgetMin = Number.isFinite(Number(run.budgetMin)) ? Number(run.budgetMin) : areaBudgetMin(c, a); let childPid = run.pid ?? null; const result = await runForeground({ cmd: run.argv[0], args: run.argv.slice(1), cwd: run.cwd || a.worktree, env: ad.cleanEnv(process.env), timeoutMs: budgetMin > 0 ? budgetMin * 60000 : 0, onSpawn(pid) { childPid = pid; writeRun(c, id, { ...readRun(c, id), pid, status: 'running' }); }, }); const outcome = outcomeOfRun(result); const latest = readRun(c, id); // fleet down marks reason "down" before the signal. A 143 from that kill is a stop. const stoppedByDown = latest?.reason === 'down'; writeRun(c, id, stoppedByDown ? { ...latest, pid: childPid, status: 'stopped', reason: 'down', endedAt: new Date().toISOString() } : { ...latest, pid: childPid, ...outcome, endedAt: new Date().toISOString() }); if (!outcome.ok) console.error(`[${id}] ${outcome.status}: ${outcome.reason}`); process.exit(outcome.ok ? 0 : (result.timedOut || result.signal ? 124 : (Number.isInteger(result.status) && result.status > 0 ? result.status : 1))); } async function up() { const fresh = has('--fresh'), bg = has('--bg'), dry = has('--dry-run'); const sessions = readSessions(); const areas = pick(); const order = [...areas.filter((a) => a.role !== 'dispatcher'), ...areas.filter((a) => a.role === 'dispatcher')]; let refused = 0; for (const a of order) if (!(await launch(a, { fresh, bg, dry, sessions }))) refused++; if (!dry) { writeAtomic(SESSIONS, JSON.stringify(sessions, null, 2) + '\n'); if (c.landing.autoStart !== false) lander('start', true); } if (refused) process.exit(1); } function stopPid(pid) { if (!pid || !pidAlive(pid)) return false; killProcessGroup(pid, 'SIGTERM'); spawnSync('sleep', ['1']); if (pidAlive(pid)) killProcessGroup(pid, 'SIGKILL'); return true; } async function down() { const areas = pick(); const full = !list('--only').length; for (const a of areas) { const ad = await adapterFor(a.harness); const run = readRun(c, a.id); const sid = readSessions()[a.id]; let killed = false; if (run?.pid && pidAlive(run.pid)) { writeRun(c, a.id, { ...run, reason: 'down' }); killed = stopPid(run.pid); } if (killed) console.log(`[${a.id}] stopped process group ${run.pid}`); else console.log(`[${a.id}] no running process`); if (ad.stop && sid) { const [b, ...rest] = ad.stop(sid); console.log(`[${a.id}] adapter stop exit ${spawnSync(b, rest, { stdio: 'inherit' }).status}`); } const latest = readRun(c, a.id); if (killed || latest?.status === 'running') writeRun(c, a.id, { ...latest, status: 'stopped', reason: 'down', endedAt: new Date().toISOString() }); if (!full) spawnSync('tmux', ['kill-window', '-t', `${tmuxSession()}:${a.id}`]); } if (full) { spawnSync('tmux', ['kill-session', '-t', tmuxSession()]); const pid = landerPid(c); if (stopPid(pid)) console.log(`lander stopped (${pid})`); else console.log('lander not running'); console.log(`stopped tmux session ${tmuxSession()}`); } } function runMark(a, sessions) { const run = readRun(c, a.id); if (run?.status === 'running' && run.pid && pidAlive(run.pid)) return `running pid ${run.pid}`; if (run?.status === 'timeout') return 'timeout'; if (run?.status === 'failed') return `failed ${run.reason ?? ''}`.trim(); if (run?.status === 'stopped') return 'stopped'; if (run?.status === 'exited') return 'exited'; if (run?.status === 'running') return 'not running'; if (sessions[a.id]) return 'not running'; return 'never started'; } async function status() { const sessions = readSessions(); const report = fleetStatus(c); console.log(`${c.project} repo ${c.repo} state ${c.stateDir} lander ${landerIsUp(c) ? 'up' : 'DOWN'}`); for (const a of c.areas) { const budgetMin = areaBudgetMin(c, a); const row = report.areas.find((r) => r.id === a.id); const live = row ? ` window=${row.window} unread=${row.unread} ${row.activity}` : ''; console.log(`${a.id.padEnd(18)} ${a.role.padEnd(10)} ${a.harness.padEnd(7)} ${`${a.tier}/${a.model ?? '-'}`.padEnd(16)} ctx=${String(a.context ?? '-').padEnd(5)} rc=${a.remoteControl ? 'on ' : 'off'} budget=${budgetMin > 0 ? `${budgetMin}m` : 'unlimited'} ${fs.existsSync(a.worktree) ? '' : 'NO-WORKTREE '}${runMark(a, sessions)}${live}`); } if (report.interpreter.unread) console.log(`interpreter unread=${report.interpreter.unread}`); for (const id of report.recovered) console.log(`nudged ${id} again`); } function ensureWorktree(a) { if (fs.existsSync(a.worktree)) return true; const dirty = uncommittedFleetFiles(); if (dirty.length) { console.error(`skip ${a.id}: commit fleet files before starting:\n${dirty.map((l) => ` ${l}`).join('\n')}`); return false; } const hasBranch = tryGit(c.repo, 'rev-parse', '--verify', '-q', `refs/heads/${a.branch}`).ok; const r = hasBranch ? tryGit(c.repo, 'worktree', 'add', a.worktree, a.branch) : tryGit(c.repo, 'worktree', 'add', '-b', a.branch, a.worktree, mainRef(c)); if (!r.ok) { console.error(`skip ${a.id}: ${r.err}`); return false; } console.log(`made ${a.id} ${a.worktree} ${a.branch}`); return true; } function uncommittedFleetFiles() { const r = tryGit(c.repo, 'status', '--porcelain', '--', 'fleet.config.json', 'tools/fleet', 'docs/agents', 'fleet', 'AGENTS.md', '.agents'); if (!r.ok) return []; return r.out.split('\n').map((l) => l.trimEnd()).filter(Boolean); } function worktrees() { const pending = pick().filter((a) => !fs.existsSync(a.worktree)); const dirty = pending.length ? uncommittedFleetFiles() : []; if (dirty.length) { die(`commit fleet files before \`fleet worktrees\`. Uncommitted files are not in the new worktrees:\n${dirty.map((l) => ` ${l}`).join('\n')}`); } for (const a of pick()) { if (fs.existsSync(a.worktree)) { console.log(`ok ${a.id} ${a.worktree}`); continue; } const hasBranch = tryGit(c.repo, 'rev-parse', '--verify', '-q', `refs/heads/${a.branch}`).ok; const r = hasBranch ? tryGit(c.repo, 'worktree', 'add', a.worktree, a.branch) : tryGit(c.repo, 'worktree', 'add', '-b', a.branch, a.worktree, mainRef(c)); console.log(`${r.ok ? 'made' : 'FAIL'} ${a.id} ${a.worktree} ${r.ok ? a.branch : r.err}`); } } function crontab() { const sched = path.join(MAIN_BIN, 'sched.sh'); console.log(`# agent-fleet ${c.project} — review, then add with \`crontab -e\` yourself. Nothing was installed.`); console.log(`FLEET_CONFIG=${MAIN_CFG}`); for (const s of c.schedules) console.log(`${s.cron} ${sched} ${s.name} # ${s.note ?? ''}`); } function schedule(name) { if (!/^[a-z0-9-]+$/.test(name || '')) die('usage: fleet run-job '); const s = c.schedules.find((x) => x.name === name) ?? {}; const file = path.join(c.repo, c.paths.schedules, `${name}.md`); return { s, file }; } async function runJob(name) { const { s, file } = schedule(name); if (!fs.existsSync(file)) die(`no prompt ${file}`, 2); const d = s.area ? getArea(c, s.area) : dispatcher(c) ?? die('no dispatcher area and no schedules[].area'); const providerName = s.provider ?? s.harness ?? d.harness; const ad = await loadAdapter(providerName, c.harnesses?.[providerName]); if (!ad.headless) die(`harness ${ad.name} has no verified headless mode (adapter stub)`, 2); const tier = s.tier ?? c.domains.scheduled.tier; const model = c.harnesses?.[ad.name]?.tiers?.[tier] ?? ad.tiers[tier]; const vars = { project: c.project, date: today(), stateDir: c.stateDir }; const prompt = fs.readFileSync(file, 'utf8').replace(/\{\{(\w+)\}\}/g, (m, k) => vars[k] ?? m); const [b, ...rest] = ad.headless({ prompt, model, extraArgs: c.harnesses?.[ad.name]?.headlessArgs ?? [], writableDirs: writableRoots(c) }); const timeoutMin = Number(s.timeoutMin ?? 40); const result = await runProcessGroup({ cmd: b, args: rest, cwd: d.worktree, env: ad.cleanEnv(process.env), timeoutMs: Number.isFinite(timeoutMin) && timeoutMin > 0 ? timeoutMin * 60000 : 0, }); const done = finishHeadless(ad, result); const sessions = readSessions(); // A job's thread is not the area's thread. Resuming the dispatcher into it // would throw away the interactive session. if (applyHeadlessSession(sessions, `job:${name}`, done)) writeAtomic(SESSIONS, JSON.stringify(sessions, null, 2) + '\n'); if (!done.ok) console.error(`job ${name} failed: ${done.reason}`); process.exit(done.code); } function schedEnv(name) { const { s, file } = schedule(name); const [qs, qe] = c.clock.quietHours.map((t) => Number(t.split(':')[0])); const vars = { STATE: c.stateDir, PROMPT: file, QUIET_START: qs, QUIET_END: qe, TIMEOUT_MIN: s.timeoutMin ?? 40, QUIET_OK: s.quietHours === 'allowed' ? 1 : 0, ALWAYS: s.ignorePause ? 1 : 0 }; for (const [k, v] of Object.entries(vars)) console.log(`${k}=${q(String(v))}`); } function lander(sub, quiet = false) { const js = path.join(MAIN_BIN, 'landq.mjs'); if (sub === 'start') { const pid = startLander(c, { landqPath: js, configPath: MAIN_CFG }); if (!quiet && pid) console.log(`lander drain started (pid ${pid})`); } else spawnSync(process.execPath, [js, 'status', '--config', MAIN_CFG], { stdio: 'inherit' }); } function setConfigHarness(file, provider) { if (!/^[A-Za-z0-9._-]+$/.test(provider || '')) die(`bad provider "${provider ?? ''}"`); const text = fs.readFileSync(file, 'utf8'); const next = /"harness"\s*:/.test(text) ? text.replace(/"harness"\s*:\s*"[^"]*"/, `"harness": "${provider}"`) : text.replace('{', `{\n "harness": "${provider}",`); if (next !== text) fs.writeFileSync(file, next); console.log(`harness: ${provider}`); } function contextFacts(repo, spec) { const bins = ['claude', 'codex', 'agent', 'pi'].filter((b) => spawnSync('sh', ['-c', `command -v ${b}`], { encoding: 'utf8' }).status === 0); let top = []; try { top = fs.readdirSync(repo).filter((n) => n !== '.git'); } catch { /* empty */ } const lines = [ '# Context for the session manager', `repo: ${repo}`, `node: ${process.version}`, `harness binaries on PATH: ${bins.join(', ') || '(none)'}`, `top: ${top.join(' ') || '(empty)'}`, ]; const readme = ['README.md', 'readme.md'].map((n) => path.join(repo, n)).find((p) => fs.existsSync(p)); if (readme) lines.push(`README:\n${fs.readFileSync(readme, 'utf8').split('\n').slice(0, 40).join('\n')}`); const pkg = path.join(repo, 'package.json'); if (fs.existsSync(pkg)) { try { const p = JSON.parse(fs.readFileSync(pkg, 'utf8')); lines.push(`package: ${p.name ?? ''} — ${p.description ?? ''}`); lines.push(`scripts: ${Object.keys(p.scripts ?? {}).join(', ') || '(none)'}`); } catch { lines.push('package.json: unreadable'); } } const log = tryGit(repo, 'log', '-8', '--oneline'); if (log.ok && log.out) lines.push(`recent commits:\n${log.out}`); if (spec) lines.push(`spec (${spec}):\n${fs.readFileSync(path.resolve(spec), 'utf8').slice(0, 8000)}`); return `${lines.join('\n')}\n`; } function fleetDirty(repo) { const r = tryGit(repo, 'status', '--porcelain', '--', 'fleet.config.json', 'tools/fleet', 'docs/agents', 'fleet', 'AGENTS.md', '.agents'); return r.ok ? r.out.split('\n').filter(Boolean) : []; } function nextLine() { return 'next: the user\'s agent runs `fleet up` and talks to the session manager. The user does not edit a file, attach to tmux, or run a command.'; } function init(target, provider, spec) { if (!target || target.startsWith('--')) die('usage: fleet init [--spec ] [--provider ]'); const repo = path.resolve(target); fs.mkdirSync(repo, { recursive: true }); if (!tryGit(repo, 'rev-parse', '--git-dir').ok) { const g = spawnSync('git', ['init', '-b', 'main'], { cwd: repo, encoding: 'utf8' }); if (g.status !== 0) die(g.stderr || 'git init failed'); console.log(`git init -b main in ${repo}`); } const copied = []; const copy = (from, to) => { if (fs.statSync(from).isDirectory()) { for (const f of fs.readdirSync(from)) copy(path.join(from, f), path.join(to, f)); return; } if (fs.existsSync(to)) return; fs.mkdirSync(path.dirname(to), { recursive: true }); fs.copyFileSync(from, to); copied.push(path.relative(repo, to)); }; const T = path.join(KIT, 'templates'); copy(path.join(T, 'fleet.config.json'), path.join(repo, 'fleet.config.json')); copy(path.join(T, 'prompts'), path.join(repo, 'docs/agents/prompts')); copy(path.join(T, 'schedules'), path.join(repo, 'docs/agents/schedules')); const backlogFrom = path.join(T, 'docs', 'backlog'); if (fs.existsSync(backlogFrom) && fs.readdirSync(backlogFrom).some((f) => !f.startsWith('.'))) copy(backlogFrom, path.join(repo, 'docs/agents/backlog')); for (const f of ['PROGRAM.md', 'RESUME-BRIEF.md', 'WIND-DOWN.md', 'STRIKES.md']) copy(path.join(T, 'docs', f), path.join(repo, 'docs/agents', f)); copy(path.join(T, 'docs', 'TODO.md'), path.join(repo, 'docs/TODO.md')); copy(path.join(T, 'fleet', 'RULE-CHANGES.md'), path.join(repo, 'fleet/RULE-CHANGES.md')); copy(path.join(KIT, 'bin'), path.join(repo, 'tools/fleet/bin')); copy(path.join(KIT, 'adapters'), path.join(repo, 'tools/fleet/adapters')); const gi = path.join(repo, '.gitignore'); const cur = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : ''; const add = ['fleet/inbox/', '.task', '.fleet/'].filter((l) => !cur.split('\n').includes(l)); if (add.length) fs.appendFileSync(gi, `${cur && !cur.endsWith('\n') ? '\n' : ''}${add.join('\n')}\n`); console.log(`copied ${copied.length} files into ${repo}${add.length ? `; .gitignore += ${add.join(' ')}` : ''}`); for (const f of copied) console.log(` ${f}`); if (provider) setConfigHarness(path.join(repo, 'fleet.config.json'), provider); writeAtomic(path.join(repo, 'fleet/context.md'), contextFacts(repo, spec)); console.log('wrote fleet/context.md'); const setup = setupCommit(repo); if (setup.pending) { console.error(setup.error || 'setup commit failed'); console.log('Setup files are in the worktree but not committed. Call the setup_commit MCP tool. Do not git commit, and do not ask the user to approve one.'); } else if (setup.committed.length) { if (!tryGit(repo, 'config', 'user.email').out) console.log('no git user.email set; the setup commit uses a local fleet-init identity'); console.log(`committed fleet setup ${setup.sha}`); } if (setup.pending || fleetDirty(repo).length) { console.log(nextLine()); return repo; } const loaded = loadConfig(['node', 'fleet', '--config', path.join(repo, 'fleet.config.json')]); const hub = dispatcher(loaded); if (hub && !fs.existsSync(hub.worktree)) { const hasBranch = tryGit(repo, 'rev-parse', '--verify', '-q', `refs/heads/${hub.branch}`).ok; const r = hasBranch ? tryGit(repo, 'worktree', 'add', hub.worktree, hub.branch) : tryGit(repo, 'worktree', 'add', '-b', hub.branch, hub.worktree, 'HEAD'); if (r.ok) console.log(`worktree ${hub.id} ${hub.worktree}`); else console.error(r.err); } if (process.stdout.isTTY && hub && fs.existsSync(hub.worktree)) { console.log('starting the session manager'); const started = spawnSync(process.execPath, [process.argv[1], 'up', '--only', hub.id, '--config', loaded.file], { stdio: 'inherit' }); process.exit(started.status ?? 1); } console.log(nextLine()); return repo; } function commitArgs() { const paths = []; let message = null; let area = null; let all = false; let submit = false; const rest = argv.slice(1); for (let i = 0; i < rest.length; i++) { const a = rest[i]; if (a === '--') { paths.push(...rest.slice(i + 1)); break; } if (a === '--all') { all = true; continue; } if (a === '--submit') { submit = true; continue; } if (a === '-m' || a === '--message') { message = rest[++i]; continue; } if (a === '--area') { area = rest[++i]; continue; } if (a === '--config') { i++; continue; } if (a.startsWith('-')) die('usage: fleet commit -m [--area id] [--all] [--submit] [--] [paths...]'); paths.push(a); } return { message, area, all, submit, paths }; } function setupCommitCmd() { const result = setupCommit(c.repo); if (result.pending) die(result.error || 'setup commit failed. Do not git commit.'); if (!result.committed.length) console.log(result.sha ? `setup already committed ${result.sha}` : 'nothing to commit'); else console.log(`committed fleet setup ${result.sha}`); } function tellCmd() { const rest = []; for (let i = 1; i < argv.length; i++) { if (argv[i] === '--config') { i++; continue; } if (argv[i] === '--') continue; rest.push(argv[i]); } const body = rest.join(' ').trim(); if (!body) die('usage: fleet tell '); const result = tellDispatch(c, { body }); for (const f of result.files) console.log(f); if (result.nudged) console.log(`nudged ${result.to}`); else if (result.wake?.reason === 'working') console.log(`mailed ${result.to} (working, not nudged)`); else console.log(`mailed ${result.to}`); } function hearCmd() { const msgs = hearInterpreter(c); if (!msgs.length) console.log('no unread messages for interpreter'); for (const m of msgs) console.log(`=== ${m.file}\n${m.text}`); } function commitCmd() { const { message, area, all, submit, paths } = commitArgs(); const result = commitWork(c, { cwd: process.cwd(), area, message, paths, all, submit }); for (const w of result.warnings) console.error(`warn: ${w}`); console.log(result.sha); if (result.landing) { for (const w of result.landing.warnings ?? []) console.error(`warn: ${w}`); console.log(`queued ${result.landing.sha.slice(0, 10)} (${result.landing.branch}, ${result.landing.files.length} files)`); if (result.landing.hint) console.log(result.landing.hint); if (result.landing.landerStarted) console.log(`lander drain started (pid ${result.landing.landerStarted})`); } } function tool(name) { const js = path.join(MAIN_BIN, `${name}.mjs`); const r = spawnSync(process.execPath, [js, ...argv.slice(1)], { stdio: 'inherit' }); process.exit(r.status ?? 1); } const run = { up, down, status, supervise: () => supervise(argv[1]), worktrees, crontab, prompt: async () => console.log(composePrompt(getArea(c, argv[1]), await adapterFor(getArea(c, argv[1]).harness))), 'run-job': () => runJob(argv[1]), 'sched-env': () => schedEnv(argv[1]), lander: () => lander(argv[1] ?? 'status'), commit: commitCmd, 'setup-commit': setupCommitCmd, tell: tellCmd, hear: hearCmd, doctor: () => tool('doctor'), landq: () => tool('landq'), backlog: () => tool('backlog'), mailbox: () => tool('mailbox') }[cmd]; if (!run) die(`unknown command "${cmd}" (fleet help)`); await run();