Read-only Mission Control fleet view: /mc pane (machines, heartbeat age, open claims, recent ships) and a fleet_status tool

Read-only Claude Code mod that shows the fleet from GET /api/mesh/fleet:
/mc opens a pane: one row per machine (host, status, heartbeat age), with stale, runner-down and offline rows in red; the open-claim count; the last five ships; and a red banner when the read is degraded or unknown. It reads on open and every 30 s while the pane is open (r refreshes; Esc closes and stops the timer).fleet_status tool (Claude sees mcp__mission-control__fleet_status, no input) returns the same summary as compact JSON text.A failed, refused (401), non-JSON or server-degraded read is shown as unknown/degraded with the reason. It is never an empty healthy fleet. Nothing is written anywhere.
Server: app/server/routes/mesh.py fleet() · snapshot: app/server/mesh_fleet.py · auth: app/server/mesh_fleet_auth.py · Mods docs: docs/reference/claude-mods/
| Variable | Value |
|---|---|
MC_LANE_URL | Pi-CEO backend base URL |
MC_LANE_SECRET | the X-Pi-CEO-Secret the fleet already uses (the fleet read accepts it) |
Unset URL or secret → /mc and the tool answer mission-control not configured: MC_LANE_URL / MC_LANE_SECRET unset and nothing is fetched.
claude plugin validate mods/mission-control
claude plugin test mods/mission-control
claude plugin marketplace add CleanExpo/Pi-Dev-Ops
claude plugin install mission-control@pi-dev-ops-mods
No version in plugin.json, so merged changes reach machines with auto-update on at the next session start.
MC_LANE_URL=... MC_LANE_SECRET=... claude --plugin-dir mods/mission-controlhooks/register.ts 147 lines1// mission-control — the fleet at a glance, inside Claude Code. Read-only.
2//
3// /mc opens a pane: one row per machine (host, status, heartbeat age; stale,
4// runner-down and offline in red), the open-claim count, the last five ships,
5// and a banner whenever the read is degraded or unknown. It refreshes on open
6// and every 30 s while open. The tool `fleet_status` (Claude sees it as
7// mcp__mission-control__fleet_status) returns the same summary as JSON text.
8//
9// Data: GET {MC_LANE_URL}/api/mesh/fleet with X-Pi-CEO-Secret (app/server/
10// routes/mesh.py fleet(); app/server/mesh_fleet_auth.py accepts the machine
11// secret). Nothing is written anywhere.
12//
13// Configuration (environment, read once per load), the same as mc-lane:
14// MC_LANE_URL Pi-CEO backend base URL
15// MC_LANE_SECRET the X-Pi-CEO-Secret the fleet already uses
16// With either unset, /mc and the tool say "not configured" and nothing is fetched.
17//
18// Functions that take `$` are top-level declarations: the engine scans the
19// module before loading it and refuses `$` handed to anything else.
20
21import type { EngineInterface, Register } from 'claude-code'
22import { formatAge, summarize, unknown, type FleetRead, type Summary } from './fleet'
23
24const PANE = 'mission-control'
25const TOOL = 'mcp__mission-control__fleet_status'
26const EVERY_MS = 30_000
27const NOT_CONFIGURED = 'mission-control not configured: MC_LANE_URL / MC_LANE_SECRET unset'
28
29type Config = { url: string; secret: string }
30type Timer = { cancel(): void }
31
32// Module state. A reload starts it over (and the engine stops the old timers).
33const st = {
34 cfg: null as Config | null,
35 summary: null as Summary | null,
36 timer: null as Timer | null,
37 inFlight: false,
38}
39
40async function readFleet($: EngineInterface, cfg: Config): Promise<FleetRead> {
41 try {
42 const r = await $.http.fetch(`${cfg.url}/api/mesh/fleet`, {
43 method: 'GET',
44 headers: { 'X-Pi-CEO-Secret': cfg.secret, Accept: 'application/json' },
45 })
46 return { status: r.status, text: r.text }
47 } catch (err) {
48 return { error: err instanceof Error ? err.message : String(err) }
49 }
50}
51
52async function fetchSummary($: EngineInterface): Promise<Summary> {
53 if (!st.cfg) return unknown(NOT_CONFIGURED)
54 const read = await readFleet($, st.cfg)
55 return summarize(read, await $.clock.now())
56}
57
58// One read at a time for the pane; a tick that lands while one is out is skipped.
59async function refresh($: EngineInterface): Promise<void> {
60 if (st.inFlight) return
61 st.inFlight = true
62 try {
63 st.summary = await fetchSummary($)
64 } finally {
65 st.inFlight = false
66 }
67 $.ui.invalidate('ui.render')
68}
69
70function stopTimer(): void {
71 st.timer?.cancel()
72 st.timer = null
73}
74
75export const register: Register = on => {
76 on('session.start', async ($, e, next) => {
77 const url = (await $.env.get('MC_LANE_URL'))?.replace(/\/+$/, '')
78 const secret = await $.env.get('MC_LANE_SECRET')
79 st.cfg = url && secret ? { url, secret } : null
80 // Registered either way, so an unconfigured machine is told why rather than shown nothing.
81 try {
82 await $.tool.register({
83 name: 'fleet_status',
84 description:
85 'Read-only Mission Control fleet summary: each machine (host, status, heartbeat age, stale), ' +
86 'open claim count, last 5 ships, and whether the read is degraded or unknown (with the reason).',
87 inputSchema: { type: 'object', properties: {} },
88 })
89 await $.command.register({ name: 'mc', description: 'Open the Mission Control fleet pane' })
90 } catch (err) {
91 $.ui.log(`mission-control: ${err instanceof Error ? err.message : String(err)}`)
92 }
93 return next(e)
94 })
95
96 on('command.run', { command: 'mc' }, async $ => {
97 if (!st.cfg) return { text: NOT_CONFIGURED }
98 await $.ui.open({ id: PANE, title: 'Mission Control', closeOnEscape: true })
99 stopTimer()
100 st.timer = $.clock.every(EVERY_MS, () => refresh($))
101 await refresh($)
102 return {}
103 })
104
105 on('ui.close', async ($, e, next) => {
106 if (e.id === PANE) stopTimer()
107 return next(e)
108 })
109
110 on('tool.call', { tool: TOOL }, async $ => {
111 const sum = await fetchSummary($)
112 return { result: JSON.stringify(sum) }
113 })
114
115 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
116 if (e.requestId !== PANE) return next(e)
117 const { Box, Text, Button } = $.ui.resolve(e)
118 const sum = st.summary
119 const rows: unknown[] = []
120
121 if (!sum) {
122 rows.push(Text({ dimColor: true, children: ['Reading the fleet…'] }))
123 } else {
124 if (sum.state !== 'ok') {
125 const label = sum.state === 'unknown' ? 'FLEET UNKNOWN' : 'FLEET DEGRADED'
126 rows.push(Text({ color: 'red', bold: true, wrap: 'wrap', children: [`${label}: ${sum.reason ?? ''}`] }))
127 }
128 if (sum.state !== 'unknown') {
129 rows.push(Text({ bold: true, children: [`Machines (${sum.machines.length})`] }))
130 if (sum.machines.length === 0) rows.push(Text({ dimColor: true, children: ['no machines have joined'] }))
131 for (const m of sum.machines) {
132 const line = `${m.host} ${m.status} ${formatAge(m.ageS)}${m.stale ? ' stale' : ''}`
133 rows.push(Box({ key: `machine-${m.host}`, children: [Text(m.alert ? { color: 'red', children: [line] } : { children: [line] })] }))
134 }
135 rows.push(Text({ children: [`Open claims: ${sum.openClaims ?? 'unknown'}`] }))
136 rows.push(Text({ bold: true, children: ['Recent ships'] }))
137 if (sum.recentShips.length === 0) rows.push(Text({ dimColor: true, children: ['none'] }))
138 for (const s of sum.recentShips) {
139 rows.push(Text({ wrap: 'truncate-end', children: [`${formatAge(s.ageS)} ${s.machine} ${s.repo} ${s.subject}`] }))
140 }
141 }
142 }
143 rows.push(Button({ key: 'refresh', label: 'Refresh', hotkey: 'r', plain: true, onPress: () => refresh($) }))
144 return Box({ flexDirection: 'column', children: rows })
145 })
146}
147hooks/fleet.ts 148 lines1// Pure helpers for mission-control: no `$`, so they unit-test without the kit.
2//
3// THE ONE RULE. A read that failed — transport error, 401, an HTML error page,
4// JSON that is not the snapshot, or a snapshot the server itself marks
5// `degraded` — must never come back looking like a healthy fleet. An empty
6// machine list with no banner means "nobody has joined", so a broken read is
7// `state: 'unknown'` (or `'degraded'`) with the reason, never `machines: []`
8// on its own. Server side: app/server/mesh_fleet.py snapshot().
9
10/** What one GET /api/mesh/fleet produced: an HTTP answer, or a transport error. */
11export type FleetRead = { status: number; text: string } | { error: string }
12
13export type MachineRow = {
14 host: string
15 status: string
16 /** Seconds since the last heartbeat, or null when last_seen is missing or unparseable. */
17 ageS: number | null
18 /** The view's is_stale (no heartbeat in 60 s), an age over 60 s, or no age at all. */
19 stale: boolean
20 /** Drawn red: stale, runner-down or offline. */
21 alert: boolean
22 activeAgents: number | null
23}
24
25export type ShipRow = {
26 machine: string
27 repo: string
28 subject: string
29 sha: string
30 ageS: number | null
31}
32
33export type Summary = {
34 /** ok: a full, trusted snapshot. degraded: the server says a source failed. unknown: no usable snapshot. */
35 state: 'ok' | 'degraded' | 'unknown'
36 /** Why the state is not ok. */
37 reason?: string
38 machines: MachineRow[]
39 /** Open (claimed or working) claims, or null when unknown. */
40 openClaims: number | null
41 recentShips: ShipRow[]
42 degraded: boolean
43 errors: string[]
44}
45
46export const RECENT_SHIPS = 5
47export const STALE_AFTER_S = 60
48const DOWN = new Set(['runner-down', 'offline'])
49
50const str = (v: unknown, max = 80): string =>
51 typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : ''
52
53function ageSeconds(iso: unknown, nowMs: number): number | null {
54 if (typeof iso !== 'string') return null
55 const t = Date.parse(iso)
56 if (Number.isNaN(t)) return null
57 return Math.max(0, Math.round((nowMs - t) / 1000))
58}
59
60/** A read that produced no usable snapshot. Never an empty healthy fleet. */
61export function unknown(reason: string): Summary {
62 return { state: 'unknown', reason, machines: [], openClaims: null, recentShips: [], degraded: true, errors: [reason] }
63}
64
65function machineRow(r: Record<string, unknown>, nowMs: number): MachineRow {
66 const status = str(r.status, 32) || 'unknown'
67 const ageS = ageSeconds(r.last_seen, nowMs)
68 const stale = r.is_stale === true || ageS === null || ageS > STALE_AFTER_S
69 return {
70 host: str(r.host, 64) || '?',
71 status,
72 ageS,
73 stale,
74 alert: stale || DOWN.has(status),
75 activeAgents: typeof r.active_agents === 'number' ? r.active_agents : null,
76 }
77}
78
79function shipRow(r: Record<string, unknown>, nowMs: number): ShipRow {
80 return {
81 machine: str(r.machine, 64),
82 repo: str(r.repo, 64),
83 subject: str(r.subject, 72),
84 sha: str(r.sha, 7),
85 ageS: ageSeconds(r.shipped_at, nowMs),
86 }
87}
88
89function errorText(e: unknown): string {
90 if (e && typeof e === 'object') {
91 const o = e as Record<string, unknown>
92 const parts = [str(o.source, 32), str(o.reason, 32), typeof o.status === 'number' ? String(o.status) : '']
93 return parts.filter(Boolean).join(' ') || 'error'
94 }
95 return str(e, 64) || 'error'
96}
97
98const rowsOf = (v: unknown): Record<string, unknown>[] | null =>
99 Array.isArray(v) ? v.filter((x): x is Record<string, unknown> => !!x && typeof x === 'object') : null
100
101/** Turns one fleet read into what the pane and the tool show. */
102export function summarize(read: FleetRead, nowMs: number): Summary {
103 if ('error' in read) return unknown(`request failed: ${str(read.error, 80) || 'error'}`)
104 if (read.status === 401 || read.status === 403) return unknown(`HTTP ${read.status}: secret refused`)
105 if (read.status < 200 || read.status >= 300) return unknown(`HTTP ${read.status}`)
106
107 let data: unknown
108 try {
109 data = JSON.parse(read.text)
110 } catch {
111 return unknown('response is not JSON')
112 }
113 if (!data || typeof data !== 'object' || Array.isArray(data)) return unknown('response is not a fleet snapshot')
114 const d = data as Record<string, unknown>
115 const machines = rowsOf(d.machines)
116 const claims = rowsOf(d.claims)
117 const ships = rowsOf(d.ships)
118 if (!machines || !claims || !ships || typeof d.degraded !== 'boolean') {
119 return unknown('response is not a fleet snapshot')
120 }
121
122 const errors = Array.isArray(d.errors) ? d.errors.map(errorText) : []
123 const degraded = d.degraded || errors.length > 0
124 const sum: Summary = {
125 state: degraded ? 'degraded' : 'ok',
126 machines: machines.map(r => machineRow(r, nowMs)),
127 openClaims: claims.length,
128 recentShips: ships.slice(0, RECENT_SHIPS).map(r => shipRow(r, nowMs)),
129 degraded,
130 errors,
131 }
132 if (degraded) {
133 sum.reason = errors.length ? `server degraded: ${errors.join(', ')}` : 'server degraded'
134 // A failed source arrives as an empty list; that empty list is not a count of zero.
135 if (errors.some(e => e.startsWith('claims'))) sum.openClaims = null
136 }
137 return sum
138}
139
140/** "12s", "4m", "3h", "2d", or "?": a heartbeat age short enough for one row. */
141export function formatAge(s: number | null): string {
142 if (s === null) return '?'
143 if (s < 120) return `${s}s`
144 if (s < 7200) return `${Math.round(s / 60)}m`
145 if (s < 172_800) return `${Math.round(s / 3600)}h`
146 return `${Math.round(s / 86_400)}d`
147}
148