diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e1fc0b4d..bba97f26 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,8 +8,8 @@ { "name": "fleet", "source": "./plugin", - "description": "Mount the fleetd MCP gateway and apply standard Claude Code settings so a session can orchestrate delegated workers. Ships no credentials.", - "version": "0.2.0", + "description": "Apply standard Claude Code settings so a session can orchestrate delegated workers, and run the fleet mod for cross-session messaging. Mounting the fleetd MCP gateway is the instance's or the project's job, not this plugin's. Ships no credentials.", + "version": "0.3.0", "author": { "name": "LTMS" } diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 82e14f11..3ac43f1c 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "fleet", - "description": "Make a project fleet-ready: mount the fleetd MCP gateway and set up standard Claude Code settings so this session can orchestrate a fleet of delegated workers. Lead-side only — member skills and agents travel in the worktree. Ships no credentials.", - "version": "0.2.0", + "description": "Set up standard Claude Code settings so this session can orchestrate a fleet of delegated workers, and run the fleet mod for cross-session messaging. Lead-side only — member skills and agents travel in the worktree, and mounting the fleetd MCP gateway is now the instance's or the project's job, not this plugin's. Ships no credentials.", + "version": "0.3.0", "author": { "name": "LTMS" }, diff --git a/plugin/.mcp.json b/plugin/.mcp.json deleted file mode 100644 index 2a9939e3..00000000 --- a/plugin/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "fleet": { - "type": "http", - "url": "${FLEETD_MCP_URL}" - } - } -} diff --git a/plugin/README.md b/plugin/README.md index cc33d331..69b0310c 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -1,7 +1,7 @@ # fleet (Claude Code plugin) -Makes a project **fleet-ready**: mounts the `fleetd` MCP gateway and applies standard Claude Code -settings, so the session can orchestrate a fleet of delegated workers. +Makes a project **fleet-ready**: applies standard Claude Code settings and runs the fleet mod, so +the session can orchestrate a fleet of delegated workers. **This plugin ships no credentials.** Every secret is referenced by environment-variable *name*; the values stay with the user. Nothing the plugin writes is unsafe to commit. @@ -22,9 +22,10 @@ Member-facing assets travel in the worktree, not in this plugin. See fleetd #362 ## What it is not The plugin is the **client-side setup**, not the bridge. `fleetd` is a separate daemon and `herdr` -is a separate PTY multiplexer, each with its own lifecycle and install. The plugin mounts an -already-running daemon and tells you what is missing when one isn't there — it deliberately does -not try to install system services on your behalf. +is a separate PTY multiplexer, each with its own lifecycle and install, and the plugin does not try +to install either on your behalf. It also does not mount the daemon for you — mounting is the +instance's or the project's own `.mcp.json`, and `/fleet:setup` is the one thing in this plugin that +still helps with that (it writes the project-level entry). ## Install @@ -33,11 +34,20 @@ not try to install system services on your behalf. /plugin install fleet@fleetd ``` -Export the gateway URL — the plugin mounts `${FLEETD_MCP_URL}`, not a hardcoded address, so one -plugin serves hosts that run the daemon on different ports: +Mount the daemon yourself — this plugin carries no mount of its own. Either add the entry below to +your Claude Code instance's own `.claude.json`, so every project you open there gets it, or run +`/fleet:setup` in the project you want to onboard, which writes the same entry into that project's +`.mcp.json`: -```shell -export FLEETD_MCP_URL=http://127.0.0.1:8765/mcp +```json +{ + "mcpServers": { + "fleet": { + "type": "http", + "url": "http://127.0.0.1:8765/mcp" + } + } +} ``` Then, in the project you want to onboard: @@ -50,18 +60,23 @@ Then, in the project you want to onboard: | Component | Effect | |---|---| -| `.mcp.json` | mounts `fleet` at `${FLEETD_MCP_URL}` for any session with the plugin enabled | -| `skills/setup` | `/fleet:setup` — preflight, project settings, credential guidance, and verification | +| `skills/setup` | `/fleet:setup` — preflight, project settings, credential guidance, and verification. Also the only thing in this plugin that helps mount `fleet`: it writes the project `.mcp.json` entry shown above. | +| `hooks/register.js` (the fleet mod) | Cross-account session messaging while the plugin is enabled: `/fleet-peers`, `/fleet-mail`, `/fleet-whoami`, and a background poll that delivers mail fleetd queued for this pane. A spawned worker or architect skips that poll, because it already gets its brief pasted into its pane. | -The server is named **`fleet`** on purpose: that is `PeerLauncher.MCP_MOUNT_NAME` in the daemon and -the name a spawned member's own mount carries. Version 0.1.0 named it `fleetd`, which produced two -mounts of one daemon for anyone who also had a project-level `.mcp.json`. Upgrading from 0.1.0 is a -**breaking change** — a project that pre-allowed `mcp__fleetd__fleet_whoami` in -`.claude/settings.json` must be updated to `mcp__fleet__*`. +Whichever file mounts the daemon, name the server **`fleet`**. That is `PeerLauncher.MCP_MOUNT_NAME` +in the daemon, the name a spawned member's own mount carries, and the name the `mcp__fleet__*` +role heuristic in `CLAUDE.md` keys on. -Because the plugin carries its own `.mcp.json`, an installed plugin needs no project-level MCP -file at all. The setup skill writes one only when you want the mount to work *without* the plugin — -for teammates who haven't installed it, or for CI. +## Upgrading from 0.2.0 — breaking + +The plugin no longer mounts the daemon. It used to carry its own `.mcp.json`, pointed at +`${FLEETD_MCP_URL}`, and that file is gone along with the environment variable. Mount `fleet` +yourself: add the entry under **Install** above to your instance's `.claude.json` or to the +project's own `.mcp.json`, by hand or with `/fleet:setup`. + +Version 0.1.0 named the mounted server `fleetd`, which produced two mounts of one daemon for +anyone who also had a project-level `.mcp.json`. A project that pre-allowed +`mcp__fleetd__fleet_whoami` in `.claude/settings.json` must be updated to `mcp__fleet__*`. ## Verifying a setup diff --git a/plugin/hooks/register.js b/plugin/hooks/register.js index c31ab31c..79abebad 100644 --- a/plugin/hooks/register.js +++ b/plugin/hooks/register.js @@ -67,23 +67,28 @@ async function livePeers($, now) { return rows } -const FLEETD_MCP = 'http://127.0.0.1:8765/mcp' +const FLEETD_MCP_DEFAULT = 'http://127.0.0.1:8765/mcp' const MCP_HEADERS = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' } // The status fleetd answers, with "Session not found", for an Mcp-Session-Id it no longer holds. const MCP_SESSION_GONE = 404 -// The MCP session every call below shares. fleetd keeps a server-side session per initialize and -// drops it only on a DELETE, so one initialize per call would leave a session behind every time. +// The MCP session every call below shares, and the URL it was opened against. fleetd keeps a +// server-side session per initialize and drops it only on a DELETE, so one initialize per call +// would leave a session behind every time. let mcpSessionId = null +let fleetdUrl = FLEETD_MCP_DEFAULT /** * Open an MCP session on the local fleetd and hold it for later calls. * - * Cleared first, so a failure here leaves no dead id behind for the next call to reuse. + * Cleared first, so a failure here leaves no dead id behind for the next call to reuse. Reads + * FLEETD_MCP_URL fresh on every open, so a session opened after the daemon moves uses the new + * address. */ async function openFleetSession($) { mcpSessionId = null - const init = await $.http.fetch(FLEETD_MCP, { + fleetdUrl = (await $.env.get('FLEETD_MCP_URL')) || FLEETD_MCP_DEFAULT + const init = await $.http.fetch(fleetdUrl, { method: 'POST', headers: MCP_HEADERS, body: JSON.stringify({ @@ -93,7 +98,7 @@ async function openFleetSession($) { }) if (!init.ok) throw new Error('fleetd initialize failed with status ' + init.status) const opened = init.headers['mcp-session-id'] - await $.http.fetch(FLEETD_MCP, { + await $.http.fetch(fleetdUrl, { method: 'POST', headers: { ...MCP_HEADERS, 'Mcp-Session-Id': opened }, body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), @@ -103,7 +108,7 @@ async function openFleetSession($) { /** Send one tools/call on the session this mod holds, and return the raw HTTP answer. */ function sendFleetToolCall($, tool, args) { - return $.http.fetch(FLEETD_MCP, { + return $.http.fetch(fleetdUrl, { method: 'POST', headers: { ...MCP_HEADERS, 'Mcp-Session-Id': mcpSessionId }, body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: tool, arguments: args } }), @@ -170,17 +175,32 @@ export function register(on) { } }) + // A spawned worker or architect already gets its brief pasted into its pane, so this + // poll would be a second, redundant delivery path for it. Every other role collects + // its own mail through this poll. + let role = null + // Collect what the fleet queued for this pane and hand each message to // Claude. Every call also renews fleetd's record that this pane collects its // own mail, so an empty answer still has to be asked for. $.clock.every(FLEETD_INBOX_POLL_MS, async () => { + if (role === null) { + try { + role = JSON.parse(await fleetTool($, 'fleet_whoami', {})).role + } catch { + // A daemon that is down, or a pane fleetd cannot place, is the ordinary + // case on a host with no fleet running. Retry on the next tick. + return + } + } + if (role === 'worker' || role === 'architect') return + let collected try { collected = JSON.parse(await fleetTool($, 'fleet_inbox', {})) } catch { - // A daemon that is down, or a pane fleetd cannot place, is the ordinary - // case on a host with no fleet running. The timer survives a throw, so - // this only keeps every tick from writing an error to the debug log. + // The timer survives a throw, so this only keeps every tick from + // writing an error to the debug log. return } for (const text of collected.messages || []) { diff --git a/plugin/skills/setup/SKILL.md b/plugin/skills/setup/SKILL.md index 22df494d..58587273 100644 --- a/plugin/skills/setup/SKILL.md +++ b/plugin/skills/setup/SKILL.md @@ -36,16 +36,11 @@ a time. command -v herdr && herdr --version 2>&1 | head -1 || echo "MISSING: herdr" command -v ccs && ccs version 2>&1 | head -1 || echo "MISSING: ccs (needed for worker profiles)" command -v codex && codex --version 2>&1 | head -1 || echo "absent: codex (optional)" -curl -s -m 5 "${FLEETD_MCP_URL%/mcp}/healthz" 2>/dev/null \ - || curl -s -m 5 http://127.0.0.1:8765/healthz \ - || echo "MISSING: fleetd daemon is not reachable" -[ -n "$FLEETD_MCP_URL" ] && echo "FLEETD_MCP_URL is set" || echo "MISSING: FLEETD_MCP_URL" +curl -s -m 5 http://127.0.0.1:8765/healthz || echo "MISSING: fleetd daemon is not reachable" ``` -**`FLEETD_MCP_URL` is required.** The plugin's own `.mcp.json` mounts `${FLEETD_MCP_URL}` rather -than a hardcoded address, so one plugin can serve hosts that run the daemon on different ports. If -it is unset the mount does not resolve. The usual value is `http://127.0.0.1:8765/mcp`; tell the -user to export it, do not write it into a file for them. +The usual address is `http://127.0.0.1:8765`. If this daemon runs elsewhere, use that address +instead wherever this skill writes `http://127.0.0.1:8765/mcp` below. A healthy daemon answers with its status **and the herdr protocol it negotiated**: @@ -95,10 +90,9 @@ If `.mcp.json` already exists, add only the `fleet` key and leave every other se If a `fleet` entry is already there with a different URL, **ask** rather than assuming yours is right — a non-default port usually means a deliberate second daemon. -> **If this plugin is installed, you can skip this step entirely.** The plugin ships its own -> `.mcp.json`, so `fleetd` is already mounted for any session with the plugin enabled. Write the -> project-level file only when the user wants the mount to work *without* the plugin — for -> teammates who have not installed it, or for CI. +This write is the only way this plugin helps mount `fleet` — the plugin carries no mount of its +own. A session that wants the mount without running this skill can instead add the same entry to +its own Claude Code instance's `.claude.json`. **Before writing it, settle whether `.mcp.json` is committed here:** diff --git a/plugin/tests/fleet-mod.test.ts b/plugin/tests/fleet-mod.test.ts index acfacf32..ea8c39da 100644 --- a/plugin/tests/fleet-mod.test.ts +++ b/plugin/tests/fleet-mod.test.ts @@ -32,6 +32,7 @@ function stubEngine(on: any): Map { on('session.cwd', () => ({ value: '/Users/x/claude-bridge' })) on('ui.log', () => ({ value: undefined })) on('prompt.submit', () => ({ value: undefined })) + on('env.get', () => ({ value: undefined })) // Claude Code's own delivery, which the mod's receive hook must reach. on('session.receive', (_$: any, e: any) => e) return store @@ -181,6 +182,7 @@ function stubSessionStart(on: any, submitted: string[]): Map { on('session.cwd', () => ({ value: '/Users/x/claude-bridge' })) on('command.register', () => ({ value: undefined })) on('ui.log', () => ({ value: undefined })) + on('env.get', () => ({ value: undefined })) // The engine skips a prompt.submit hook that answers anything but { text } or { drop }, and // the mod's callback then throws, so this must hand the text straight back. on('prompt.submit', (_$: any, e: any) => { @@ -194,10 +196,12 @@ function stubSessionStart(on: any, submitted: string[]): Map { /** * Answer fleetd's MCP requests, with the tool result read fresh on every call. * - * `fetches` collects every method sent, and `sessions` the Mcp-Session-Id of each tools/call. - * Each initialize hands out the next id, so a reused session and a reopened one differ. - * `sessionGone` makes a tools/call answer the way fleetd answers for a session id it no longer - * holds. + * `fetches` collects every method sent, with a tools/call entry naming its tool (such as + * `tools/call:fleet_inbox`), and `sessions` the Mcp-Session-Id of each tools/call. Each + * initialize hands out the next id, so a reused session and a reopened one differ. + * `sessionGone` makes a tools/call answer the way fleetd answers for a session id it no + * longer holds. `whoamiText` answers a `fleet_whoami` call apart from `toolText`, which + * answers every other tool. */ function stubFleetdDynamic( on: any, @@ -205,24 +209,28 @@ function stubFleetdDynamic( fetches: string[], sessions: string[] = [], sessionGone: () => boolean = () => false, + whoamiText: () => string = () => '{"role":"primary"}', ) { let opened = 0 on('http.fetch', (_$: any, e: any) => { const body = JSON.parse(e.init.body) - fetches.push(body.method) if (body.method === 'initialize') { + fetches.push(body.method) opened += 1 return { value: { ok: true, status: 200, headers: { 'mcp-session-id': 'sid-' + opened }, text: '{}' } } } if (body.method === 'tools/call') { + fetches.push(body.method + ':' + body.params.name) sessions.push(e.init.headers['Mcp-Session-Id']) if (sessionGone()) { const gone = '{"jsonRpcError":{"code":-32603,"message":"Session not found"}}' return { value: { ok: false, status: 404, headers: {}, text: gone } } } - const result = { jsonrpc: '2.0', id: 2, result: { content: [{ type: 'text', text: toolText() }] } } + const text = body.params.name === 'fleet_whoami' ? whoamiText() : toolText() + const result = { jsonrpc: '2.0', id: 2, result: { content: [{ type: 'text', text }] } } return { value: { ok: true, status: 200, headers: {}, text: 'data: ' + JSON.stringify(result) + '\n' } } } + fetches.push(body.method) return { value: { ok: true, status: 202, headers: {}, text: '' } } }) } @@ -311,11 +319,11 @@ test('a second inbox poll reuses the first MCP session', async ($, on) => { await clock.advance(3_000) await clock.advance(3_000) - // fleetd holds a session per initialize and drops it only on a DELETE, so one initialize per - // poll would leave one behind every three seconds. - expect(sessions.length).toBe(2) // control: both polls really reached fleetd + // The first poll also reads the role once, so it makes two tools/call (whoami, then + // inbox); the second poll already knows the role and makes only one (inbox). + expect(sessions.length).toBe(3) // control: all three calls really reached fleetd expect(initializes(fetches)).toBe(1) - expect(sessions[1]).toBe(sessions[0]) + expect(sessions.every((s) => s === sessions[0])).toBe(true) }) test('a session fleetd no longer holds is opened again and the call retried', async ($, on) => { @@ -347,3 +355,136 @@ test('a session fleetd no longer holds is opened again and the call retried', as expect(sessions[sessions.length - 1]).toBe('sid-2') expect(submitted.length).toBe(2) }) + +/** How many of `fetches` were a tools/call for the named tool. */ +function toolCalls(fetches: string[], tool: string): number { + return fetches.filter((m) => m === 'tools/call:' + tool).length +} + +test('a worker role stops the poll from calling fleet_inbox', async ($, on) => { + const clock = mock.clock(on) + const submitted: string[] = [] + stubSessionStart(on, submitted) + const fetches: string[] = [] + const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] } + stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"worker"}') + + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' }) + + await clock.advance(3_000) + await clock.advance(3_000) + + expect(toolCalls(fetches, 'fleet_whoami')).toBe(1) + expect(toolCalls(fetches, 'fleet_inbox')).toBe(0) + expect(submitted.length).toBe(0) +}) + +test('an architect role stops the poll from calling fleet_inbox', async ($, on) => { + const clock = mock.clock(on) + const submitted: string[] = [] + stubSessionStart(on, submitted) + const fetches: string[] = [] + const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] } + stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"architect"}') + + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' }) + + await clock.advance(3_000) + await clock.advance(3_000) + + expect(toolCalls(fetches, 'fleet_whoami')).toBe(1) + expect(toolCalls(fetches, 'fleet_inbox')).toBe(0) + expect(submitted.length).toBe(0) +}) + +test('a primary role keeps the poll calling fleet_inbox', async ($, on) => { + // The positive control for the two tests above: the same stubs, a role neither + // gates, so a bug that silenced every role would still pass them. + const clock = mock.clock(on) + const submitted: string[] = [] + stubSessionStart(on, submitted) + const fetches: string[] = [] + const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] } + stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"primary"}') + + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' }) + + await clock.advance(3_000) + + expect(toolCalls(fetches, 'fleet_inbox')).toBe(1) + expect(submitted.length).toBe(1) +}) + +test('an observer role keeps the poll calling fleet_inbox', async ($, on) => { + const clock = mock.clock(on) + const submitted: string[] = [] + stubSessionStart(on, submitted) + const fetches: string[] = [] + const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] } + stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"observer"}') + + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' }) + + await clock.advance(3_000) + + expect(toolCalls(fetches, 'fleet_inbox')).toBe(1) + expect(submitted.length).toBe(1) +}) + +test('a whoami that fails on the first tick is retried and the poll resumes', async ($, on) => { + const clock = mock.clock(on) + const submitted: string[] = [] + stubSessionStart(on, submitted) + const fetches: string[] = [] + const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] } + let whoamiFails = true + on('http.fetch', (_$: any, e: any) => { + const body = JSON.parse(e.init.body) + if (body.method === 'tools/call' && body.params.name === 'fleet_whoami' && whoamiFails) { + fetches.push(body.method + ':' + body.params.name) + return { value: { ok: false, status: 503, headers: {}, text: '' } } + } + if (body.method === 'initialize') { + fetches.push(body.method) + return { value: { ok: true, status: 200, headers: { 'mcp-session-id': 'sid-1' }, text: '{}' } } + } + if (body.method === 'tools/call') { + fetches.push(body.method + ':' + body.params.name) + const text = body.params.name === 'fleet_whoami' ? '{"role":"observer"}' : JSON.stringify(inbox) + const result = { jsonrpc: '2.0', id: 2, result: { content: [{ type: 'text', text }] } } + return { value: { ok: true, status: 200, headers: {}, text: 'data: ' + JSON.stringify(result) + '\n' } } + } + fetches.push(body.method) + return { value: { ok: true, status: 202, headers: {}, text: '' } } + }) + + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' }) + + await clock.advance(3_000) + expect(toolCalls(fetches, 'fleet_inbox')).toBe(0) // control: a failed whoami calls no fleet_inbox + expect(submitted.length).toBe(0) + + whoamiFails = false + await clock.advance(3_000) + + expect(toolCalls(fetches, 'fleet_inbox')).toBe(1) + expect(submitted.length).toBe(1) +}) + +test('whoami is called once, not on every tick, once the role is known', async ($, on) => { + const clock = mock.clock(on) + const submitted: string[] = [] + stubSessionStart(on, submitted) + const fetches: string[] = [] + const inbox = { sessionId: 'term_self', count: 0, messages: [] as string[] } + stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"primary"}') + + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' }) + + await clock.advance(3_000) + await clock.advance(3_000) + await clock.advance(3_000) + + expect(toolCalls(fetches, 'fleet_whoami')).toBe(1) + expect(toolCalls(fetches, 'fleet_inbox')).toBe(3) +})