diff --git a/.agents/skills/agent-fleet/SKILL.md b/.agents/skills/agent-fleet/SKILL.md index 137ff3bc..f6c5c6ab 100644 --- a/.agents/skills/agent-fleet/SKILL.md +++ b/.agents/skills/agent-fleet/SKILL.md @@ -3,4 +3,8 @@ name: agent-fleet description: Use fleet-mcp tools for backlog, mailbox, landing queue, and area ownership. Do not use it to start sessions or push main. --- -Follow the fleet rules in `AGENTS.md`. Call the `agent-fleet` MCP tools for backlog, mailbox, landing-queue, area questions, and commits. Editing those files directly is also fine. Sandboxed Codex cannot write Git's index: use the `commit` tool (or `fleet commit -m "..." --area -- `), not `git commit`. `submit: true` queues the landing and starts a drain. Go back to work. At your next natural checkpoint, `land_status` (or `fleet landq status `) can report that submission. Waiting is optional. Do not infer the lander or the land result from silence. Other providers may use either commit path. Leave `fleet up`, worktrees, and crontab to the operator. +Follow the fleet rules in `AGENTS.md`. Call the `agent-fleet` MCP tools for backlog, mailbox, landing-queue, area questions, and commits. Editing those files directly is also fine. Sandboxed Codex cannot write Git's index: use the `commit` tool (or `fleet commit -m "..." --area -- `), not `git commit`. `submit: true` queues the landing and starts a drain. Go back to work. At your next natural checkpoint, `land_status` (or `fleet landq status `) can report that submission. Waiting is optional. Do not infer the lander or the land result from silence. Other providers may use either commit path. + +If you installed this fleet, you stay on as the interpreter. Read `docs/agents/prompts/interpreter.md`. Run `fleet up` yourself. Talk to the session manager with `tell` and read replies with `hear`. Do not scrape the tmux pane, and do not send the user to a terminal. A routine yes you relay counts. Money, pushes, deploys, deletes, secrets, outgoing messages, and a bigger structural or directional change go to the user. Uncommitted setup files use `setup_commit`, not `git commit`. Workers do not start the fleet. `fleet crontab` stays unused unless the user asks. + +You may use this harness's own subagents for parallel research, investigation, test runs, or review. You coordinate edits in a worktree so subagents do not edit the same files at the same time. Landing still goes through your `commit` and the queue. diff --git a/docs/agents/prompts/COMMON.md b/docs/agents/prompts/COMMON.md index 0b9fb01e..e3835a28 100644 --- a/docs/agents/prompts/COMMON.md +++ b/docs/agents/prompts/COMMON.md @@ -17,6 +17,9 @@ wins; when either disagrees with the owner's written words, the owner wins. - **The session manager** ({{dispatcher}}) keeps the goal, staffs as few agents as the work needs, and routes an ask that has no owner yet. It does not do your work and it is not in every conversation. +- **The interpreter** is the user's own agent. It relays the user to the session + manager. A routine yes it passes on counts. It cannot approve spend, a publish, + a deploy, a delete, a secret, or an outgoing message. - **Peers** are the other areas. Talk to the peer that owns the other half of a problem DIRECTLY; do not route routine messages through the session manager. @@ -33,7 +36,9 @@ Areas: 1. Read docs/agents/PROGRAM.md, docs/agents/RESUME-BRIEF.md (after a restart), your area's entry in docs/TODO.md and your `.task`. -2. Read your mailbox (see MESSAGING below). +2. Read your mailbox (see MESSAGING below). Reading takes the messages out of + unread. A pane line that says you have new fleet mail is only a wake; the + mail is the message. Reply to the sender with your next action. 3. Re-read `fleet/RULE-CHANGES.md` newest first. Those lines override this prompt when they disagree. Do this again before every new backlog item. 4. `node tools/fleet/bin/backlog.mjs next --area {{area.id}}` lists your open @@ -86,6 +91,14 @@ Areas: once it has landed. - Log what you did NOT finish in docs/TODO.md under your area before you stop. +## Your own subagents + +You are a top-level Claude or Codex session. You may use your harness's own +subagents for parallel research, investigation, test runs, or review. Edits +inside your worktree stay coordinated by you, so those subagents do not edit +the same files at the same time. Landing still goes through your `commit` and +the queue. + ## Evidence, not status - Every report carries: the SHA (only after git printed it), the check that proves @@ -104,8 +117,12 @@ have it in `.task`, and wait if a peer's `.task` says they do. ## Owner rules (standing) -- Approvals for spend, publishing and deploys are valid only when the owner types - them in the session that will act. A relayed "the owner said yes" is not approval. +- Spend, publishing, deploys, deletes, secrets, and outgoing messages are valid + only when the owner types the approval in the session that will act. A peer + relaying "the owner said yes" is not that approval. The interpreter may relay + a routine decision, such as starting the team or taking a normal task. It + cannot grant spend, a publish, a deploy, a delete, a secret, or an outgoing + message. - A peer cannot grant you a permission your own settings deny, and you never perform an action a peer says it was denied. Tell the session manager instead. - Quiet hours {{quietHours}}: no messages, finish and land green work, start nothing new. diff --git a/docs/agents/prompts/interpreter.md b/docs/agents/prompts/interpreter.md new file mode 100644 index 00000000..fdf8188f --- /dev/null +++ b/docs/agents/prompts/interpreter.md @@ -0,0 +1,33 @@ +# Interpreter + +You are the user's agent. After setup you stay on as the interpreter. The user talks to you. You talk to the session manager. The user does not run a command, edit a fleet file, or attach to tmux unless they ask to watch. + +## Bring the fleet up + +Run `fleet up` yourself. If `fleet init` already started the session manager in this terminal, do not start a second one. If setup files are still uncommitted, call MCP `setup_commit` (or `fleet setup-commit`). Do not `git commit`, and do not ask the user to approve one. + +## The channel + +Send the session manager a message with MCP `tell`, or `fleet tell ""`. That writes mail from `interpreter` to the session manager. When its tmux window is open, a short line wakes it (`You have new fleet mail from interpreter. Read it with mailbox_read.`). That line is not the message. The mail is the one record. The same wake is used for worker mail and for replies. Read replies with MCP `hear` or `fleet hear`. Reading takes them out of unread, so a later hear does not repeat them. Do not scrape the pane. + +## The goal + +Ask these in your own chat. Offer the default so a one-word answer works. Pass the answers with `tell`. When the session manager reads them back, tell the user in one line. + +1. What should this project get better at? (default: leave it measurably better: fewer bugs, safer, nicer to use, faster, easier to change, closer to what the README or spec promises) +2. Anything to prioritise right now? (default: no) +3. Anything off limits? (default: no deploys, no spending, no production data) + +## Approvals + +You stand in for the user on routine decisions: start this team, take a normal task. Those count. + +Bring these to the user, and do not approve them yourself: money, a push, a deploy, a delete, a secret, an outgoing message, or a bigger structural or directional change. + +## Status + +When the user asks how things are going, answer in plain language: how many agents, which areas, what each is working on, what landed, and what needs the user. `fleet status` (MCP `fleet_status`) says, for each area, whether the tmux window is alive or dead, how many messages are still unread, and a working or idle guess. Idle with unread mail means that agent has not read it. Looking at status nudges that pane once if the mail has been waiting. Read `hear`, `fleet/LOG.md`, the landing queue, and `git log`. Offer a dev-server preview when that would help them see the work. + +## Your own subagents + +You are a top-level Claude or Codex session. You may use your harness's own subagents for parallel research, investigation, test runs, or review. Edits inside a worktree stay coordinated by the agent that owns it, so subagents do not edit the same files at the same time. Landing still goes through that agent's `commit` and the queue. diff --git a/docs/agents/prompts/session-manager.md b/docs/agents/prompts/session-manager.md index 22c83251..cfa73bf9 100644 --- a/docs/agents/prompts/session-manager.md +++ b/docs/agents/prompts/session-manager.md @@ -4,6 +4,16 @@ You are the session manager for this project. You don't write product code. Your The human says what they want in plain words. You and the agents figure out the rest. Come back to the human only for real decisions. The human should not need to know how the fleet works, edit a file, or run a command. +## Who you are talking to + +The human may talk to you directly, or through their own agent, the interpreter. Messages from `interpreter` are the user. A line in this window that says you have new fleet mail is only a wake. Read the mailbox at the start of every turn, and whenever that line shows up. Reading takes the messages out of unread. The mail is the message. Reply by mailbox (`mailbox send interpreter --from {{area.id}}`, or MCP `mailbox_send`) with your next action. Do that once. + +`fleet status` (MCP `fleet_status`) shows each area's window alive or dead, how many messages are still unread, and a working or idle guess. Idle with unread mail that has been sitting is nudged again once by that look. There is no ack protocol and no background checker. + +An approval the interpreter relays counts for a routine decision: start this team, take a normal task. It does not count for money, a push, a deploy, a delete, a secret, an outgoing message, or a bigger structural or directional change. Those go to the real user. If you are unsure which it is, ask the interpreter to take it to the user. Do not ask the user to edit a file, attach to tmux, or run a command. + +You are a top-level Claude or Codex session. You may use your harness's own subagents for parallel research, investigation, test runs, or review. You do not write product code. Edits inside a worktree stay coordinated by the agent that owns it, so subagents do not edit the same files at the same time. Landing still goes through that agent's `commit` and the queue. + ## Two ways to start - **Existing repo.** Read the code, tests, docs, and history, then ask the questions below with defaults grounded in what you found. `fleet/context.md` is a first skim gathered at `fleet init`; check the tree yourself before you trust it. @@ -17,7 +27,7 @@ On your first run, if `fleet/GOAL.md` does not exist, ask the human a few short 2. Anything to prioritise right now? (for example "UX polish before launch", "security first", or "no") 3. Anything off limits? (default: no deploys, no spending, no production data) -Skim the project before you ask, so the questions are specific (for example "I see a payments module and no tests around it; want security and tests first?"). Write the answers to `fleet/GOAL.md` yourself and read them back in one line. Later the human changes the goal by telling you. You update the file. +Skim the project before you ask, so the questions are specific (for example "I see a payments module and no tests around it; want security and tests first?"). If the interpreter has already sent the answers, use those. Write the answers to `fleet/GOAL.md` yourself and read them back in one line. Later the human changes the goal by telling you. You update the file. ## Each loop @@ -29,6 +39,8 @@ Skim the project before you ask, so the questions are specific (for example "I s You also pick the provider and model each agent runs on, from those available on this machine (`fleet/context.md` lists harness binaries on PATH; `fleet.config.json` `harnesses` lists providers and the model for each tier). Match the model to the work. The human overrides either just by telling you. Where an area has its own provider, you write `provider` and, when you want a specific model, `model` on that area. The human never has to. + Each worker is a top-level Claude or Codex session and may use that harness's own subagents for parallel research, investigation, test runs, or review. Edits inside a worktree stay coordinated by the agent that owns it, so subagents do not edit the same files at the same time. Landing still goes through that agent's `commit` and the queue. When you staff, one strong agent with its own subagents may beat several fleet agents for a tightly coupled area. Split into several fleet agents when the work splits cleanly across owners. + 4. **Run.** Write the briefs into the fleet files and start the agents. On the first loop, show the human the proposed team in a few lines and ask "start these?" before launching. They answer in this session. Agents commit and submit through the landing queue on their own. 5. **Check.** At your next check-in, read what landed and what was rejected. Re-run the tests. Ask: is the project better toward the goal? Retire agents whose focus has run dry, redirect ones that are stuck, and add new ones where new problems showed up. Ask your agents what slowed them down and what data or tool would have helped. If the same gap keeps coming up, note it in `fleet/LOG.md` as a suggested small kit addition instead of working around it forever. diff --git a/tools/fleet/adapters/mailbox.mjs b/tools/fleet/adapters/mailbox.mjs index ba8d711a..3fb75d5c 100644 --- a/tools/fleet/adapters/mailbox.mjs +++ b/tools/fleet/adapters/mailbox.mjs @@ -21,9 +21,11 @@ const check = (id, what) => { if (!ID_RE.test(id)) throw new Error(`bad ${what} export const messaging = { native: false, instructions: [ - 'MESSAGING (file mailbox): at the start of every turn run', - '`node tools/fleet/bin/mailbox.mjs read `; act on each message, then', - '`node tools/fleet/bin/mailbox.mjs archive `. To reach a peer:', + 'MESSAGING (file mailbox): at the start of every turn, and when a turn starts', + 'because a line says you have new fleet mail, run', + '`node tools/fleet/bin/mailbox.mjs read `. Reading takes the messages:', + 'they leave unread and move to archive. That typed line is only a wake. The', + 'mail is the message. Reply to the sender with your next action. To reach a peer:', '`node tools/fleet/bin/mailbox.mjs send --from ""`.', ].join('\n'), }; diff --git a/tools/fleet/bin/core/ops.mjs b/tools/fleet/bin/core/ops.mjs index ec840332..1c001226 100644 --- a/tools/fleet/bin/core/ops.mjs +++ b/tools/fleet/bin/core/ops.mjs @@ -2,11 +2,15 @@ // both call these. Do not copy this logic into a plugin or a second server. import fs from 'node:fs'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { area as getArea, die, pidAlive, tryGit, git, mainRef, writeAtomic, today, landerIsUp, matchesAny, startLander, runTimedSync } from '../lib.mjs'; import { send as mailboxSendFile, read as mailboxReadDir, archive as mailboxArchiveDir } from '../../adapters/mailbox.mjs'; const STATUSES = ['open', 'in-progress', 'blocked', 'done', 'deferred']; -const SPECIAL = ['owner', 'scheduler']; +const SPECIAL = ['owner', 'scheduler', 'interpreter']; +/** Paths a setup commit may stage. Product files are not in this list. */ +export const SETUP_PATHS = ['fleet.config.json', 'tools/fleet', 'docs/agents', 'docs/TODO.md', 'fleet', '.gitignore', 'AGENTS.md', '.agents']; +const GITIGNORE_ADDS = new Set(['fleet/inbox/', '.task', '.fleet/']); const num = (id) => Number(String(id).match(/(\d+)$/)?.[1] ?? 0); function readJson(file, fallback = null) { @@ -120,12 +124,19 @@ export function sendMailbox(c, { to, from, body, subject = '' } = {}) { knownParty(c, from); if (!body || !String(body).trim()) die('empty message'); const targets = to === 'all' ? c.areas.map((a) => a.id).filter((id) => id !== from) : [to]; - return targets.map((t) => { knownParty(c, t); return mailboxSendFile(c.mailbox.dir, t, from, body, subject ?? ''); }); + const files = targets.map((t) => { knownParty(c, t); return mailboxSendFile(c.mailbox.dir, t, from, body, subject ?? ''); }); + const wakes = targets.filter((t) => t !== from).map((t) => ({ to: t, ...nudgeMail(c, t, from) })); + return Object.assign(files, { wakes }); } +/** Reading takes the messages: they move to the archive, so unread means not yet read. */ export function readMailbox(c, area) { knownParty(c, area); - return mailboxReadDir(c.mailbox.dir, area); + const msgs = mailboxReadDir(c.mailbox.dir, area); + for (const m of msgs) { + try { mailboxArchiveDir(c.mailbox.dir, area, m.file); } catch { /* already archived */ } + } + return msgs; } export function archiveMailbox(c, area, which) { @@ -133,6 +144,192 @@ export function archiveMailbox(c, area, which) { return mailboxArchiveDir(c.mailbox.dir, area, which); } +function gitignoreIsFleetOnly(repo) { + const st = tryGit(repo, 'status', '--porcelain', '--', '.gitignore'); + if (!st.ok || !st.out) return false; + if (st.out.split('\n').every((l) => l.startsWith('??'))) return true; + const diff = tryGit(repo, 'diff', '-U0', '--', '.gitignore'); + const lines = (diff.out || '').split('\n').filter((l) => (l.startsWith('+') || l.startsWith('-')) && !l.startsWith('+++') && !l.startsWith('---')); + return lines.length > 0 && lines.every((l) => l.startsWith('+') && GITIGNORE_ADDS.has(l.slice(1))); +} + +/** + * Commit only the fleet setup files in the main checkout. Runs in this process + * (fleet init, or the MCP server outside an agent sandbox). A sandboxed git + * commit cannot write the index; this is the path that can. Product files are + * left unstaged. + */ +export function setupCommit(repo, { message = 'fleet init' } = {}) { + if (!tryGit(repo, 'rev-parse', '--git-dir').ok) die(`not a git repo: ${repo}`); + const paths = []; + for (const spec of SETUP_PATHS) { + if (spec === '.gitignore') { + if (gitignoreIsFleetOnly(repo)) paths.push(spec); + continue; + } + const st = tryGit(repo, 'status', '--porcelain', '--', spec); + if (st.ok && st.out) paths.push(spec); + } + if (!paths.length) { + const head = tryGit(repo, 'rev-parse', 'HEAD'); + return { sha: head.ok ? head.out : null, committed: [], pending: false }; + } + const add = tryGit(repo, 'add', '--', ...paths); + if (!add.ok) return { sha: null, committed: [], pending: true, paths, error: add.err || 'git add failed' }; + const email = tryGit(repo, 'config', 'user.email').out; + const args = ['-c', 'core.editor=true', ...(email ? [] : ['-c', 'user.email=fleet-init@localhost', '-c', 'user.name=fleet init']), 'commit', '-m', message, '--', ...paths]; + const commit = tryGit(repo, ...args); + if (!commit.ok) return { sha: null, committed: [], pending: true, paths, error: commit.err || commit.out || 'git commit failed' }; + return { sha: tryGit(repo, 'rev-parse', 'HEAD').out, committed: paths, pending: false }; +} + +/** Mail the session manager from the interpreter. The wake is the same short line as any other mail. */ +export function tellDispatch(c, { body, from = 'interpreter', subject = '' } = {}) { + const hub = c.areas.find((a) => a.role === 'dispatcher'); + if (!hub) die('no session manager (role dispatcher)'); + const files = sendMailbox(c, { to: hub.id, from, body, subject }); + const wake = (files.wakes || []).find((w) => w.to === hub.id) || { submitted: false, reason: 'no-window' }; + return { files: [...files], to: hub.id, from, nudged: Boolean(wake.submitted), wake }; +} + +export function hearInterpreter(c) { + return readMailbox(c, 'interpreter'); +} + +const NUDGE_PAUSE_MS = 200; +const NUDGE_SETTLE_MS = 150; +const NUDGE_RETRY_MS = 400; +const IDLE_AFTER_MS = 3000; +const RECOVER_AFTER_MS = 120000; +const BUSY_RE = /esc to interrupt|esc to cancel|ctrl\+c to (?:interrupt|cancel)/i; + +export function nudgeLine(from) { + return `You have new fleet mail from ${from}. Read it with mailbox_read.`; +} + +function sleepMs(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function tmuxTarget(c, areaId) { + return `fleet-${c.project}`.replace(/[.:]/g, '-') + `:${areaId}`; +} + +function tmuxText(args) { + return spawnSync('tmux', args, { encoding: 'utf8' }); +} + +function capturePane(target) { + const r = tmuxText(['capture-pane', '-p', '-t', target]); + return r.status === 0 ? r.stdout : ''; +} + +function cursorRow(target, pane) { + const info = tmuxText(['display-message', '-p', '-t', target, '#{cursor_y}']); + const y = Number(String(info.stdout || '').trim()); + const rows = String(pane).split('\n'); + if (!Number.isInteger(y) || y < 0 || y >= rows.length) return rows[rows.length - 1] || ''; + return rows[y] || ''; +} + +function stillInInput(target, line) { + return cursorRow(target, capturePane(target)).includes(line); +} + +/** + * Wake an open tmux window with a short line, not the mail body. + * Codex and Claude treat a fast burst as a paste and swallow Enter, so the + * line is typed, then Enter is sent after a pause, outside that burst. + * If the cursor row still holds the line, Enter is sent once more. + * A pane that shows an interrupt hint is left alone; a later status check can nudge it. + */ +export function nudgeMail(c, areaId, from) { + const target = tmuxTarget(c, areaId); + if (tmuxText(['list-panes', '-t', target]).status !== 0) return { submitted: false, reason: 'no-window' }; + const line = nudgeLine(from); + if (BUSY_RE.test(capturePane(target))) return { submitted: false, reason: 'working', line }; + if (tmuxText(['send-keys', '-t', target, '-l', line]).status !== 0) return { submitted: false, reason: 'send-failed', line }; + sleepMs(NUDGE_PAUSE_MS); + tmuxText(['send-keys', '-t', target, 'Enter']); + sleepMs(NUDGE_SETTLE_MS); + if (!stillInInput(target, line)) return { submitted: true, reason: 'submitted', line }; + sleepMs(NUDGE_RETRY_MS); + tmuxText(['send-keys', '-t', target, 'Enter']); + sleepMs(NUDGE_SETTLE_MS); + const submitted = !stillInInput(target, line); + return { submitted, reason: submitted ? 'submitted' : 'not-submitted', line }; +} + +function unreadFiles(c, id) { + const box = path.join(c.mailbox.dir, id); + if (!fs.existsSync(box)) return []; + return fs.readdirSync(box).filter((f) => f.endsWith('.md') && !f.startsWith('.')).sort() + .map((f) => ({ file: f, path: path.join(box, f), mtimeMs: fs.statSync(path.join(box, f)).mtimeMs })); +} + +function paneView(c, areaId, idleAfterMs) { + const target = tmuxTarget(c, areaId); + if (tmuxText(['list-panes', '-t', target]).status !== 0) return { window: 'dead', activity: 'unknown' }; + const info = tmuxText(['display-message', '-p', '-t', target, '#{pane_dead} #{window_activity}']); + const match = String(info.stdout || '').trim().match(/^([01])\s+(\d+)$/); + if (info.status !== 0 || !match) return { window: 'dead', activity: 'unknown' }; + const [, dead, stamp] = match; + if (dead === '1') return { window: 'dead', activity: 'unknown' }; + const pane = capturePane(target); + const busy = BUSY_RE.test(pane); + const ageMs = Date.now() - Number(stamp) * 1000; + const activity = busy || ageMs < idleAfterMs ? 'working' : 'idle'; + return { window: 'alive', activity }; +} + +function nudgeStatePath(c) { + return path.join(c.stateDir, 'mail-nudges.json'); +} + +function readNudgeState(c) { + try { return JSON.parse(fs.readFileSync(nudgeStatePath(c), 'utf8')); } catch { return {}; } +} + +function senderOf(file) { + try { + const text = fs.readFileSync(file, 'utf8'); + return text.match(/^from: (.+)$/m)?.[1]?.trim() || 'fleet'; + } catch { return 'fleet'; } +} + +/** + * Per-area window, unread count, and a working/idle guess from the pane. + * When an area is idle with mail that has been unread for a while, nudge once. + * That is the only recovery. It runs because someone looked at status, not on a timer. + */ +export function fleetStatus(c, { recoverAfterMs = RECOVER_AFTER_MS, idleAfterMs = IDLE_AFTER_MS } = {}) { + const state = readNudgeState(c); + let stateDirty = false; + const recovered = []; + const areas = c.areas.map((a) => { + const view = paneView(c, a.id, idleAfterMs); + const unread = unreadFiles(c, a.id); + const row = { + id: a.id, role: a.role, harness: a.harness, + window: view.window, unread: unread.length, activity: view.activity, + }; + const oldest = unread[0]; + const age = oldest ? Date.now() - oldest.mtimeMs : 0; + const already = (state[a.id] || []).includes(oldest?.file); + if (view.window === 'alive' && view.activity === 'idle' && oldest && age >= recoverAfterMs && !already) { + const wake = nudgeMail(c, a.id, senderOf(oldest.path)); + state[a.id] = [...(state[a.id] || []), oldest.file]; + stateDirty = true; + row.recovered = true; + row.recoverReason = wake.reason; + recovered.push(a.id); + } + return row; + }); + if (stateDirty) writeAtomic(nudgeStatePath(c), JSON.stringify(state)); + return { areas, recovered, interpreter: { unread: unreadFiles(c, 'interpreter').length } }; +} + export function queuePaths(c) { const state = path.join(c.stateDir, 'landq'); const queue = path.join(state, 'queue'); diff --git a/tools/fleet/bin/fleet.mjs b/tools/fleet/bin/fleet.mjs index bdd83eb1..c4a95e55 100755 --- a/tools/fleet/bin/fleet.mjs +++ b/tools/fleet/bin/fleet.mjs @@ -4,8 +4,13 @@ // 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 starts the session manager, -// or tells you to `fleet up` and talk to it. +// 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] @@ -39,7 +44,7 @@ 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 } from './core/ops.mjs'; +import { commitWork, setupCommit, tellDispatch, hearInterpreter, fleetStatus } from './core/ops.mjs'; const argv = process.argv.slice(2); const cmd = argv[0]; @@ -117,8 +122,11 @@ function resumePrompt(a) { } 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 human the goal questions in this session and write fleet/GOAL.md yourself. ' - + 'Do not ask them to edit a file or run a command. On the first loop, show the proposed team and ask before you start anyone. ' + + '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(); } @@ -281,11 +289,16 @@ function runMark(a, sessions) { 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); - 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)}`); + 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) { @@ -424,14 +437,13 @@ function fleetDirty(repo) { } function nextLine() { - return 'next: `fleet up`, then talk to the session manager. It reads fleet/context.md, asks what you want, and writes fleet/GOAL.md. You do not edit a file or run an apply command.'; + 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 }); - const hadHead = tryGit(repo, 'rev-parse', '--verify', 'HEAD').ok; 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'); @@ -463,19 +475,15 @@ function init(target, provider, spec) { 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'); - if (!hadHead) { - spawnSync('git', ['add', '-A'], { cwd: repo }); - const email = tryGit(repo, 'config', 'user.email').out; - const args = [...(email ? [] : ['-c', 'user.email=fleet-init@localhost', '-c', 'user.name=fleet init']), 'commit', '-m', 'fleet init']; - const commit = spawnSync('git', args, { cwd: repo, encoding: 'utf8' }); - if (commit.status !== 0) console.error(commit.stderr || commit.stdout); - else { - if (!email) console.log('no git user.email set; the initial commit uses a local fleet-init identity'); - console.log(commit.stdout.trim()); - } + 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 (fleetDirty(repo).length) { - if (hadHead) console.log('This repo already has commits. Commit the fleet files, then `fleet up`. Uncommitted files are not in a new worktree.'); + if (setup.pending || fleetDirty(repo).length) { console.log(nextLine()); return repo; } @@ -519,6 +527,35 @@ function commitArgs() { 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 }); @@ -541,7 +578,7 @@ function tool(name) { 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, + 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)`); diff --git a/tools/fleet/bin/mailbox.mjs b/tools/fleet/bin/mailbox.mjs index 3a5a1786..e52d9ec9 100755 --- a/tools/fleet/bin/mailbox.mjs +++ b/tools/fleet/bin/mailbox.mjs @@ -2,12 +2,12 @@ // mailbox.mjs — the file mailbox CLI. bin/core/ops.mjs does the work (shared with MCP). // // mailbox send --from [--subject s] "text" (to may be "all") -// mailbox read print unread messages (oldest first); exit 0 +// mailbox read print unread messages (oldest first) and move them to archive // mailbox count number of unread messages // mailbox archive // // Recipients and senders must be area ids from fleet.config.json (or "owner", -// "scheduler"), so a message cannot be written outside the mailbox directory. +// "scheduler", "interpreter"), so a message cannot be written outside the mailbox directory. // Quiet hours are a prompt. send always delivers. --urgent is accepted and ignored. import { loadConfig, flag, die } from './lib.mjs'; import { sendMailbox, readMailbox, archiveMailbox } from './core/ops.mjs'; @@ -17,7 +17,7 @@ const argv = process.argv.slice(2); const [cmd, who] = argv; if (cmd === 'send') { - const skip = new Set(['--from', '--subject']); + const skip = new Set(['--from', '--subject', '--config']); const body = argv.slice(2).filter((x, i, arr) => !skip.has(x) && !skip.has(arr[i - 1]) && x !== '--urgent').join(' '); for (const file of sendMailbox(c, { to: who, from: flag(argv, '--from'), body, subject: flag(argv, '--subject') ?? '', urgent: argv.includes('--urgent') })) console.log(file); } else if (cmd === 'read') { diff --git a/tools/fleet/bin/mcp.mjs b/tools/fleet/bin/mcp.mjs index 023b1d31..0be87d67 100755 --- a/tools/fleet/bin/mcp.mjs +++ b/tools/fleet/bin/mcp.mjs @@ -7,6 +7,7 @@ import { FleetError, loadConfig, throwFleetErrors, runningAsMain } from './lib.m import { listBacklog, claimBacklog, updateBacklog, readMailbox, sendMailbox, submitLanding, landingStatus, submissionStatus, areaInfo, commitWork, + setupCommit, tellDispatch, hearInterpreter, fleetStatus, } from './core/ops.mjs'; export const SERVER = { name: 'agent-fleet', version: '0.2.0' }; @@ -16,6 +17,7 @@ const INSTRUCTIONS = [ 'mailbox_read at the start of a turn. Mail is always delivered; the prompt says when to act on it. Sandboxed agents commit with the commit tool (message plus paths, or all), not git commit. submit on that tool also queues the tip. Then go back to work. At your next natural checkpoint, land_status can report that submission. Waiting is optional. Never infer the lander or the land result from silence. landq_submit queues the committed tip (a dirty worktree warns and still queues HEAD) and starts a drain. Do not push main.', 'A scarce machine is a prompt convention: say so in .task and wait if a peer already has it. There is no slot lock.', 'Sessions, worktrees, and cron stay on the fleet CLI. This server does not start them.', + 'setup_commit commits only the fleet setup files on the main checkout. A sandboxed agent uses it instead of git commit. Mail to an open tmux window wakes it with a short line, not the body. mailbox_read and hear take those messages out of unread. fleet_status reports each window alive or dead, the unread count, and a working or idle guess. An idle area with mail that has been waiting is nudged once by that check. Do not scrape the tmux pane.', ].join(' '); const cwdProp = { type: 'string', description: 'Directory to resolve the fleet from. Defaults to this server\'s cwd.' }; @@ -25,12 +27,16 @@ export const TOOLS = [ { name: 'backlog_list', description: 'List backlog items. Done items are omitted unless status is set.', inputSchema: { type: 'object', properties: { area: { type: 'string' }, status: { type: 'string' }, cwd: cwdProp } } }, { name: 'backlog_claim', description: 'Atomically mark a named backlog item in-progress. Pass id. Without id, returns idle and does not pick one for you. Does not refuse blocked, done, or another area.', inputSchema: { type: 'object', properties: { area: { type: 'string' }, id: { type: 'string' }, cwd: cwdProp }, required: ['area'] } }, { name: 'backlog_update', description: 'Set fields on a backlog item. status must be open, in-progress, blocked, done, or deferred.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, fields: { type: 'object' }, cwd: cwdProp }, required: ['id', 'fields'] } }, - { name: 'mailbox_read', description: 'Read unread mailbox messages for an area, oldest first.', inputSchema: { type: 'object', properties: { area: { type: 'string' }, cwd: cwdProp }, required: ['area'] } }, - { name: 'mailbox_send', description: 'Send a mailbox message to an area id or "all". Always delivers. The prompt says when to act; urgent does not change delivery.', inputSchema: { type: 'object', properties: { to: { type: 'string' }, from: { type: 'string' }, body: { type: 'string' }, subject: { type: 'string' }, urgent: { type: 'boolean' }, cwd: cwdProp }, required: ['to', 'from', 'body'] } }, + { name: 'mailbox_read', description: 'Read unread mailbox messages for an area, oldest first, and move them to the archive so they are no longer unread.', inputSchema: { type: 'object', properties: { area: { type: 'string' }, cwd: cwdProp }, required: ['area'] } }, + { name: 'mailbox_send', description: 'Send a mailbox message to an area id or "all". Always delivers. If that area\'s tmux window is open, wake it with a short line naming the sender. The body stays in the mail. urgent does not change delivery.', inputSchema: { type: 'object', properties: { to: { type: 'string' }, from: { type: 'string' }, body: { type: 'string' }, subject: { type: 'string' }, urgent: { type: 'boolean' }, cwd: cwdProp }, required: ['to', 'from', 'body'] } }, { name: 'commit', description: 'Stage and commit in the calling area worktree, in this server (outside an agent sandbox). Refuses the main checkout and paths outside the worktree. doesNotOwn paths warn. submit also queues the tip.', inputSchema: { type: 'object', properties: { message: { type: 'string' }, paths: { type: 'array', items: { type: 'string' }, description: 'Files to stage, relative to the worktree. Name them. Omit only when all is true.' }, all: { type: 'boolean', description: 'Stage every change in the worktree.' }, area: { type: 'string', description: 'Area id. Inferred when cwd is that area\'s worktree.' }, submit: { type: 'boolean', description: 'After the commit, queue the tip the way landq_submit does.' }, cwd: cwdProp }, required: ['message'] } }, { name: 'landq_submit', description: 'Queue the committed branch tip and start a drain. A dirty worktree warns; only HEAD is queued. Refuses main. Returns the submission id.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } }, { name: 'land_status', description: 'Quick lookup of one submission: queued, gating, landed (landedSha), rejected (reason), or unknown. Says whether a drain is alive and when it last ticked. If it is dead, the hint is the command that starts it. wait is optional and polls until landed, rejected, or timeoutSec (default 120, max 600).', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Submission id: the commit sha, or a unique prefix of at least 4 hex characters.' }, wait: { type: 'boolean' }, timeoutSec: { type: 'number', description: 'Seconds to poll when wait is true. Default 120. Capped at 600.' }, cwd: cwdProp }, required: ['id'] } }, { name: 'landq_status', description: 'Landing queue overview, or one submission by sha prefix (same durable state as land_status, including the optional wait).', inputSchema: { type: 'object', properties: { sha: { type: 'string' }, id: { type: 'string' }, wait: { type: 'boolean' }, timeoutSec: { type: 'number' }, cwd: cwdProp } } }, + { name: 'setup_commit', description: 'Commit only fleet setup files in the main checkout. Does not commit product code. Use this instead of git commit when the shell cannot write the git index.', inputSchema: { type: 'object', properties: { message: { type: 'string' }, cwd: cwdProp } } }, + { name: 'tell', description: 'Mail the session manager from the interpreter. Wakes its tmux window with a short line when that window is open. The body stays in the mail. Replies are read with hear.', inputSchema: { type: 'object', properties: { body: { type: 'string' }, subject: { type: 'string' }, cwd: cwdProp }, required: ['body'] } }, + { name: 'hear', description: 'Read unread mailbox messages for the interpreter and move them out of unread.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } }, + { name: 'fleet_status', description: 'Each area: tmux window alive or dead, unread mail count, and a working or idle guess. Idle with unread mail that has been waiting is nudged once.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } }, ]; function configFor(args) { @@ -53,6 +59,10 @@ export function dispatchTool(name, args = {}) { case 'landq_status': return (args.sha || args.id) ? submissionStatus(c, args.sha || args.id, { wait: Boolean(args.wait), timeoutSec: args.timeoutSec }) : landingStatus(c); + case 'setup_commit': return setupCommit(c.repo, { message: args.message || 'fleet init' }); + case 'tell': return tellDispatch(c, { body: args.body, subject: args.subject ?? '' }); + case 'hear': return hearInterpreter(c); + case 'fleet_status': return fleetStatus(c); default: throw new FleetError(`unknown tool ${name}`); } }