Fleet Dispatch¶
Hash-verified, drift-aware deployment of tracked skills and peer config is documented in docs/config-deploy.md.
Fleet Dispatch is a local-first command runner for house PCs. It supports the original OneDrive queue path and a persistent LAN peer path. Each target PC runs fleet-dispatch serve, listens on LAN IPv4 plus loopback, verifies signed jobs, checks the usage governor, executes through Claude CLI or PowerShell, and writes results back to its outbox.
Commands¶
fleet-dispatch init-key
fleet-dispatch enqueue --targets all --kind claude --lane WT-xxxx --payload-file job.md
fleet-dispatch enqueue --targets ANDYGREATROOMPC --kind pwsh --lane WT-xxxx --payload "Get-Process pwsh"
fleet-dispatch send --target ANDYGREATROOMPC --kind claude --lane WT-xxxx --payload-file job.md --wait
fleet-dispatch worktree list --target ANDYGREATROOMPC --lane WT-xxxx --repo C:\wt\repo
fleet-dispatch listen --once
fleet-dispatch listen --poll 60
fleet-dispatch serve --port 47600 --poll 30 --host 10.0.0.53
fleet-dispatch status
fleet-dispatch results --target ANDYGREATROOMPC
Targets¶
--targets all expands to the live fleet listed in TargetResolver.DefaultPcs:
ANDREWBEDROOMTV, ANDYGREATROOMPC, DESKTOP-D249KD4, DESKTOP-SCDT9HI.
A dispatch directory does not make a name dispatchable. all used to expand to whatever
folders existed under %OneDrive%\PC-Bootstrap\_shared\dispatch, and any name with a folder was
accepted — so the folder a bad job created then legitimised that name forever. 7 jobs rotted in
BEDROOM-PC, a queue nothing has ever drained, while enqueue reported success every time
(FLT-68). A folder is evidence of history, not of a live drainer, so it now gets no vote.
To dispatch to a new box before it is shipped in DefaultPcs, add it with
FLEET_DISPATCH_PCS (comma-separated, added to the defaults — it never removes one):
$env:FLEET_DISPATCH_PCS = "NEWPC"
Then pin the name in DefaultPcs and ship. Use the box's real COMPUTERNAME spelling.
Retiring a box is deliberately not possible through the env var — it is an edit to
DefaultPcs, so it goes through review rather than a stray shell variable.
Allowlisting is enqueue-only. send talks to a peer over HTTP and fails loudly on a bad
name, which is not the silent-rot failure this guards; listen drains whatever is addressed to
its own machine.
send uses the direct peer HTTP path. enqueue keeps the async OneDrive path.
Peer discovery reads %USERPROFILE%\.claude\fleet-peers.json ({ "PCNAME": "host:port" }) unless FLEET_DISPATCH_PEERS_FILE overrides it, then merges %OneDrive%\PC-Bootstrap\_shared\peers\*.json. serve writes this PC's {name,host,port} entry at startup. Unknown peer targets fall back to target:47600.
serve auto-picks the LAN address by preferring NICs with an IPv4 default gateway (so Hyper-V/WSL virtual switches don't win). On dual-homed hosts where more than one NIC is gatewayed, pin the address with --host <ip> or the FLEET_DISPATCH_HOST environment variable — it is used for both the listener bind and peer self-registration. A wildcard (0.0.0.0, +, *) binds all interfaces but never gets advertised to peers.
Security¶
Trust boundary is the LAN plus the shared fleet key. The key lives outside OneDrive at %USERPROFILE%\.claude\fleet-dispatch.key and must be copied through the SSH mesh.
The same fleet key provides two checks:
- Per-job HMAC-SHA256 signature for authenticity and integrity.
X-Fleet-Authbearer, equal to lowercase hexSHA256(key), as a cheap pre-verify gate before HMAC work.
The listener binds only its preferred host address (Tailscale IP or LAN, see Addressing) and 127.0.0.1, not 0.0.0.0 or +. Inbound firewall rules allow TCP 47600 from LocalSubnet (Domain/Private) and from the tailnet, bound to the Tailscale interface. Network attackers without the key cannot pass bearer or HMAC checks. Replay guard rejects captured requests when a result already exists in outbox or archive/done. The governor still defers non-critical work during RED or BLACKOUT.
Residual boundary: anyone who can reach the bind address (the LAN subnet, or the tailnet) and holds the fleet key can run commands as the listener identity. Treat the key like admin-equivalent house infrastructure.
init-key creates %USERPROFILE%\.claude\fleet-dispatch.key, restricts ACLs to current user, and prints: copy this file to each PC's ~/.claude via the SSH mesh (scp), NEVER into OneDrive. Override path with FLEET_DISPATCH_KEY_FILE.
Governor Integration¶
Listeners read %USERPROFILE%\.claude\usage-governor\state.json. RED or BLACKOUT defers non-critical jobs in inbox. Critical jobs run. Missing or unreadable governor state fails open and does not defer.
Addressing¶
serve binds and advertises one IP that every peer must reach and that must stay constant.
By default it auto-picks that IP via PeerRegistry.GetPreferredHost: the Tailscale
address (100.64.0.0/10) when present, otherwise the gatewayed LAN NIC. The tailnet address is
preferred because the physical LAN address is not stable on this fleet — the boxes sit behind
wireless NETGEAR extenders that each run their own DHCP, so a lease renewal or an SSID roam
silently changes a box's 10.0.0.x IP and orphans the pinned host, the URL reservation, and the
shared peers entry (FLT-83). The 100.x address rides over all of it (FLT-84).
--host <ip> / FLEET_DISPATCH_HOST overrides the auto-pick — use it to force a box onto its
LAN instead of the tailnet, or to disambiguate. The firewall carries two rules: LocalSubnet
on Domain+Private (never Public), and a tailnet rule for 100.64.0.0/10 bound to the Tailscale
interface — the interface bind is the boundary, since a RemoteAddress filter alone is only a
source-IP check and a host on a hostile L2 could set a 100.64/10 source to reach the port over
the physical NIC. The fleet key (HMAC) remains the authentication boundary regardless. Without
the tailnet rule the bind succeeds but every peer is refused.
Persistent Listener¶
Install it as a scheduled task. Two steps, once per box, in this order — use the box's Tailscale IP (see Addressing below):
# 1. UNELEVATED. Reserves both URL prefixes so the listener can bind without admin.
# It self-elevates for the netsh calls, then verifies the bind as the account that
# actually runs serve - which is why the parent must NOT be elevated: an elevated
# bind succeeds with or without a reservation and proves nothing. It refuses to run
# elevated for that reason. The IP is mandatory: guessing picks the wrong NIC.
.\setup-urlacl.ps1 -IpAddress 100.69.90.95
# 2. ELEVATED. Registers the task, opens the firewall (LAN + tailnet rules), proves /health.
.\repair-fleet-listener.ps1 -LanHost 100.69.90.95
setup-urlacl.ps1 is the only thing that grants reservations; repair-fleet-listener.ps1
only reports them and refuses to run -RunLevel Limited without both, since
HttpListener.Start() is all-or-nothing and one reservation is worse than none.
The listener runs unprivileged by default. -RunLevel Highest is a last resort: serve
executes arbitrary commands (kind=pwsh) from the LAN, so an elevated listener turns a
fleet-key leak into admin RCE. Use it only until step 1 is done, and setup-urlacl.ps1 -Remove
reverses step 1.
Do NOT install this as a Windows service. sc.exe create FleetDispatchServe ... was the
documented recipe here until 2026-07-16 and it cannot work: FleetDispatch.exe is a console
app that never connects to the SCM, so Windows waits 30s, logs 7000/7009 (error 1053) and
kills it. The listener genuinely serves during those 30 seconds, so the box looks healthy
right up until it isn't — ANDYGREATROOMPC sat unreachable this way. See FLT-71.
--host is mandatory on dual-homed boxes: the auto-picked address may be a NIC peers cannot
reach, and serve advertises whatever it picks into the shared peers registry.
Set FLEET_DISPATCH_ROOT only when testing or using a nonstandard OneDrive path.