SLOPSHOPPER

fleet-status

Shows this fleet instance's name and the agents at work across every fleet above the prompt.

newbandtimer
★ 5v0.0.0-devGPL-3.0updated 2026-10-09BenjaminBenetti/fleet-man/internal/agentstrategy/statusmod
A shopper browsing a rack in a slop shop
README

fleet-man

A CLI/TUI tool for managing fleets of agents each inside there own isolated devcontainers. This tool enables hugly parallel development, especially if you utilize the fleet-admiral skill (auto added to your claude code on install).

fleet-man close up


fleet-man screenshot

What problem does fleet solve?

I made this tool to solve my own problem, and hopefully yours as well. I wanted a tool that allowed the following.

  1. Running unlimited parallel agents that don't step on each other. I mean

this in a way beyond the git worktree I don't want ports to step on each other, I don't want one agent deleting the whole computer to step on another agent. I want full isolation.

  1. Use my claude code (or any agent you want) subscription at the subscription

price! Many similar tools exist to this, but require you to pay API rates. This tool just uses terminals so you will never need to pay API rates.

  1. Not need to constantly setup new environments for agents. Environments need

automated setup.

  1. Give me a one screen dashboard to manage all my agents, including knowing

when they need attention.

fleet solves all these problems. I use it every day. A few other senior developers at my company use it every day. You should use it every day!

Contents

Deep-dive docs (in doc/):

Install

sudo curl -sL https://raw.githubusercontent.com/BenjaminBenetti/fleet-man/main/install.sh | sh

To install a specific version:

sudo curl -sL https://raw.githubusercontent.com/BenjaminBenetti/fleet-man/main/install.sh | sh -s -- --version v0.2.0

Usage

Run fleet with no arguments to launch the interactive TUI, or use subcommands directly:

# Launch TUI
fleet

# Spawn instances from the current repo
fleet up agent-1
fleet up agent-2

# Stop and restart an existing instance without removing it
fleet stop agent-1
fleet start agent-1

# List instances
fleet ls

# Exec into an instance
fleet exec agent-1 bash

# Open VS Code on an instance
fleet code agent-1

# Launch the in-instance link/app grid (run inside an instance)
fleet launch

# View logs
fleet logs agent-1

# Rebuild an instance's container in place (e.g. after editing devcontainer.json)
fleet rebuild agent-1

# Remove an instance
fleet down agent-1

# Remove a fleet and all its instances
fleet destroy my-project

# Spawn from anywhere with explicit repo
fleet up agent-1 --repo git@github.com:org/my-project.git

# Reference an existing fleet from anywhere
fleet up my-project/agent-3

# Fleet a local directory instead of a git remote: file:// marks a TEMPLATE dir
# that is copied (not cloned) into every new instance. A local path has no repo
# name, so the fleet must be named explicitly (the TUI prompts for it).
fleet up scratch/agent-1 --repo file:///home/me/scratch-project

# Configure automation: agents (workers) and triggers (what fires them)
fleet agent create my-project nightly-builder --system-prompt "Build and report"
fleet trigger create my-project nightly --agent nightly-builder --cron "0 0 * * *" --prompt "Run the nightly build"
# A 'bash' trigger polls a command on its cron and fires only when it exits 0
# (stdout becomes the event payload) — for sources without a webhook
fleet trigger create my-project new-issues --type bash --agent triager --cron "*/5 * * * *" \
  --script 'gh issue list --label needs-triage --json number -q ".[].number" | grep -q .' --prompt "Triage the new issues"
fleet agent list my-project
fleet trigger list my-project
fleet trigger logs my-project nightly   # inspect a trigger's recorded firings

# Virtual microphone (enable it under Settings -> Microphone first)
fleet mic devices                       # this machine's capture devices
fleet mic sources                       # every connected client's capture devices (* = the one recorded)
fleet mic attach                        # provide the microphone without a TUI open (records the Settings device; --device overrides)

Agent state and container lifecycle

Enabling a fleet's Claude Code mount shares ~/.claude, ~/.claude.json, and the project's .claude/settings.local.json across its devcontainer instances. Project overrides survive instance deletion and recreation; other project .claude files remain part of each workspace. Rebuild existing instances to apply newly enabled mounts and project settings links.

