fleetd #791: merge PR #794 — fleet plugin 0.3.0, no MCP mount, mod off for members

The plugin no longer carries .mcp.json; the instance or the project mounts
fleet. The mod asks fleet_whoami once and skips the fleet_inbox poll for a
worker or an architect, so members keep paste delivery. The mod reads
FLEETD_MCP_URL with $.env.get (documented in the mods API), defaulting to
http://127.0.0.1:8765/mcp.

Lead verification on 87f7c03: claude plugin validate plugin passed;
claude plugin test plugin 21 pass, 0 fail. With the role gate removed, the
worker and architect tests fail (19 pass, 2 fail), so they are not vacuous.
This commit is contained in:
Dai Ha
2026-10-06 08:03:01 +02:00
7 changed files with 225 additions and 63 deletions
+2 -2
View File
@@ -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"
}
+2 -2
View File
@@ -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"
},
-8
View File
@@ -1,8 +0,0 @@
{
"mcpServers": {
"fleet": {
"type": "http",
"url": "${FLEETD_MCP_URL}"
}
}
}
+34 -19
View File
@@ -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
+30 -10
View File
@@ -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 || []) {
+6 -12
View File
@@ -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:**
+151 -10
View File
@@ -32,6 +32,7 @@ function stubEngine(on: any): Map<string, any> {
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<string, any> {
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<string, any> {
/**
* 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)
})