Enabling a fleet's Codex mount shares ~/.codex and installs Codex in each instance. Creation and rebuilds also configure Codex's default permissions for automatic approval review ("Approve for me"): approval_policy = "on-request", approvals_reviewer = "auto_review", and sandbox_mode = "workspace-write". These three top-level settings are reset on every create/rebuild of any instance in the fleet, including when Codex is already installed in the image. Use a named Codex profile for a different permission mode; other settings and named profiles are preserved. Configuration requires flock (provided by util-linux or BusyBox on Linux). Fleet keeps a .fleet-codex.lock file in the shared Codex home to coordinate provisioning. Auto-review handles eligible approval requests; actions it rejects may still need your input.

Codex's Linux workspace sandbox requires working bubblewrap/user namespaces inside the container. Standard unprivileged Fleet instances may be unable to create these namespaces; this reproduces with Fleet's own integration fixture, so Codex's workspace sandbox may be unavailable even when installation and configuration succeed. If Codex then requests approval to run outside its sandbox, eligible requests go to automatic review instead of the user. See the Codex container sandbox guidance. Fleet configures the approval mode but does not change the container's security options.

Fleet runs the devcontainer's postStartCommand during creation and after each stop/start cycle. Starting an already running instance does not repeat it. Restart hook failures are logged while the container remains running.

TUI Keybindings

KeyAction
j/kNavigate
spaceExpand/collapse fleet
enter/eExec into instance
sStop/start instance
oOpen instance in new terminal
aAdd instance
nNew fleet
dDelete instance/fleet
cOpen VS Code
RRebuild instance (preserves workspace)
LView logs (instance logs, or a selected trigger's event logs in the automation view)
→/l, ←/hSelect / deselect the PR-status auto tag
enter (on PR status)Open the PR in a browser
ASwitch armada (remote fleet)
rRefresh
qQuit

MCP Server

The fleet daemon also runs an MCP server over Streamable HTTP, so AI agents (and any MCP client) can drive fleet programmatically. It starts automatically with the daemon — no extra command.

  • It binds 127.0.0.1:6012, or the next free port if 6012 is taken.
  • The active port is written to ~/.fleet/mcp.port, so clients can discover the endpoint. The URL is http://127.0.0.1:<port>.
  • Requests must carry Authorization: Bearer <token>, where the token is read from ~/.fleet/mcp.token. The loopback port is reachable by any local user, so the token (file mode 0600, same-user only) is the access boundary — matching the daemon's unix socket. The token is generated once and reused across restarts.

Claude Code needs no setup: launching the fleet TUI registers the server in ~/.claude.json as the user-scope fleet entry (URL and token included) and re-syncs it on every launch, so a daemon restart that lands on a new port heals itself. It also installs the Fleet Admiral skill (~/.claude/skills/fleet-admiral), which teaches the agent how to drive these tools. (Local daemons only: when FLEET_GATEWAY/FLEET_SERVER point the TUI at a remote daemon, registration is skipped — point Claude Code at the Public MCP URL from the settings page instead, see Remote MCP. Registration is best-effort and never blocks startup.) The snippet below is for other MCP clients or manual setups.

For convenience the server also writes ~/.fleet/mcp.env (mode 0600) with the endpoint as shell exports, and wires ~/.bashrc to source it, so new shells get:

FLEET_MCP_PORT=6012
FLEET_MCP_URL=http://127.0.0.1:6012
FLEET_MCP_TOKEN=<token>

An MCP client config (mcp.json) can then reference them directly:

{
  "mcpServers": {
    "fleet": {
      "type": "http",
      "url": "${FLEET_MCP_URL}",
      "headers": { "Authorization": "Bearer ${FLEET_MCP_TOKEN}" }
    }
  }
}

(zsh users: add [ -f "$HOME/.fleet/mcp.env" ] && . "$HOME/.fleet/mcp.env" to ~/.zshrc.)

Tools mirror the non-interactive CLI: fleet_list, fleet_status, fleet_version, fleet_logs, fleet_up, fleet_start, fleet_stop, fleet_down, fleet_destroy_fleet, fleet_clone, fleet_rebuild, fleet_exec, the tmux session tools fleet_session_spawn / fleet_session_exec / fleet_session_read / fleet_session_list, and the automation CRUD tools fleet_automation_list, fleet_agent_create / fleet_agent_update / fleet_agent_delete, fleet_trigger_create / fleet_trigger_update / fleet_trigger_delete, and fleet_trigger_logs (mirroring the fleet agent and fleet trigger CLI commands). Interactive, open-ended commands (fleet shell, log following) are intentionally not exposed.

Fleet MCP inside instances

A fleet can also provide these tools to the coding agents running inside its instances, so an agent there can take on work too big for one agent — spin up more instances, run agents in them, collect what they produce — or act as a coordinator for all of your fleets. Turn on Fleet MCP in the fleet's options (e on the fleet). Off by default; it applies to every instance of that fleet, however it was created (by you, a clone, or an automation trigger).

  • It is the full fleet MCP, on purpose — every tool, every fleet. That includes tools that reach the host: a bash automation trigger runs its script on the host as you, and a file:// fleet copies host directories into an instance. Turning it on gives anything running in those instances — an agent, a prompt injection of it, the repo's own scripts — host-level access: an agent-escape risk you accept by turning it on.
  • The daemon serves MCP on a socket in each instance's control directory (/fleet-mounts/control/mcp.sock); the staged fleet mcp-bridge relays it to an agent over stdio. No token enters the instance: only instances of a fleet with the setting on get the socket, and only that instance's own processes can connect.
  • Agents pick it up from their shell, not from their config: ~/.fleet/fleet.rc (sourced by ~/.bashrc) sees the socket and runs fleet mcp-env, which writes a Claude Code plugin (the MCP server plus the Fleet Admiral skill) under ~/.cache/fleet/mcp and exports CLAUDE_CODE_PLUGIN_DIRS. Nothing is written to ~/.claude or ~/.claude.json. So it reaches a claude started from a bash shell opened after the setting was turned on (a new session, an automation agent); a command that clears the environment (sudo without -E, env -i) loses it. Claude Code is the only agent supported so far.
  • Devcontainer instances on Linux hosts only. Instances an agent creates are not cleaned up for it — it is told to fleet_down them when done.

Remote MCP

By default the MCP server is loopback-only. To let a remote agent drive your fleet, you can expose it through a fleet gateway — a small public relay you (or someone) runs. Your daemon dials out to the gateway (so it works behind NAT/firewalls) and the gateway routes inbound MCP requests back down that tunnel:

agent ──HTTPS──▶ fleet gateway ──reverse tunnel──▶ your fleetd ──▶ local MCP server

⚠️ Exposing fleet over the internet: the bearer token is the only thing gating access (and over gRPC it grants full daemon control), and the gateway operator can see your traffic — use a gateway you trust and keep the token secret. See The Fleet Gateway for the trust model.

Enable it (in the TUI)

In Settings → Fleet Remote (MCP):

  1. Set Gateway URL to the gateway's gRPC endpoint, e.g. https://gateway.example.com:50051 (this is where the daemon registers and dials out — the gateway's --grpc-addr, default port 50051; behind a proxy it's whatever host:port routes to that listener). It is not the public address agents use.
  2. Flip Enable Remote MCP on.

Once connected, the read-only Public MCP URL appears (e.g. https://gateway.example.com/mcp/<id>) — that is the address external tools use. The line shows the live connection state (connecting / connected / error).

A second, independent toggle — Enable Remote Fleet — exposes the daemon's gRPC control surface so a remote fleet binary can drive this instance (see Remote control). Turning it on reveals a Remote Fleet via selector with two transports:

  • Gateway (the default) — through the same gateway tunnel. With it, a read-only Public GRPC URL appears under the Public MCP URL (computed by the gateway from its --public-grpc-url flag) — that value is what you feed another fleet as FLEET_GATEWAY. With the toggle off, fleetd never negotiates the gRPC tunnel feature, so the gateway rejects any incoming gRPC commands aimed at the daemon.
  • SSH — no gateway at all. The daemon serves the same token-gated gRPC server on a loopback port (shown on an SSH Listener row, and recorded in ~/.fleet/ssh.port), and a remote machine reaches it over a plain SSH tunnel that its fleet daemon maintains — see Remote control over SSH. Nothing is exposed beyond what ssh user@host already grants.

The toggles are independent: expose MCP, remote control, or both. Only remote fleet has the SSH transport; MCP and webhooks stay gateway-only.

A third, independent toggle — Enable Webhook — exposes this daemon's automation webhook endpoint through the same gateway. With it on, a read-only Public Webhook URL base appears (e.g. https://gateway.example.com/webhook/<id>), served on the gateway's public HTTP listener — no extra gateway flag needed. A remote system (CI, a SaaS webhook, curl) POSTs an event to <public-webhook-url>/<name>, where <name> is a webhook trigger you defined; the gateway relays it down the tunnel and fleetd fires that trigger's agents if the event passes the trigger's filter (a regex over the body, or a JSON path/value match). The trigger's create/edit dialog renders its full URL ready to paste into the remote system. With the toggle off, fleetd never negotiates the webhook feature, so the gateway 404s the route for this daemon.

Use it from a remote agent

Point the remote MCP client at the Public MCP URL, with the same bearer token your daemon uses (from ~/.fleet/mcp.token):

{
  "mcpServers": {
    "fleet-remote": {
      "type": "http",
      "url": "https://gateway.example.com/mcp/<id>",
      "headers": { "Authorization": "Bearer <token from ~/.fleet/mcp.token>" }
    }
  }
}

Running a gateway

The gateway is the same binary: fleet gateway. TLS is optional: give it a publicly-trusted certificate (e.g. from Let's Encrypt) and it terminates TLS itself, or omit the cert and run it behind a TLS-terminating reverse proxy (see below).

fleet gateway \
  --public-url https://gateway.example.com \
  --tls-cert /etc/fleet/tls/fullchain.pem \
  --tls-key  /etc/fleet/tls/privkey.pem
FlagDefaultPurpose
--public-url(required)External base URL agents use; session URLs are <public-url>/mcp/<id>. Scheme (https/http) must match how the public endpoint is actually served
--public-grpc-url(optional)External base URL of the gRPC endpoint remote fleet clients dial, e.g. https://gateway.example.com:50051. Daemons that enable Remote Fleet are handed <public-grpc-url>/grpc/<id> as their Public GRPC URL (shown in the TUI, used as FLEET_GATEWAY). Unset = no URL is computed
--tls-cert / --tls-key(optional)TLS certificate + key (PEM). Provide both to serve HTTPS, or neither for plain HTTP behind a proxy. A lone cert or key is an error
--public-addr:443MCP + /healthz listener — HTTP/1.1 (HTTPS when a cert is set, else HTTP)
--grpc-addr:50051Native gRPC listener — HTTP/2 (h2c when cert-less, h2 under TLS). Hosts remote fleet control and fleetd registration. Empty disables both
--max-sessions1024Cap on concurrent tunnels
--session-key(random per boot)Secret key signing the session-resume tokens daemons present on reconnect. Set it (or FLEET_GATEWAY_SESSION_KEY) so daemons keep the same session URL across gateway restarts; left unset, a restart hands every daemon a fresh URL

Every flag is also settable via environment variable — FLEET_GATEWAY_<FLAG> with dashes as underscores (e.g. FLEET_GATEWAY_PUBLIC_URL, FLEET_GATEWAY_MAX_SESSIONS) — which is handy for configuring the Docker image in Kubernetes. A flag given on the command line wins over its environment variable.

Two ports must be reachable: expose --public-addr to MCP agents and --grpc-addr to remote fleet clients and your daemons (they register over it). A GET /healthz on the public listener returns ok.

There is no separate control port. fleetd registers and carries its reverse tunnel over a long-lived gRPC bidi stream on --grpc-addr (HTTP/2), so the whole gateway is plain HTTP/HTTP-2 — no raw TCP.

Behind a TLS-terminating reverse proxy

Both listeners are L7 — there's no raw-TCP port anymore, so a standard HTTP/gRPC ingress fronts the entire gateway:

  • --public-addr (MCP, HTTP/1.1) → a Traefik HTTP IngressRoute (TLS-terminating).
  • --grpc-addr (gRPC, HTTP/2) → a Traefik gRPC route (h2c backend) — see Traefik's gRPC guide. This one carries both remote fleet control RPCs and fleetd registration (the same long-lived bidi stream).

Run the gateway with no --tls-cert/--tls-key (plain HTTP/h2c) and let the proxy terminate TLS. For example:

# MCP — L7 HTTP, TLS terminated at Traefik:
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata: { name: fleet-gateway-mcp }
spec:
  entryPoints: [websecure]
  routes:
    - match: Host(`gateway.example.com`) && PathPrefix(`/mcp`)
      services: [{ name: fleet-gateway, port: 80 }]            # gateway --public-addr
  tls: { secretName: gateway-tls }
---
# gRPC (remote control + fleetd registration) — L7 gRPC, h2c to the backend:
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata: { name: fleet-gateway-grpc }
spec:
  entryPoints: [websecure]
  routes:
    - match: Host(`grpc.gateway.example.com`)
      services: [{ name: fleet-gateway, port: 50051, scheme: h2c }] # gateway --grpc-addr
  tls: { secretName: gateway-tls }

Set --public-url https://gateway.example.com; the daemon's Gateway URL is the gRPC endpoint, e.g. https://grpc.gateway.example.com (or https://gateway.example.com:50051 without a proxy). Clients (and daemons) verify TLS against the system roots at the proxy edge; the proxy speaks plain HTTP/h2c to the cert-less gateway, which can then bind unprivileged ports and needs no cert.

Run it with Docker

Every tagged release also publishes a gateway image to the GitHub Container Registry, so you don't have to build the binary yourself. It's the same fleet gateway, with the flags passed as docker run arguments (or as FLEET_GATEWAY_* environment variables — see the table above):

docker run -d --name fleet-gateway \
  -p 443:443 -p 50051:50051 \
  -v /etc/fleet/tls:/tls:ro \
  ghcr.io/benjaminbenetti/fleet-man/gateway:latest \
  --public-url https://gateway.example.com \
  --tls-cert /tls/fullchain.pem \
  --tls-key  /tls/privkey.pem

Image tags track releases: :latest (newest stable), :X.Y.Z, and :X.Y. Images are multi-arch (linux/amd64 + linux/arm64). To run behind a TLS-terminating proxy, drop the -v .../tls mount and the --tls-cert/--tls-key flags (and a --public-url http://… or proxy-fronted https://…); the container then serves plain HTTP.

Security model

  • No gateway authentication. Anyone can open a tunnel; the gateway just routes bytes. Isolation comes from the unguessable 256-bit id in the public URL.
  • The bearer token is the real access boundary. The gateway forwards the Authorization header untouched to your loopback MCP server, whose existing token check gates every request. Treat the Public MCP URL as a capability, but the token is the secret — share both only with agents you trust.
  • The id in the URL is not the reconnect credential: the daemon holds a separate secret (never placed in the URL), so a URL holder cannot hijack your tunnel.
  • Session-resume tokens are bearer reclaim capabilities. Each registration returns a JWT signed with --session-key; the daemon stores it (mode 0600, next to the secret) and presents it on reconnect, which is what keeps the URL stable across gateway restarts. Anyone holding the token can claim that session, so guard the signing key like any other server secret.
  • The public connection is TLS when the gateway holds a cert (the daemon verifies it against the system roots), or when a reverse proxy terminates TLS in front of it. Running the gateway itself on plain HTTP is only safe behind such a proxy (or on a trusted private network) — never expose a cert-less gateway directly.

Remote control (gRPC)

The same gateway tunnel can also carry the daemon's gRPC API, so a remote fleet client can drive your daemon directly — list/up/down, watch live status, stream logs, change config, and so on. It rides the same tunnel as remote MCP but has its own toggle: flip Enable Remote Fleet in Settings → Fleet MCP (independent of Enable Remote MCP, so you can expose MCP, remote control, or both). The gateway serves native gRPC on its dedicated --grpc-addr listener (default :50051); the client dials it as an ordinary gR

Source 3 files
hooks/register.tsx 108 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FleetStatusView } from '../types'
5import {
6  GAP,
7  STATUS_FILE,
8  TICK_MS,
9  fitBand,
10  isAdmiral,
11  newClocks,
12  parseStatus,
13  sameView,
14  toView,
15} from './view'
16
17// The fleet status mod: a band above the prompt naming the fleet instance this
18// Claude Code runs in, how many agents are working and idle across every fleet,
19// which ones just stopped, and whether the fleet MCP (Fleet Admiral) is here.
20// Fleet installs it into every instance (`fleet claude-mod-env`, from fleet.rc);
21// the daemon feeds it through STATUS_FILE.
22
23const view = atom({ plugin: 'fleet-status', key: 'view' } as const, null)
24
25async function readStatus($: EngineInterface) {
26  try {
27    return parseStatus(await $.fs.read(STATUS_FILE))
28  } catch {
29    return null // no file: the setting is off, or this instance has no control mount
30  }
31}
32
33async function hasAdmiral($: EngineInterface) {
34  try {
35    return isAdmiral(await $.tool.list())
36  } catch {
37    return false
38  }
39}
40
41export const register: Register = on => {
42  on('session.start', async ($, e, next) => {
43    const clocks = newClocks()
44    let last: FleetStatusView | null = null
45    let isReading = false
46
47    const tick = async () => {
48      if (isReading) return
49      isReading = true
50      try {
51        const status = await readStatus($)
52        const now = await $.clock.now()
53        const v = status === null ? null : toView(status, now, await hasAdmiral($), clocks)
54        if (!sameView(last, v)) {
55          last = v
56          await update($, view, () => v)
57        }
58      } finally {
59        isReading = false
60      }
61    }
62
63    void tick()
64    $.clock.every(TICK_MS, () => void tick())
65    return next(e)
66  })
67
68  // The band is one slot. A survey has it to itself; a band another plugin
69  // beneath draws is kept, stacked above this one; only the engine's own empty
70  // drawing is replaced.
71  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
72    const v = await read($, view)
73    if (v === null || e.props.hasSurvey) return next(e)
74
75    const { Box, Text } = $.ui.resolve(e)
76    const band = fitBand(v, e.props.bodyColumns)
77    const below = await next(e)
78
79    const row = (
80      <Box flexDirection="row" justifyContent="space-between" columnGap={GAP}>
81        <Box flexDirection="row" columnGap={GAP} flexShrink={1}>
82          {band.left.map(s => (
83            <Text key={s.key} color={s.color} bold={s.bold} dimColor={s.dim} wrap="truncate-end">
84              {s.text}
85            </Text>
86          ))}
87        </Box>
88        {band.right.length > 0 && (
89          <Box flexDirection="row" columnGap={GAP} flexShrink={0}>
90            {band.right.map(s => (
91              <Text key={s.key} color={s.color} bold={s.bold} dimColor={s.dim}>
92                {s.text}
93              </Text>
94            ))}
95          </Box>
96        )}
97      </Box>
98    )
99    if (below.type === 'engine') return row
100    return (
101      <Box flexDirection="column">
102        {below}
103        {row}
104      </Box>
105    )
106  })
107}
108
hooks/view.ts 208 lines
1import type { FleetStatusView } from '../types'
2
3// The status file the fleet daemon keeps in this instance's control directory
4// (bind-mounted at /fleet-mounts/control) while the "Fleet status mod" setting
5// is on. Absent — the setting off, or a backend with no control mount — the
6// mod draws nothing.
7export const STATUS_FILE = '/fleet-mounts/control/claude-status.json'
8
9// How often the file is read.
10export const TICK_MS = 2_000
11// How long a "stopped" alert stays up.
12export const ALERT_MS = 15_000
13// How long after the file last changed its counts are trusted. While the
14// counts are live the daemon rewrites it every few seconds.
15export const STALE_MS = 15_000
16
17// The file as the daemon writes it (agentstrategy.StatusModFile). `live` is
18// false while no fleet TUI is connected: agent activity is not polled then,
19// so the file only names the instance.
20export type Status = {
21  updated_at: number
22  live: boolean
23  fleet: string
24  instance: string
25  working: number
26  idle: number
27  stops: StatusStop[]
28}
29
30// An agent elsewhere that went from working to idle, at `at` (daemon clock).
31export type StatusStop = { fleet: string; instance: string; at: number }
32
33const isString = (v: unknown): v is string => typeof v === 'string'
34const isCount = (v: unknown): v is number =>
35  typeof v === 'number' && Number.isFinite(v) && v >= 0
36
37// parseStatus reads the file's text, or null when it is not a status.
38export function parseStatus(text: string): Status | null {
39  let raw: unknown
40  try {
41    raw = JSON.parse(text)
42  } catch {
43    return null
44  }
45  if (typeof raw !== 'object' || raw === null) return null
46  const s = raw as Record<string, unknown>
47  if (!isString(s.fleet) || !isString(s.instance) || !isCount(s.updated_at)) return null
48  if (!isCount(s.working) || !isCount(s.idle)) return null
49  const stops: StatusStop[] = []
50  for (const stop of Array.isArray(s.stops) ? s.stops : []) {
51    const t = stop as Record<string, unknown>
52    if (isString(t.fleet) && isString(t.instance) && isCount(t.at)) {
53      stops.push({ fleet: t.fleet, instance: t.instance, at: t.at })
54    }
55  }
56  return {
57    updated_at: s.updated_at,
58    live: s.live === true,
59    fleet: s.fleet,
60    instance: s.instance,
61    working: s.working,
62    idle: s.idle,
63    stops,
64  }
65}
66
67// Clocks is what toView remembers between reads. Every time it keeps is on the
68// mod's own clock: the daemon's (updated_at, at) is only ever compared with
69// itself, so a container clock that drifts from the host's changes nothing.
70export type Clocks = {
71  // updated_at of the last read (null before the first), and when (mod
72  // clock) it was last seen to change.
73  updatedAt: number | null
74  freshAt: number
75  // When each stop (by key) started, translated onto the mod clock.
76  stopSeen: Map<string, number>
77}
78
79// A session (or a reload of the mod) starts knowing nothing: until the file is
80// seen to change, its counts and stops may be what a daemon left behind long
81// ago — no TUI since, or a daemon that died — so they are not shown.
82export const newClocks = (): Clocks => ({ updatedAt: null, freshAt: -Infinity, stopSeen: new Map() })
83
84// toView turns a read of the file at `now` (mod clock) into what the band shows.
85export function toView(s: Status, now: number, admiral: boolean, clocks: Clocks): FleetStatusView {
86  const isFirstRead = clocks.updatedAt === null
87  if (s.updated_at !== clocks.updatedAt) {
88    // The first read proves nothing about freshness; only a change does.
89    if (!isFirstRead) clocks.freshAt = now
90    clocks.updatedAt = s.updated_at
91  }
92  const isStale = !s.live || now - clocks.freshAt > STALE_MS
93
94  const stopped: string[] = []
95  const live = new Set<string>()
96  for (const stop of s.stops) {
97    const name = `${stop.fleet}/${stop.instance}`
98    const key = `${name}@${stop.at}`
99    live.add(key)
100    let seen = clocks.stopSeen.get(key)
101    if (seen === undefined) {
102      // Its age by the daemon's clock, carried over to ours. One already in
103      // the file on the first read is of unknown age: taken as long gone.
104      seen = isFirstRead ? -Infinity : now - Math.max(0, s.updated_at - stop.at)
105      clocks.stopSeen.set(key, seen)
106    }
107    if (now - seen < ALERT_MS && !stopped.includes(name)) stopped.push(name)
108  }
109  for (const key of clocks.stopSeen.keys()) {
110    if (!live.has(key)) clocks.stopSeen.delete(key)
111  }
112
113  return {
114    label: `${s.fleet}/${s.instance}`,
115    working: isStale ? null : s.working,
116    idle: isStale ? null : s.idle,
117    stopped: isStale ? [] : stopped,
118    admiral,
119  }
120}
121
122// isAdmiral reports whether the session has a fleet MCP: a fleet MCP server's
123// tools include fleet_list, whatever the server is named (the instance's own,
124// or one the user registered). It follows the tools the session holds: a
125// session keeps its fleet MCP connection after the fleet's Fleet MCP setting is
126// turned off, so it keeps the indicator too.
127export function isAdmiral(tools: { name: string; mcp: boolean }[]): boolean {
128  return tools.some(t => t.mcp && t.name.endsWith('__fleet_list'))
129}
130
131export const sameView = (a: FleetStatusView | null, b: FleetStatusView | null): boolean =>
132  JSON.stringify(a) === JSON.stringify(b)
133
134// One run of text in the band.
135export type Segment = { key: string; text: string; color?: string; bold?: boolean; dim?: boolean }
136
137// The band laid out for a width: what is about this instance on the left (its
138// name, then the agents that just stopped), and what is fleet-wide pinned to
139// the right (the counts across every fleet, then admiral).
140export type Band = { left: Segment[]; right: Segment[] }
141
142// The cells between two segments.
143export const GAP = 2
144
145// Theme colors, so the band follows Claude Code's own theme. Idle is drawn
146// faint instead: the ANSI themes have no gray to give it.
147const WORKING = 'success'
148const STOPPED = 'warning'
149const ADMIRAL = 'ide'
150
151const width = (band: Band): number => {
152  const parts = [...band.left, ...band.right]
153  return parts.reduce((n, p) => n + [...p.text].length, 0) + GAP * Math.max(0, parts.length - 1)
154}
155
156// fitBand lays the view out in `columns` cells, shortening it step by step:
157// the full words first, then the compact forms, then dropping what matters
158// least. The instance's own name is never dropped — telling which Claude this
159// is is the band's first job.
160export function fitBand(v: FleetStatusView, columns: number): Band {
161  const name: Segment = { key: 'name', text: v.label, bold: true }
162  const hasCounts = v.working !== null && v.idle !== null
163  const stoppedList = (names: string[]) => names.join(', ')
164
165  const build = (o: {
166    compact: boolean
167    stops: 'all' | 'first' | 'none'
168    admiral: boolean
169    counts: boolean
170  }): Band => {
171    const left: Segment[] = [name]
172    const right: Segment[] = []
173    if (v.stopped.length > 0 && o.stops !== 'none') {
174      const shown = o.stops === 'all' ? v.stopped : v.stopped.slice(0, 1)
175      const more = v.stopped.length - shown.length
176      const text =
177        `⚑ ${stoppedList(shown)}` + (more > 0 ? ` +${more}` : '') + (o.compact ? '' : ' stopped')
178      left.push({ key: 'stopped', text, color: STOPPED })
179    }
180    if (hasCounts && o.counts) {
181      right.push(
182        o.compact
183          ? { key: 'working', text: `●${v.working}`, color: WORKING }
184          : { key: 'working', text: `● ${v.working} working`, color: WORKING },
185        o.compact
186          ? { key: 'idle', text: `○${v.idle}`, dim: true }
187          : { key: 'idle', text: `○ ${v.idle} idle`, dim: true },
188      )
189    }
190    if (v.admiral && o.admiral) {
191      right.push({ key: 'admiral', text: o.compact ? 'Admiral' : 'Admiral connected', color: ADMIRAL })
192    }
193    return { left, right }
194  }
195
196  const steps = [
197    build({ compact: false, stops: 'all', admiral: true, counts: true }),
198    build({ compact: true, stops: 'all', admiral: true, counts: true }),
199    build({ compact: true, stops: 'first', admiral: true, counts: true }),
200    build({ compact: true, stops: 'first', admiral: false, counts: true }),
201    build({ compact: true, stops: 'none', admiral: false, counts: true }),
202  ]
203  for (const band of steps) {
204    if (width(band) <= columns) return band
205  }
206  return build({ compact: true, stops: 'none', admiral: false, counts: false })
207}
208
types/index.d.ts 21 lines
1// The band the fleet status mod draws above the prompt, as its ui.render hook
2// reads it from $.state.
3export type FleetStatusView = {
4  // "<fleet>/<instance>" of the instance this Claude Code runs in.
5  label: string
6  // Agents working and idle (paused) across every fleet; null while they are
7  // not live (no fleet TUI connected, or a file not yet seen to change).
8  working: number | null
9  idle: number | null
10  // "<fleet>/<instance>" of each other agent that paused moments ago.
11  stopped: string[]
12  // A fleet MCP server is among this session's tools (Fleet Admiral mode).
13  admiral: boolean
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'fleet-status': { view: FleetStatusView | null }
19  }
20}
21