apra-fleet at a glance: a live status line entry and a /fleet-status pane of every fleet member.

<img src="assets/marketing/hero-banner-light.svg" alt="apra-fleet: run a fleet of AI agents across your devices, your providers, your workflows." width="100%">
Run a fleet of AI agents across your devices, your providers, your workflows.
What Kubernetes did for containers, apra-fleet does for AI agents: scheduling, credentials, isolation, and observability for an agentic workforce -- on any machine, anywhere, using every LLM provider at once.
Quick Start - Live Demo - How It Works - fleet-sprint Getting Started Guide - Website
<img src="assets/marketing/dashboard-demo.gif" alt="apra-fleet fleet-sprint dashboard, real recording: the sprint's own integration tester finds two real bugs, files them against itself, then a second cycle plans, fixes, and closes them -- captions burned in." width="100%">
This repository is built by the product you are looking at. An autonomous apra-fleet workflow plans, codes, reviews, tests, and ships this codebase in multi-hour sprints -- filing bugs against itself and fixing them. The recording above is a real run, not a mockup.
Running one AI agent is a demo. Running fifty -- across a MacBook in the office, a GPU box in the lab, three cloud VMs, and your CI -- is an operations problem nobody else has solved:
One control plane. Any device. Any model. Any workflow. Any domain.
| Pillar | Concretely |
|---|---|
| Any device | Register any Windows / macOS / Linux machine (local or over SSH) as a fleet member in one command. Cloud members auto-start on demand. Windows members are fully supported for dispatch, command execution, and background long-running tasks (launched detached via WMI). |
| Any model | Claude, Codex, Copilot, Antigravity, local models (any OpenAI-compatible endpoint via OpenCode) -- mixed freely. Tier-based routing (cheap / standard / premium) keeps cost governance built in. Cross-provider review is a quality mechanism: a different model, with different blind spots, checks every change. |
| Any workflow | Workflows are durable programs, not prompt chains: multi-hour, resumable, observable, with member reservations and atomic state. Write your own; ship it to the fleet. |
| Any domain | Not just software development. The pattern fits wherever work decomposes into agent-sized pieces that need orchestration and an audit trail: nightly retail replenishment (reconcile inventory deltas, draft purchase orders for sign-off), logistics exception handling (triage a delayed shipment, re-book, notify), healthcare intake (summarize referrals, check completeness, route), back-office runs (invoice matching, compliance evidence collection). Software engineering is the vertical running today -- your domain is a workflow away. |
<img src="assets/marketing/fleet-topology-light.svg" alt="apra-fleet topology: one control plane dispatching to six heterogeneous member devices across providers and operating systems" width="100%">
Our flagship workflow, fleet-sprint, develops software autonomously: plan -> develop -> review -> deploy -> integration-test -> harvest, in cycles, until the goal is met or the evidence says stop.
It is not a toy. It builds apra-fleet itself:
A fleet that has run in production:
pm-1 Opus (premium) orchestrator
doer-1 Sonnet (standard) feature work
doer-2 Antigravity large-context tasks
reviewer Opus (premium) final review
The engine does not know what a "sprint" is; it knows how to run your workflow reliably across your fleet (see Any domain above).
1. Install -- one command via npm (Node.js 22.16+), or grab the standalone installer binary for your platform from Releases and double-click it (installation is the default action):
npm install -g @apralabs/apra-fleet
apra-fleet install # installs for Claude Code (default)
apra-fleet install --llm agy # or --llm opencode / codex / copilot
apra-fleet start # npm install registers no OS service: start the server (again after reboot)
The standalone installer instead registers and starts a user-level service (no start needed) and does not add anything to PATH: the binary is ~/.apra-fleet/bin/apra-fleet (Windows PowerShell: & "$HOME\.apra-fleet\bin\apra-fleet.exe" status). See docs/install.md.
The default install includes fleet-se (fleet-sprint, supervisor, bd), which requires a system Node.js 22.16+ and npm on PATH; the installer fails up front if they are missing. Use apra-fleet install --workflows none to install the core only.
For Claude Code, install also adds the fleet-status mod -- a Claude Code plugin that shows every member's state in the status line and opens a /fleet-status pane where you can also remove a member and list, add, replace or delete secrets (a secret's value is typed in another terminal or the server's one-time form, never in Claude Code). It is installed through the claude CLI from a local marketplace in ~/.apra-fleet/claude-plugins/ and replaces the old statusLine script. If the claude CLI is missing or too old to install plugins, install says why and keeps the statusLine script instead. A statusLine of your own is never overwritten. See docs/install.md.
2. Connect your agent. Load the fleet server in Claude Code with /mcp (or restart your provider CLI). Your agent now has a fleet. To check from a shell, claude mcp list should show a line like apra-fleet: http://localhost:7523/mcp (HTTP) - ... Connected (7523 is the default port).
3. Register members -- in plain language. apra-fleet is driven conversationally through any MCP-capable agent:
"Register a local member called
doer. Register another calledreviewer. Pair them."
"Register 192.168.1.10 as
build-server. Username akhil, work folder/home/akhil/projects/myapp."
Remote passwords are collected out-of-band -- typed into a separate terminal, never the chat -- used once to set up SSH keys, then forgotten.
4. Run your first workflow:
apra-fleet workflow hello-world
Then point the fleet at real work:
apra-fleet workflow fleet-sprint \
--issue my-project-epic --members doer \
--branch fleet-sprint/first-run --base main
Open the dashboard, watch your fleet PLAN->BUILD->REVIEW->TEST->SHIP in a loop till closure
New to fleet-sprint? Read the fleet-sprint Getting Started Guide (Markdown if you're reading this on GitHub) -- a plain-English walkthrough of what it does, what you need to prepare (beads backlog,
deploy.md, test playbooks, member registration), how to launch and monitor a sprint, and what's automated versus what's still your call.
Running fleet-sprint after npm install: the apra-fleet workflow fleet-sprint ... command above is the same one command for everyone -- whether you installed via npm install -g @apralabs/apra-fleet, the standalone binary, or a git-clone dev checkout. There is no separate fleet-sprint command to install or remember. (Standalone installer: the binary is not on PATH, so use ~/.apra-fleet/bin/apra-fleet or add ~/.apra-fleet/bin to PATH -- see step 1.) See the full flag reference for every option.
<img src="assets/marketing/fleet-stack-light.svg" alt="apra-fleet layered architecture stack: dependencies from OS primitives up to autonomous engineering orchestrators" width="100%">
Component Docs: fleet-sprint | auto-sprint.js | apra-fleet-client | apra-pm | apra-fleet-mcp | Agent Roles | Sprint reliability gates
flowchart LR
CP["Control Plane<br/>(Server, Engine, Supervisor)"] -->|Dispatch & Sync| M1["MacBook<br/>(Claude)"] & M2["Linux GPU<br/>(vLLM)"] & M3["Cloud VM<br/>(AGY)"] & M4["Windows<br/>(OpenCode)"]
execute_prompt supports session forking (fork) on fork-capable providers -- branch a new, independent session from an existing one's context (e.g. a primed session reused across per-task dispatches) without continuing to write into the source session. See docs/mcp-tools.md for the parameter contract.Every agent session starts by calling kb_session_prime. The KB checks which files have changed since last read and returns exactly those. Unchanged files are served from cached summaries -- no re-read, no wasted tokens.
Cold session: kb_session_prime returns stale_files=[a.ts, b.ts, c.ts]
Agent reads all three, calls kb_capture for each.
Warm session: kb_session_prime returns stale_files=[], session_warm=true
Agent works from KB summaries. Zero file reads.
MCP tools that ship with the KB:
| Tool | What it does |
|---|---|
kb_session_prime | Prime a session: stale files, fresh summaries, GitNexus call list |
kb_capture | Store a learning, context-cache, runbook, or knowledge entry |
kb_query | Two-level FTS retrieval (L1: title+summary, L2: full content) |
kb_list | Audit-list entries by confidence/type/module/symbol (read-only, no use_count bump) |
kb_context | Batch file freshness check (single git call for N files) |
kb_invalidate | Mark files stale immediately (also called by the git hook), or discard own entries by ids |
kb_promote | Advance confidence: UNVERIFIED -> INFERRED -> CONFIRMED (FULL session, or the sprint's kb_maintainer member session only) |
kb_harvest | Extract learnings from a session transcript (auto-fires after execute_prompt) |
kb_export | Additively merge live CONFIRMED entries whose cited files still match their recorded hashes into .fleet/kb-canonical.json -- the git-shareable team bible (FULL session only) |
kb_bible_commit | Merge confirmed entry ids into the bible (format v3, per-entry file hashes), remove superseded or invalidated entries, and commit locally with base-branch provenance (used by the sprint kb_maintainer) |
kb_setup | Install git hook, write provider config, store remote token encrypted (FULL session only) |
code_* tools resolve the calling session's own folder, refuse with typed E-CODE-INDEX-NOT-READY / E-CODE-INTEL-DISABLED errors when the index is not usable, and report indexedCommit on every result; code_reindex and code_status drive and inspect the index (see docs/code-index-readiness.md). Registering a remote member also installs a member-mode apra-fleet on it and wires a per-folder MCP entry, recorded as fleetMcp (see docs/member-fleet-mcp-wiring.md). session_stats reports each member's kb_* / code_* call counts, which fleet-sprint shows per dispatch in its Knowledge & Code Intel viewer tab.
kb_setup --remote <url> --token <key> takes effect immediately: the next KB tool call resolves its project provider from this config, so a stock build points at a remote KB server by configuration alone, with no code change and no separate "server mode" build. A config with no remote, or any config the reader cannot parse, always falls back to the local SQLite provider -- see Client-side provider selection for the exact selection rule and its fallback-construction invariant.
The provider config is install-wide, not per repo. There is one knowledge/config.json per fleet install, so pointing it at a remote KB points EVERY repo that install serves -- every member, every project -- at that server. kb_setup only places the git post-commit hook (in the calling session's own repo); it does not scope the config. On a shared fleet server, treat kb_setup --remote as a change for all of its users.
Every KB tool call is scoped to the calling session's own repo -- a fleet server handling many members across many repos never lets one repo's learnings land in another repo's KB. No kb_* tool takes a scope argument: a member session uses its registered work folder and any other session uses the fleet server's working folder, and a folder that is not a git repository with an origin remote is refused with a typed error that names the fix: E-SELF-NO-WORKFOLDER (create the folder or re-register the member), E-SELF-NOT-A-REPO (git init or clone there) or E-SELF-NO-REMOTE (git remote add origin <url>; for a remote member, record its origin with update_member git_repos). Member sessions get a reduced, explicit tool list: the code_* tools, a few self-reporting tools and the kb_* tools except kb_setup and kb_export (never served to a member) and kb_promote / kb_resolve_contradiction (served only to the sprint engine's kb_maintainer session, which applies the reviewer's promotions). The apra-fleet call verb lets a process call tools as a member. KB reads default to CONFIRMED, undisputed entries. See Per-repo KB isolation for the resolution, anchor and cache-keying rules, and Member-session tool calls for the member tool model.
The backend is swappable: start with local SQLite, add a central HTTP server for a team, or plug in Postgres later -- all via a one-line config change.
See docs/knowledge-layer.md for the full guide.
There are two ways to orchestrate agents, and apra-fleet is built on the observation that you need both -- at different stages of a workflow's life:
execute_command -- zero tokens. The model is invoked only at the corners that genuinely require judgment (execute_prompt): review this diff, plan this backlog, decide this exception.<img src="assets/marketing/cost-collapse-light.svg" alt="Cost per apra-fleet e2e run: four real LLM-driven runs ranging $0.46-$3.05, then ~$0.00 / run forever after switching to a deterministic workflow." width="100%">
That is not a projection -- it is this repository's own e2e setup+teardown step, before and after we hardened it. Development tokens are not operating tokens: pay once to discover the workflow, then run it free.
| LLM-orchestrated (explore) | Workflow-orchestrated (operate) | |
|---|---|---|
| Control flow | the model decides each step (tokens) | deterministic program (free) |
| Shell / git / file steps | narrated through the model | execute_command, zero tokens |
| Where the model runs | everywhere | judgment nodes only (execute_prompt) |
| Cost curve | scales with every step | scales with thinking only |
| Failure mode | drift and silent retries | typed errors, resumable state |
The collapse is two-dimensional. As a workflow hardens, control flow moves from model to program -- and the judgment nodes that remain move from frontier models to cheaper ones, because a well-specified task no longer needs discovery-grade reasoning. Develop a workflow with Claude; operationalize it on OpenCode against a local or OpenRouter model. Same fleet, same workflow -- swap the members. Tier routing makes it a registration change, not a rewrite.
Only a fleet makes that trade possible. Single-provider tools cannot leave their vendor; in-process frameworks cannot move orchestration out of the token path. Because apra-fleet's unit of execution is the member -- a machine plus a provider, swappable at registration -- the same hardened workflow runs on frontier models the day you design it and on commodity models every day after.
fleet-sprint is this principle, lived: it began as LLM-orchestrated exploration; each discovered pattern was hardened into the deterministic engine; today the engine drives hour-long autonomous runs in which models are consulted only as planner, doer, reviewer, tester, and harvester.
| Tool | Overlap | Where apra-fleet differs |
|---|---|---|
| Single-agent coding assistants | AI writes code | A fleet adds agents that review, test, and deploy each other's work -- across vendors. |
| CI self-hosted runners | Runs work on other machines | Conversational and stateful, not pipeline-triggered; agents carry context between phases. |
| SkyPilot / dstack | Multi-machine compute | Coordinates agents and their context, credentials, and permissions -- not just jobs. |
| Google A2A | Agent-to-agent messaging | An opinionated orchestration and operations layer, not just a transport. |
| Agent frameworks (LangGraph, CrewAI, ...) | Multi-agent logic | Those compose agents inside one process; apra-fleet operates agents across real machines, providers, and days-long workflows. |
When NOT to use it: a one-off single-file change needs no fleet.
Secrets are entered out-of-band into a credential store and referenced as {{secret.NAME}} -- resolved server-side at execution, never visible to any LLM or log; see docs/secret-variables.md. Credentials scope to members, expire on TTL, and can carry a network egress policy (allow / deny / confirm). Every member runs with composed, provider-native permission files -- allow-listed tools, not god-mode. VCS access is provisioned and revocable per member, across GitHub, Bitbucket, and Azure DevOps -- host differences (URL shape, PR REST dialect, auth pattern, error vocabulary) are hidden behind a per-provider descriptor rather than leaking into shared code; see docs/design-azure-devops-vcs-auth.md for the Azure DevOps provider's credential-assembly and PAT-lifetime details. A credential-requiring VCS command can be handed to the server for execution (vcs_credential_exec) rather than the orchestrator learning the plaintext token itself: the server substitutes the credential into the command, runs it on the member, and redacts the token from every field of the result -- the plaintext never transits an orchestrator-readable output. Permission composition verifies its own delivery: a grant is read back off the target member and structurally compared against what was intended before it is reported as applied, so a failed or partial write is surfaced as an explicit failure rather than a false success. Dropped or unmappable permission tokens are surfaced explicitly in composition warnings, and shell pipeline stages are decomposed into constituent command grants to provide precise permission suggestions across providers.
The fleet send_email tool sends email via SendGrid or SMTP. Secrets are stored in the fleet credential store. Non-secret config (provider, host, port, from address) is passed by the workflow in each call.
Store email secrets via the CLI:
# SendGrid API key
apra-fleet secret --set sendgrid_api_key --persist
# SMTP password
apra-fleet secret --set smtp_password --persist
Or via the MCP tool (the path an LLM agent uses):
{ "name": "sendgrid_api_key", "prompt": "Enter your SendGrid API key", "persist": true }
Secrets are encrypted in the fleet credential store. They never appear in workflow code, config files, or environment variables.
The workflow passes non-secret config inline and calls send_email. Load your config however you prefer (JSON file, hardcoded, etc.):
import { parseToolJson } from '@apralabs/apra-fleet-client';
import {hooks/register.tsx 907 lines1// fleet-status: apra-fleet at a glance inside Claude Code.
2//
3// - A status line entry, kept fresh: busy and stalled members by name, the
4// rest counted ("<busy> alice 02:14 <idle> 3 <offline> 1").
5// - /fleet-status opens a pane listing every member by category, each with
6// a Remove control (remove_member after a confirm press; never forced, so
7// the server still refuses a busy or held member).
8// - A Secrets section in the pane: list, add or replace, and delete the
9// secrets in apra-fleet's credential store.
10//
11// Data comes from apra-fleet's MCP tools, called over this session's own
12// connection to the apra-fleet server (so it carries whatever credentials
13// that connection has): fleet_snapshot and credential_store_list, polled
14// every 30s and re-read after any other apra-fleet tool call.
15//
16// A secret's VALUE never passes through this mod, Claude Code or the model.
17// Adding one asks the server (credential_store_set with return_url) for a
18// one-time link to its own entry form, where the value is typed into a
19// masked field and goes from the browser straight to the server; or the
20// person runs `apra-fleet secret --set` in another terminal, which prompts
21// with input hidden. The mod only ever sends names and settings, and only
22// ever holds the link. Claude Code's own Input is never used for a value:
23// it has no masking and other plugins' ui.input hooks would see the text.
24// Claude Code 2.1.300 puts the call through the permission check like any
25// tool call (observed: a prompt per poll without a rule), so it relies on the
26// mcp__apra-fleet__* allow rule `apra-fleet install --llm claude` writes. A
27// plugin's own hooks never answer for its own calls (the engine skips
28// re-entry), so the mod cannot approve itself.
29//
30// Source is ASCII only: glyphs are written as \u escapes.
31
32import { atom, read, update } from 'claude-code'
33import type { EngineInterface, Register, RenderChildren, UiPressArgument } from 'claude-code'
34
35import type { FleetMember, FleetMemberStatus, FleetView, MemberAction, PaneLayout, SecretForm, SecretRow, SecretsView } from '../types'
36
37const SERVER = 'apra-fleet'
38const TOOL_PREFIX = 'mcp__apra-fleet__'
39const SNAPSHOT_TOOL = 'fleet_snapshot'
40const PANE = 'fleet-status'
41const POLL_MS = 30_000
42const BUSY_RECHECK_MS = 2_000
43const STATUS_NAMES_MAX = 3
44const CONNECTING = 'connecting...'
45const UNREACHABLE = 'server not reachable'
46const RETRY_MS = 3_000
47// While a read fails this soon after session start the server is presumed
48// to be starting: retried every RETRY_MS, shown as CONNECTING.
49const STARTUP_MS = 60_000
50
51const LIST_TOOL = 'credential_store_list'
52const SET_TOOL = 'credential_store_set'
53const UPDATE_TOOL = 'credential_store_update'
54const DELETE_TOOL = 'credential_store_delete'
55const REMOVE_TOOL = 'remove_member'
56const SECRET_NAME = /^[a-zA-Z0-9_-]{1,64}$/
57const KEY = '\uD83D\uDD11'
58
59const view = atom({ plugin: 'fleet-status', key: 'view' } as const, { members: [], fetchedAt: 0 } as FleetView)
60const secrets = atom({ plugin: 'fleet-status', key: 'secrets' } as const, { items: [], fetchedAt: 0 } as SecretsView)
61const CLOSED_FORM: SecretForm = { mode: 'closed', name: '', members: '', keep: 'persistent', ttl: 'none' }
62const secretForm = atom({ plugin: 'fleet-status', key: 'secretForm' } as const, CLOSED_FORM)
63// Members open, secrets folded; the less-used fields behind "more". Kept
64// across sessions in $.store.
65export const DEFAULT_LAYOUT: PaneLayout = { membersOpen: true, secretsOpen: false, moreMembers: false, moreSecrets: false, moreOptions: false }
66const LAYOUT_KEY = 'layout'
67const layout = atom({ plugin: 'fleet-status', key: 'layout' } as const, DEFAULT_LAYOUT)
68const memberAction = atom({ plugin: 'fleet-status', key: 'memberAction' } as const, {} as MemberAction)
69
70const GLYPH: Record<FleetMemberStatus, string> = {
71 busy: '\u26A1',
72 idle: '\uD83D\uDCA4',
73 offline: '\u274C',
74 unknown: '\u2753',
75}
76const COLOR: Record<FleetMemberStatus, string> = {
77 busy: 'warning',
78 idle: 'success',
79 offline: 'error',
80 unknown: 'permission',
81}
82// Needs-attention first.
83const PRIORITY: Record<FleetMemberStatus, number> = { unknown: 0, busy: 1, idle: 2, offline: 3 }
84const DOT = '\u00B7'
85const RULE = '\u2500'
86// One-glyph remove/delete control: a narrow x, one column wide on every
87// terminal (the wastebasket emoji's width varies and would break columns).
88const CROSS = '\u2715'
89const OPEN = '\u25BE'
90const CLOSED = '\u25B8'
91const MORE = '\u2026'
92
93/** 134 -> "02:14", 3725 -> "1:02:05". */
94export function clock(s: number): string {
95 const two = (n: number) => String(n).padStart(2, '0')
96 const h = Math.floor(s / 3600)
97 const m = Math.floor((s % 3600) / 60)
98 const sec = s % 60
99 return h > 0 ? `${h}:${two(m)}:${two(sec)}` : `${two(m)}:${two(sec)}`
100}
101
102function asStatus(raw: unknown): FleetMemberStatus {
103 return raw === 'busy' || raw === 'idle' || raw === 'offline' ? raw : 'unknown'
104}
105
106/** The fleet_snapshot result text -> members, or throws with a short reason. */
107export function parseSnapshot(texts: string[]): { version?: string; members: FleetMember[] } {
108 for (const text of texts) {
109 let data: unknown
110 try {
111 data = JSON.parse(text)
112 } catch {
113 continue
114 }
115 const d = data as { version?: unknown; members?: unknown }
116 if (!d || !Array.isArray(d.members)) continue
117 const members = d.members.map((raw): FleetMember => {
118 const m = raw as Record<string, unknown>
119 const member: FleetMember = {
120 id: String(m.id ?? ''),
121 name: String(m.name ?? m.id ?? '?'),
122 icon: typeof m.icon === 'string' ? m.icon : '',
123 category: typeof m.category === 'string' && m.category ? m.category : null,
124 status: asStatus(m.status),
125 }
126 if (typeof m.quietForS === 'number' && Number.isFinite(m.quietForS) && m.quietForS >= 0) member.quietForS = Math.round(m.quietForS)
127 if (typeof m.reservedBy === 'string' && m.reservedBy) member.reservedBy = m.reservedBy
128 return member
129 })
130 return { version: typeof d.version === 'string' ? d.version : undefined, members }
131 }
132 throw new Error('unexpected fleet_snapshot reply')
133}
134
135function byAttention(a: FleetMember, b: FleetMember): number {
136 return PRIORITY[a.status] - PRIORITY[b.status] || a.name.localeCompare(b.name)
137}
138
139/** The one-line status entry for a view. */
140export function statusLine(v: FleetView): string | undefined {
141 if (v.fetchedAt === 0) return undefined
142 if (v.error) return v.error
143 if (v.members.length === 0) return 'no members'
144 const named = v.members.filter(m => m.status === 'busy' || m.status === 'unknown').sort(byAttention)
145 const parts = named.slice(0, STATUS_NAMES_MAX).map(m => {
146 const t = m.status === 'busy' && m.quietForS !== undefined ? ` ${clock(m.quietForS)}` : ''
147 return `${GLYPH[m.status]} ${m.name}${t}`
148 })
149 if (named.length > STATUS_NAMES_MAX) parts.push(`+${named.length - STATUS_NAMES_MAX}`)
150 for (const s of ['idle', 'offline'] as const) {
151 const n = v.members.filter(m => m.status === s).length
152 if (n > 0) parts.push(`${GLYPH[s]} ${n}`)
153 }
154 return parts.join(' ')
155}
156
157/** The fleet line with the secrets part appended when there is one. */
158export function withSecrets(line: string | undefined, s: SecretsView): string | undefined {
159 const extra = secretsStatus(s)
160 if (!extra) return line
161 return line ? `${line} ${extra}` : extra
162}
163
164/**
165 * Body rows the pane draws for the members part: title, rule, section header,
166 * the error line, then (open) a label per category and a row per member, or
167 * the empty/error lines; and the footer.
168 */
169export function paneRows(v: FleetView, l: PaneLayout = DEFAULT_LAYOUT): number {
170 const fixed = 4
171 if (!l.membersOpen) return fixed + (v.error && v.members.length > 0 ? 1 : 0)
172 if (v.members.length === 0) return fixed + 1 + (v.error?.startsWith(UNREACHABLE) ? 1 : 0)
173 const categories = new Set(v.members.map(m => m.category ?? '')).size
174 const labelled = categories > 1 || v.members.some(m => m.category !== null)
175 return fixed + (v.error ? 1 : 0) + v.members.length + (labelled ? categories : 0)
176}
177
178/** Plain-text summary, the /fleet-status transcript line. */
179export function summaryText(v: FleetView): string {
180 if (v.error) return v.error
181 if (v.members.length === 0) return 'no members registered'
182 const count = (s: FleetMemberStatus) => v.members.filter(m => m.status === s).length
183 const bits = (['busy', 'unknown', 'idle', 'offline'] as const)
184 .filter(s => count(s) > 0)
185 .map(s => `${count(s)} ${s}`)
186 return `${v.members.length} member${v.members.length === 1 ? '' : 's'}: ${bits.join(', ')}`
187}
188
189/**
190 * Why a fleet_snapshot read failed, for the status line. Claude Code reports
191 * a server that is still starting and one that is down the same way (`no tool
192 * "fleet_snapshot" on a server named "apra-fleet"; servers with tools: none`),
193 * so time since session start tells them apart; a connected apra-fleet that
194 * lacks the tool is an older server.
195 */
196export function classifyError(text: string, sinceStartMs: number): string {
197 const withTools = /servers with tools:\s*(.*)$/i.exec(text)?.[1] ?? ''
198 if (withTools.split(/[,\s]+/).includes(SERVER)) return 'server too old: update apra-fleet'
199 if (sinceStartMs < STARTUP_MS) return CONNECTING
200 // Newer wording: the server's tools are not in this session at all -- down,
201 // or hidden by a permissions.deny rule.
202 const hidden = HIDDEN_TOOL.exec(text)?.[1]
203 if (hidden) return `${UNREACHABLE} (or ${TOOL_PREFIX}${hidden} denied)`
204 return UNREACHABLE
205}
206
207/**
208 * The `no connected MCP tool "X"` wording Claude Code uses when a tool is not
209 * in this session: the server is down, or a permissions.deny rule hides it.
210 */
211const HIDDEN_TOOL = /no connected MCP tool "([\w-]+)"/
212
213/**
214 * A failed $.mcp.call -> the line the pane shows. When fleet_snapshot still
215 * answers, the server is up, so a tool it says is missing is hidden from this
216 * session (a permissions.deny rule): say so, rather than echo the raw error.
217 */
218export function callErrorText(text: string, snapshotOk: boolean): string {
219 const tool = HIDDEN_TOOL.exec(text)?.[1]
220 if (tool && snapshotOk) {
221 const what = tool.startsWith('credential_store_') ? 'credential tools' : tool
222 return `${what} hidden in this session (permission deny rule ${TOOL_PREFIX}${tool}?)`
223 }
224 return text.replace(/^\$\.mcp\.call:\s*/, '')
225}
226
227async function errorText($: EngineInterface, err: unknown): Promise<string> {
228 const v = await read($, view)
229 return callErrorText(err instanceof Error ? err.message : String(err), v.fetchedAt > 0 && !v.error)
230}
231
232
233// --- Secrets ---------------------------------------------------------------
234
235const TTL_SECONDS: Record<SecretForm['ttl'], number | undefined> = { none: undefined, '1h': 3600, '1d': 86400, '7d': 604800 }
236
237function texts(res: { content: Array<{ type: string; text?: unknown }> }): string[] {
238 return res.content.flatMap(b => (b.type === 'text' && typeof b.text === 'string' ? [b.text] : []))
239}
240
241/** credential_store_list's reply -> rows (names and settings only; it never carries a value). */
242export function parseSecrets(replies: string[]): SecretRow[] {
243 for (const text of replies) {
244 let data: unknown
245 try {
246 data = JSON.parse(text)
247 } catch {
248 continue
249 }
250 if (!Array.isArray(data)) continue
251 return data.map((raw): SecretRow => {
252 const r = raw as Record<string, unknown>
253 return {
254 name: String(r.name ?? '?'),
255 scope: String(r.scope ?? ''),
256 members: String(r.members ?? '*'),
257 expiry: String(r.expiry ?? 'none'),
258 }
259 }).sort((a, b) => a.name.localeCompare(b.name))
260 }
261 throw new Error('unexpected credential_store_list reply')
262}
263
264/** Expired, or due within the hour ("12m 3s remaining": no hours left). */
265export function expiresSoon(row: SecretRow): boolean {
266 if (row.expiry === 'expired') return true
267 return row.expiry.endsWith('remaining') && !/\dh\b/.test(row.expiry)
268}
269
270/** credential_store_list's expiry, compact for the pane: "167h 59m remaining" -> "6d left". */
271export function expiryLabel(expiry: string): string {
272 if (expiry === 'none') return 'no expiry'
273 const hm = /^(\d+)h (\d+)m remaining$/.exec(expiry)
274 if (hm) {
275 const h = Number(hm[1])
276 return h >= 48 ? `${Math.floor(h / 24)}d left` : `${h}h ${hm[2]}m left`
277 }
278 const m = /^(\d+)m \d+s remaining$/.exec(expiry)
279 if (m) return `${m[1]}m left`
280 return expiry.replace(/ remaining$/, ' left')
281}
282
283/** The status line's secrets part: only when some secret needs attention. */
284export function secretsStatus(s: SecretsView): string | undefined {
285 const n = s.items.filter(expiresSoon).length
286 return n > 0 ? `${KEY} ${n} expiring` : undefined
287}
288
289/**
290 * Why an entry link is unsafe to open as given, or undefined when it is fine:
291 * https, or a loopback address (the value never leaves this machine).
292 */
293export function entryLinkWarning(link: string): string | undefined {
294 let u: URL
295 try {
296 u = new URL(link)
297 } catch {
298 return 'not a link this mod can check'
299 }
300 if (u.protocol === 'https:') return undefined
301 const host = u.hostname.replace(/^\[|\]$/g, '')
302 if (host === '127.0.0.1' || host === 'localhost' || host === '::1') return undefined
303 return `plain http to ${u.host}: the value would cross the network unencrypted. Use an SSH tunnel or https, or the terminal command.`
304}
305
306/**
307 * credential_store_set's return_url reply -> the link and its expiry, or a
308 * failure line. The server sends both a text block and structuredContent
309 * ({ absoluteUrl, expiresAt }); Claude Code 2.1.301 hands a plugin the
310 * structured form as the only text block, so JSON is read first.
311 * A server without return_url (0.4.x) ignores it and collects the value out of
312 * band itself (a hidden-input terminal); its "NAME stored" reply is `stored`.
313 */
314export function parseEntryLink(replies: string[], structured?: unknown): { link: string; expires?: string } | { stored: true } | { error: string } {
315 const fromJson = (d: unknown) => {
316 const o = d as { absoluteUrl?: unknown; url?: unknown; expiresAt?: unknown } | null
317 const link = typeof o?.absoluteUrl === 'string' ? o.absoluteUrl : typeof o?.url === 'string' && /^https?:/.test(o.url) ? o.url : undefined
318 if (!link) return undefined
319 return typeof o?.expiresAt === 'string' ? { link, expires: o.expiresAt } : { link }
320 }
321 const s = fromJson(structured)
322 if (s) return s
323 for (const text of replies) {
324 try {
325 const j = fromJson(JSON.parse(text))
326 if (j) return j
327 } catch {
328 // not JSON: the human-readable reply, read below
329 }
330 }
331 const all = replies.join('\n')
332 if (/^\s*\[FAIL\]/.test(all)) return { error: all.trim().split('\n')[0]!.replace(/^\s*\[FAIL\]\s*/, '') }
333 const link = /(https?:\/\/[^\s"'<>]+)/.exec(all)?.[1]
334 if (!link && /^\W*[\w-]+ stored\b/m.test(all)) return { stored: true }
335 if (!link) return { error: 'the server did not return an entry link' }
336 const expires = /\(expires ([^)]+)\)/.exec(all)?.[1]
337 return expires ? { link, expires } : { link }
338}
339
340// --- Removing a member ------------------------------------------------------
341
342/**
343 * remove_member's reply -> the note line. The server refuses a busy or held
344 * member unless forced; the pane never forces, it shows the refusal.
345 */
346export function removeNote(name: string, replies: string[], isError?: boolean): string {
347 const first = replies.join('\n').trim().split('\n')[0] ?? ''
348 if (!isError && /has been removed/.test(first)) return `Removed ${name}.`
349 const why = first.replace(/^\W+/, '').replace(/\s*(or\s+)?set force=true.*$/i, '').replace(/\s*Error code: [\w-]+\.?/, '')
350 .replace(/^Cannot remove member "[^"]*":\s*/, '')
351 return `Not removed: ${why || 'no reply from the server'}`
352}
353
354async function removeMember($: EngineInterface, name: string): Promise<void> {
355 let note: string
356 try {
357 const res = await $.mcp.call(SERVER, REMOVE_TOOL, { member_name: name })
358 note = removeNote(name, texts(res), res.isError)
359 } catch (err) {
360 note = `Not removed: ${await errorText($, err)}`
361 }
362 await update($, memberAction, () => ({ note }))
363 await refresh($)
364}
365
366/** Rows the remove confirm and its outcome take under the member list. */
367export function memberActionRows(a: MemberAction): number {
368 return (a.confirmRemove ? 1 : 0) + (a.note ? 1 : 0)
369}
370
371/** The command that stores a value from another terminal, its input hidden. */
372export function cliCommand(name: string): string {
373 return `apra-fleet secret --set ${name} --persist`
374}
375
376let secretsInFlight: Promise<void> | undefined
377
378/** One credential_store_list read into the secrets view. */
379function refreshSecrets($: EngineInterface): Promise<void> {
380 if (secretsInFlight) return secretsInFlight
381 secretsInFlight = (async () => {
382 let fresh: SecretsView
383 try {
384 const res = await $.mcp.call(SERVER, LIST_TOOL, {})
385 if (res.isError) throw new Error(texts(res).join(' ') || 'tool error')
386 fresh = { items: parseSecrets(texts(res)), fetchedAt: Date.now() }
387 } catch (err) {
388 const prev = await read($, secrets)
389 fresh = { ...prev, fetchedAt: Date.now(), error: await errorText($, err) }
390 }
391 await update($, secrets, () => fresh)
392 // A delete or apply changes the expiring count: keep the status line in step.
393 const v = await read($, view)
394 if (v.fetchedAt) $.ui.status(withSecrets(statusLine(v), fresh))
395 })().finally(() => {
396 secretsInFlight = undefined
397 })
398 return secretsInFlight
399}
400
401async function setNote($: EngineInterface, note: string | undefined): Promise<void> {
402 await update($, secretForm, f => ({ ...f, note }))
403}
404
405/** Ask the server for a one-time entry link; the value is typed on its form, never here. */
406async function requestEntryLink($: EngineInterface): Promise<void> {
407 const f = await read($, secretForm)
408 if (!SECRET_NAME.test(f.name)) return setNote($, 'Name: letters, digits, _ and -, up to 64 characters.')
409 const args: Record<string, unknown> = {
410 name: f.name,
411 prompt: `Value for ${f.name}`,
412 persist: f.keep === 'persistent',
413 members: f.members.trim() || '*',
414 return_url: true,
415 }
416 const ttl = TTL_SECONDS[f.ttl]
417 if (ttl !== undefined) args.ttl_seconds = ttl
418 await setNote($, 'Asking the server for a secure entry\u2026')
419 try {
420 const res = await $.mcp.call(SERVER, SET_TOOL, args)
421 const got = parseEntryLink(texts(res), res.structuredContent)
422 if ('stored' in got) {
423 await update($, secretForm, () => ({ ...CLOSED_FORM, note: `${f.name} stored.` }))
424 return refreshSecrets($)
425 }
426 if ('error' in got) return setNote($, `No browser link from this server; use the command above. (${got.error})`)
427 await update($, secretForm, cur => ({
428 ...cur,
429 mode: 'link' as const,
430 link: got.link,
431 linkExpires: got.expires && !Number.isNaN(Date.parse(got.expires)) ? new Date(got.expires).toTimeString().slice(0, 5) : got.expires,
432 warning: entryLinkWarning(got.link),
433 note: undefined,
434 }))
435 } catch (err) {
436 await setNote($, `No link: ${await errorText($, err)}`)
437 }
438}
439
440/** After `apra-fleet secret --set`: apply the members and expiry picked here (settings only). */
441async function applySettings($: EngineInterface): Promise<void> {
442 const f = await read($, secretForm)
443 const args: Record<string, unknown> = { name: f.name, members: f.members.trim() || '*' }
444 const ttl = TTL_SECONDS[f.ttl]
445 if (ttl !== undefined) args.ttl_seconds = ttl
446 try {
447 const res = await $.mcp.call(SERVER, UPDATE_TOOL, args)
448 const line = texts(res).join(' ').trim()
449 await setNote($, res.isError || /not found/i.test(line) ? `Not applied: ${line || 'no such secret yet'}` : `Settings applied to ${f.name}.`)
450 } catch (err) {
451 await setNote($, `Not applied: ${await errorText($, err)}`)
452 }
453 await refreshSecrets($)
454}
455
456async function deleteSecret($: EngineInterface, name: string): Promise<void> {
457 try {
458 const res = await $.mcp.call(SERVER, DELETE_TOOL, { name })
459 const line = texts(res).join(' ')
460 await update($, secretForm, f => ({
461 ...f,
462 confirmDelete: undefined,
463 note: res.isError || /not found/i.test(line) ? `Could not delete ${name}.` : `Deleted ${name}.`,
464 }))
465 } catch (err) {
466 const why = await errorText($, err)
467 await update($, secretForm, f => ({ ...f, confirmDelete: undefined, note: `Could not delete ${name}: ${why}` }))
468 }
469 await refreshSecrets($)
470}
471
472/** Rows the Secrets section takes in the pane: rule and header, then (open) the list, confirm, form and note. */
473export function secretsRows(s: SecretsView, f: SecretForm, l: PaneLayout = DEFAULT_LAYOUT): number {
474 if (!l.secretsOpen) return 2
475 // With an error the empty-state line is not drawn: the error stands alone.
476 const list = s.error ? 1 + s.items.length : Math.max(1, s.items.length)
477 const form = f.mode === 'form' ? 4 + (l.moreOptions ? 3 : 0) : f.mode === 'link' ? 3 + (f.warning ? 1 : 0) : 0
478 return 2 + list + form + (f.note ? 1 : 0) + (f.confirmDelete ? 1 : 0)
479}
480
481/** The token the fleet resolves to a secret's value inside a member-bound command. */
482export function secretToken(name: string): string {
483 return `{{secret.${name}}}`
484}
485
486// --- Pane size ---------------------------------------------------------------
487
488/**
489 * Cells kept free at the body's right edge. The terminal surface does not
490 * draw the last two body columns (observed: every row, the rule included),
491 * so the tree is laid out that much narrower and right-aligned controls
492 * (x, more, less, add) land in cells that show.
493 */
494export const GUTTER = 2
495const MIN_COLUMNS = 40
496const MAX_COLUMNS = 72
497// Member row: status glyph, icon and state columns, the x control.
498const MEMBER_FIXED = 3 + 3 + 9 + 1
499export const FOOTER = `refresh ${POLL_MS / 1000}s ${DOT} press name to insert token`
500
501/** Terminal cells a string takes: astral and the status emoji are two wide. */
502export function cells(text: string): number {
503 let n = 0
504 for (const ch of text) n += ch.codePointAt(0)! >= 0x1f000 || ch === GLYPH.busy || ch === GLYPH.offline || ch === GLYPH.unknown ? 2 : 1
505 return n
506}
507
508/** What a member row shows behind "more": always something, whatever the state. */
509export function memberExtras(m: FleetMember): string {
510 const bits = [
511 m.status === 'busy' ? (m.quietForS !== undefined ? `quiet ${clock(m.quietForS)}` : 'working') : '',
512 m.status === 'unknown' ? 'no activity -- may be stalled' : '',
513 m.status === 'idle' && !m.reservedBy ? 'free for work' : '',
514 m.status === 'offline' ? 'not reachable' : '',
515 m.reservedBy ? `held by ${m.reservedBy}` : '',
516 m.id && m.id !== m.name ? `id ${m.id.slice(0, 8)}` : '',
517 ]
518 return bits.filter(Boolean).join(` ${DOT} `)
519}
520
521/** The widest member name the row can give its full length, at `width` body cells. */
522export function nameCap(width: number, indent: number): number {
523 return Math.max(10, width - MEMBER_FIXED - indent)
524}
525
526function counted(v: FleetView): Array<{ s: FleetMemberStatus; n: number }> {
527 return (['busy', 'unknown', 'idle', 'offline'] as const)
528 .map(c => ({ s: c, n: v.members.filter(m => m.status === c).length }))
529 .filter(c => c.n > 0)
530}
531
532/**
533 * Body columns the pane's content takes (header, section headers, member and
534 * secret rows, the open form, the footer), clamped to 40-72, plus GUTTER.
535 */
536export function paneColumns(v: FleetView, s: SecretsView, l: PaneLayout = DEFAULT_LAYOUT, f: SecretForm = CLOSED_FORM): number {
537 const widths: number[] = [cells(FOOTER)]
538 widths.push(10 + (v.version ? 3 + v.version.replace(/^v/, '').length : 0) + 2 + 14)
539 const summary = counted(v).reduce((w, c, i) => w + cells(`${GLYPH[c.s]} ${c.n}`) + (i ? 2 : 0), 0)
540 widths.push(cells(`${OPEN} Members ${v.members.length}`) + (summary ? 2 + summary : 0) + 2 + 5)
541 if (l.membersOpen && v.members.length > 0) {
542 const indent = new Set(v.members.map(m => m.category ?? '')).size > 1 || v.members.some(m => m.category !== null) ? 1 : 0
543 const name = Math.max(...v.members.map(m => m.name.length)) + 2
544 for (const m of v.members) {
545 const extra = l.moreMembers ? cells(memberExtras(m)) + 1 : 0
546 widths.push(indent + MEMBER_FIXED + name + extra)
547 }
548 }
549 const expiring = s.items.filter(expiresSoon).length
550 widths.push(cells(`${OPEN} ${KEY} Secrets ${s.items.length}`) + (expiring ? 2 + cells(`${expiring} expiring`) : 0) + 2 + (s.items.length ? 7 : 0) + 3)
551 if (l.secretsOpen) {
552 const name = Math.min(28, Math.max(0, ...s.items.map(r => r.name.length)) + 2)
553 for (const r of s.items) {
554 const expiry = l.moreSecrets || expiresSoon(r) ? cells(expiryLabel(r.expiry)) : 0
555 widths.push(name + (l.moreSecrets ? 28 : 0) + expiry + 1 + 'replace'.length + 2)
556 }
557 if (f.mode === 'form') {
558 widths.push(1 + cliCommand(f.name || '<NAME>').length + 1 + 6)
559 widths.push(1 + (l.moreOptions ? 27 : 0) + 19 + 1 + (l.moreOptions ? 13 : 14) + 1 + 7)
560 }
561 }
562 return Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, ...widths)) + GUTTER
563}
564
565/** Open (or resize) the pane to what its content takes now. */
566async function openPane($: EngineInterface): Promise<void> {
567 const v = await read($, view)
568 const l = await read($, layout)
569 const s = await read($, secrets)
570 const f = await read($, secretForm)
571 // Inline (above the prompt) the pane asks for the rows its content takes;
572 // docked beside the transcript, for the columns.
573 const rows = paneRows(v, l) + (l.membersOpen ? memberActionRows(await read($, memberAction)) : 0) + secretsRows(s, f, l)
574 await $.ui.open({ id: PANE, title: 'Fleet', rows, columns: paneColumns(v, s, l, f) })
575}
576
577async function setLayout($: EngineInterface, patch: Partial<PaneLayout>): Promise<void> {
578 const next = await update($, layout, cur => ({ ...cur, ...patch }))
579 // Remembering the layout is a convenience: a store that refuses must not undo the toggle.
580 await $.store.set(LAYOUT_KEY, next).catch(() => undefined)
581 // A toggle is a press inside the open pane: re-ask its size for the new content.
582 await openPane($).catch(() => undefined)
583}
584
585let inFlight: Promise<void> | undefined
586let startedAt = Date.now()
587let poll: { cancel: () => void } | undefined
588
589/** One fleet_snapshot read into the view and the status line; calls overlap into one. */
590function refresh($: EngineInterface): Promise<void> {
591 if (inFlight) return inFlight
592 inFlight = (async () => {
593 let fresh: FleetView
594 try {
595 const res = await $.mcp.call(SERVER, SNAPSHOT_TOOL, {})
596 const texts = res.content.flatMap(b => (b.type === 'text' && typeof b.text === 'string' ? [b.text] : []))
597 if (res.isError) throw new Error(texts.join(' ') || 'tool error')
598 const snap = parseSnapshot(texts)
599 fresh = { version: snap.version, members: snap.members, fetchedAt: Date.now() }
600 } catch (err) {
601 // Keep the last good members: the pane shows them under the error.
602 const prev = await read($, view)
603 const text = err instanceof Error ? err.message : String(err)
604 fresh = { ...prev, fetchedAt: Date.now(), error: classifyError(text, Date.now() - startedAt) }
605 }
606 await update($, view, () => fresh)
607 await refreshSecrets($)
608 $.ui.status(withSecrets(statusLine(fresh), await read($, secrets)))
609 // Bounded by STARTUP_MS: after it a failure is no longer CONNECTING and
610 // reads settle to the 30s poll.
611 if (fresh.error === CONNECTING) $.clock.after(RETRY_MS, () => void refresh($))
612 })().finally(() => {
613 inFlight = undefined
614 })
615 return inFlight
616}
617
618export const register: Register = on => {
619 on('session.start', async ($, e, next) => {
620 await $.command.register({
621 name: 'fleet-status',
622 description: 'Show every apra-fleet member and what it is doing',
623 })
624 startedAt = Date.now()
625 const saved = (await $.store.get(LAYOUT_KEY)) as Partial<PaneLayout> | undefined
626 if (saved && typeof saved === 'object') await update($, layout, () => ({ ...DEFAULT_LAYOUT, ...saved }))
627 poll?.cancel()
628 poll = $.clock.every(POLL_MS, () => void refresh($))
629 void refresh($)
630 return next(e)
631 })
632
633 on('command.run', { command: 'fleet-status' }, async $ => {
634 await refresh($)
635 await openPane($)
636 return { text: summaryText(await read($, view)) }
637 })
638
639 // Any other apra-fleet tool call may change a member's state: re-read
640 // shortly after it starts (execute_prompt marks the member busy at
641 // dispatch) and again once it settles.
642 on('tool.call', { tool: /^mcp__apra-fleet__/ }, async ($, e, next) => {
643 if (e.tool === TOOL_PREFIX + SNAPSHOT_TOOL) return next(e)
644 const early = $.clock.after(BUSY_RECHECK_MS, () => void refresh($))
645 try {
646 return await next(e)
647 } finally {
648 early.cancel()
649 void refresh($)
650 }
651 }).catch(($, e, next) => next(e))
652
653 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
654 const ui = $.ui.resolve(e)
655 const { Box, Text, Button, Link } = ui
656 // Mobile draws no Input or Select: the form then shows its values as text.
657 const Input = 'Input' in ui ? ui.Input : undefined
658 const Select = 'Select' in ui ? ui.Select : undefined
659 const v = await read($, view)
660 const act = await read($, memberAction)
661 const s = await read($, secrets)
662 const f = await read($, secretForm)
663 // GUTTER: the last body cells do not show on the terminal; lay out short of them.
664 const width = Math.max(20, (e.props.bodyColumns ?? 60) - GUTTER)
665 const rule = <Text dimColor>{RULE.repeat(width)}</Text>
666 const asOf = v.fetchedAt ? new Date(v.fetchedAt).toTimeString().slice(0, 8) : '--:--:--'
667 const copy = (text: string) => (p: UiPressArgument) => void $.ui.copy({ text, surface: p.surface })
668 const setForm = (fn: (cur: SecretForm) => SecretForm) => void update($, secretForm, fn)
669
670 const header = (
671 <Box flexDirection="row" justifyContent="space-between">
672 <Text>
673 <Text bold color="claude">apra-fleet</Text>
674 {v.version ? <Text dimColor>{` v${v.version.replace(/^v/, "")}`}</Text> : null}
675 </Text>
676 <Text dimColor>{`as of ${asOf}`}</Text>
677 </Box>
678 )
679
680 const l = await read($, layout)
681 const toggle = (patch: Partial<PaneLayout>) => () => void setLayout($, patch)
682 // A section header: the fold control, a summary, then the section's own controls.
683 const sectionHeader = (key: string, open: boolean, title: string, summary: RenderChildren, controls: RenderChildren) => (
684 <Box flexDirection="row" justifyContent="space-between">
685 <Box flexDirection="row" gap={2}>
686 <Button key={key} label={`${open ? OPEN : CLOSED} ${title}`} plain onPress={toggle(key === 'fold-members' ? { membersOpen: !open } : { secretsOpen: !open })} />
687 {summary}
688 </Box>
689 <Box flexDirection="row" gap={2}>{controls}</Box>
690 </Box>
691 )
692 const moreButton = (key: string, shown: boolean, patch: Partial<PaneLayout>) => (
693 <Button key={key} label={shown ? 'less' : `more${MORE}`} plain dimColor onPress={toggle(patch)} />
694 )
695
696 // --- Members ---
697 const counts = counted(v)
698 const memberSummary = (
699 <Box flexDirection="row" gap={2}>
700 {counts.map(c => (
701 <Text color={COLOR[c.s]}>{`${GLYPH[c.s]} ${c.n}`}</Text>
702 ))}
703 </Box>
704 )
705 let memberBody
706 const hint = v.error?.startsWith(UNREACHABLE) ? 'Start it with `apra-fleet start`, then /mcp to reconnect.' : undefined
707 if (v.error && v.members.length === 0) {
708 memberBody = (
709 <Box flexDirection="column">
710 <Text color={v.error === CONNECTING ? 'subtle' : 'error'}>{v.error}</Text>
711 {hint ? <Text dimColor>{hint}</Text> : null}
712 </Box>
713 )
714 } else if (v.members.length === 0) {
715 memberBody = <Text dimColor>No members yet. Ask Claude to register one.</Text>
716 } else {
717 const groups = new Map<string, FleetMember[]>()
718 for (const m of v.members) {
719 const key = m.category ?? ''
720 groups.set(key, [...(groups.get(key) ?? []), m])
721 }
722 const keys = [...groups.keys()].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b)))
723 const showCategories = keys.length > 1 || keys[0] !== ''
724 // The cap is what the row leaves after its fixed columns, not a constant.
725 const nameWidth = Math.min(nameCap(width, showCategories ? 1 : 0), Math.max(...v.members.map(m => m.name.length)) + 2)
726 memberBody = (
727 <Box flexDirection="column">
728 {keys.map(key => (
729 <Box flexDirection="column">
730 {showCategories ? <Text bold dimColor>{key === '' ? 'uncategorized' : key}</Text> : null}
731 {groups.get(key)!.sort(byAttention).map(m => (
732 <Box flexDirection="row" paddingLeft={showCategories ? 1 : 0}>
733 <Box width={3}><Text>{GLYPH[m.status]}</Text></Box>
734 <Box width={3}><Text>{m.icon}</Text></Box>
735 <Box width={nameWidth}><Text bold wrap="truncate-end">{m.name}</Text></Box>
736 <Box width={9}><Text color={COLOR[m.status]}>{m.status}</Text></Box>
737 <Box flexGrow={1}>
738 {l.moreMembers ? <Text dimColor wrap="truncate-end">{memberExtras(m)}</Text> : null}
739 </Box>
740 <Button key={`remove:${m.name}`} label={CROSS} plain dimColor
741 onPress={() => void update($, memberAction, () => ({ confirmRemove: m.name }))} />
742 </Box>
743 ))}
744 </Box>
745 ))}
746 {act.confirmRemove ? (
747 <Box flexDirection="row" gap={1}>
748 <Text color="warning">{`Remove ${act.confirmRemove} from the fleet?`}</Text>
749 <Button key="remove-confirm" label="Remove" variant="primary"
750 onPress={() => void removeMember($, act.confirmRemove!)} />
751 <Button key="remove-cancel" label="Cancel" onPress={() => void update($, memberAction, () => ({}))} />
752 </Box>
753 ) : null}
754 {act.note ? <Text color={act.note.startsWith('Removed') ? 'success' : 'error'}>{act.note}</Text> : null}
755 </Box>
756 )
757 }
758 const membersBlock = (
759 <Box flexDirection="column">
760 {rule}
761 {sectionHeader('fold-members', l.membersOpen, `Members ${v.members.length}`, memberSummary,
762 l.membersOpen && v.members.length > 0 ? moreButton('more-members', l.moreMembers, { moreMembers: !l.moreMembers }) : null)}
763 {v.error && v.members.length > 0 ? <Text color="error">{`${v.error} -- last known below`}</Text> : null}
764 {l.membersOpen ? memberBody : null}
765 </Box>
766 )
767
768 // --- Secrets ---
769 const openForm = (row?: SecretRow) => {
770 // The form is wider than the list: re-ask the pane's size once it is drawn.
771 void update($, secretForm, (): SecretForm => ({
772 ...CLOSED_FORM,
773 mode: 'form',
774 name: row?.name ?? '',
775 members: row && row.members !== '*' ? row.members : '',
776 keep: row && row.scope !== 'persistent' ? 'session' : 'persistent',
777 replacing: row ? true : undefined,
778 })).then(() => (l.secretsOpen ? openPane($) : setLayout($, { secretsOpen: true }))).catch(() => undefined)
779 }
780 const closeForm = () => {
781 setForm(cur => ({ ...CLOSED_FORM, note: cur.note }))
782 void refreshSecrets($)
783 }
784 // Pressing a secret's name puts its token (never its value) at the prompt's cursor.
785 const insertToken = async (name: string) => {
786 const got = await $.prompt.fill({ text: secretToken(name), mode: 'insert' })
787 await setNote($, got.isFilled ? `Inserted ${secretToken(name)} at the cursor.` : `Could not insert: the prompt box is not taking text right now.`)
788 }
789 const formBlock = f.mode === 'form' ? (
790 <Box flexDirection="column" paddingLeft={1}>
791 {f.replacing || !Input
792 ? <Text>{`Name: ${f.name || '(type a name)'}`}{f.replacing ? <Text dimColor> (replacing its value)</Text> : null}</Text>
793 : <Input key="secret-name" label="Name" autoFocus placeholder="GITHUB_TOKEN" value={f.name} submitLabel="ok"
794 onInput={(t: string) => setForm(cur => ({ ...cur, name: t.trim() }))}
795 onSubmit={(t: string) => setForm(cur => ({ ...cur, name: t.trim() }))} />}
796 {l.moreOptions ? (
797 <Box flexDirection="column">
798 {Input
799 ? <Input key="secret-members" label="Members" placeholder="* (all)" value={f.members} submitLabel="ok"
800 onInput={(t: string) => setForm(cur => ({ ...cur, members: t }))}
801 onSubmit={(t: string) => setForm(cur => ({ ...cur, members: t }))} />
802 : <Text>{`Members: ${f.members.trim() || '* (all)'}`}</Text>}
803 {Select
804 ? <Select key="secret-keep" label="Keep" value={f.keep}
805 options={[{ value: 'persistent', label: 'persistent (survives restart)' }, { value: 'session', label: 'this server session only' }]}
806 onSelect={(k: string) => setForm(cur => ({ ...cur, keep: k as SecretForm['keep'] }))} />
807 : <Text>{`Keep: ${f.keep}`}</Text>}
808 {Select
809 ? <Select key="secret-ttl" label="Expires" value={f.ttl}
810 options={[{ value: 'none', label: 'never' }, { value: '1h', label: 'in 1 hour' }, { value: '1d', label: 'in 1 day' }, { value: '7d', label: 'in 7 days' }]}
811 onSelect={(t: string) => setForm(cur => ({ ...cur, ttl: t as SecretForm['ttl'] }))} />
812 : <Text>{`Expires: ${f.ttl}`}</Text>}
813 </Box>
814 ) : null}
815 <Text dimColor>
816 {f.keep === 'session'
817 ? 'Session-only: use the browser link (the command always persists).'
818 : l.moreOptions
819 ? 'Run in ANOTHER terminal (the value is typed hidden), then Apply:'
820 : 'Run in ANOTHER terminal (the value is typed hidden):'}
821 </Text>
822 <Box flexDirection="row" gap={1}>
823 <Text color="claude">{cliCommand(f.name || '<NAME>')}</Text>
824 <Button key="secret-copy-cmd" label="Copy" onPress={copy(cliCommand(f.name || '<NAME>'))} />
825 </Box>
826 <Box flexDirection="row" gap={1}>
827 {l.moreOptions ? <Button key="secret-apply" label="Apply members and expiry" variant="primary" onPress={() => void applySettings($)} /> : null}
828 <Button key="secret-link" label="Get browser link" onPress={() => void requestEntryLink($)} />
829 <Button key="secret-options" label={l.moreOptions ? 'fewer options' : `more options${MORE}`} plain dimColor onPress={toggle({ moreOptions: !l.moreOptions })} />
830 <Button key="secret-cancel" label="Close" onPress={closeForm} />
831 </Box>
832 </Box>
833 ) : f.mode === 'link' && f.link ? (
834 <Box flexDirection="column" paddingLeft={1}>
835 <Text dimColor>{`Open once and type the value for ${f.name} there${f.linkExpires ? ` (expires ${f.linkExpires})` : ''}:`}</Text>
836 <Box flexDirection="row" gap={1}>
837 <Link href={f.link} />
838 <Button key="secret-copy-link" label="Copy" onPress={copy(f.link)} />
839 </Box>
840 {f.warning ? <Text color="error">{`Careful: ${f.warning}`}</Text> : null}
841 <Box flexDirection="row" gap={1}>
842 <Button key="secret-done" label="Done" variant="primary" onPress={closeForm} />
843 </Box>
844 </Box>
845 ) : null
846
847 const expiring = s.items.filter(expiresSoon).length
848 const nameWidth = Math.min(28, Math.max(0, ...s.items.map(r => r.name.length)) + 2)
849 const secretsBlock = (
850 <Box flexDirection="column">
851 {rule}
852 {sectionHeader('fold-secrets', l.secretsOpen, `${KEY} Secrets ${s.items.length}`,
853 expiring > 0 ? <Text color="warning">{`${expiring} expiring`}</Text> : null,
854 <Box flexDirection="row" gap={2}>
855 {l.secretsOpen && s.items.length > 0 ? moreButton('more-secrets', l.moreSecrets, { moreSecrets: !l.moreSecrets }) : null}
856 {/* Always drawn: a pressed control that vanished would drop the pane's focus back to the prompt. */}
857 <Button key="secret-add" label="add" plain onPress={() => openForm()} />
858 </Box>)}
859 {l.secretsOpen ? (
860 <Box flexDirection="column">
861 {s.error ? <Text color="error">{`secrets: ${s.error}`}</Text> : null}
862 {s.items.length === 0
863 ? (s.error ? null : <Text dimColor>No secrets stored.</Text>)
864 : s.items.map(row => (
865 <Box flexDirection="row">
866 <Box width={nameWidth}>
867 <Button key={`secret-insert:${row.name}`} label={row.name} plain onPress={() => void insertToken(row.name)} />
868 </Box>
869 {l.moreSecrets ? <Box width={12}><Text dimColor>{row.scope}</Text></Box> : null}
870 {l.moreSecrets ? <Box width={16}><Text dimColor wrap="truncate-end">{row.members === '*' ? 'all members' : row.members}</Text></Box> : null}
871 <Box flexGrow={1} marginRight={1}>
872 {l.moreSecrets || expiresSoon(row)
873 ? <Text color={expiresSoon(row) ? 'warning' : undefined} dimColor={!expiresSoon(row)} wrap="truncate-end">{expiryLabel(row.expiry)}</Text>
874 : null}
875 </Box>
876 <Box flexDirection="row" gap={1}>
877 <Button key={`secret-replace:${row.name}`} label="replace" plain dimColor onPress={() => openForm(row)} />
878 <Button key={`secret-delete:${row.name}`} label={CROSS} plain dimColor
879 onPress={() => setForm(cur => ({ ...cur, confirmDelete: row.name, note: undefined }))} />
880 </Box>
881 </Box>
882 ))}
883 {f.confirmDelete ? (
884 <Box flexDirection="row" gap={1}>
885 <Text color="warning">{`Delete secret ${f.confirmDelete}?`}</Text>
886 <Button key="secret-delete-confirm" label="Delete" variant="primary" onPress={() => void deleteSecret($, f.confirmDelete!)} />
887 <Button key="secret-delete-cancel" label="Cancel" onPress={() => setForm(cur => ({ ...cur, confirmDelete: undefined }))} />
888 </Box>
889 ) : null}
890 {formBlock}
891 {f.note ? <Text color={/^(No link|Not applied|Could not)/.test(f.note) ? 'error' : 'success'}>{f.note}</Text> : null}
892 </Box>
893 ) : null}
894 </Box>
895 )
896
897 return (
898 <Box flexDirection="column" width={width}>
899 {header}
900 {membersBlock}
901 {secretsBlock}
902 <Text dimColor>{FOOTER}</Text>
903 </Box>
904 )
905 })
906}
907types/index.d.ts 89 lines1export type FleetMemberStatus = 'busy' | 'idle' | 'offline' | 'unknown'
2
3export type FleetMember = {
4 id: string
5 name: string
6 icon: string
7 category: string | null
8 status: FleetMemberStatus
9 quietForS?: number
10 reservedBy?: string
11}
12
13export type FleetView = {
14 /** Server version, absent until the first good read. */
15 version?: string
16 members: FleetMember[]
17 /** Epoch ms of the last read attempt that answered, 0 before any. */
18 fetchedAt: number
19 /** Why the last read failed, absent when it succeeded. */
20 error?: string
21}
22
23/** One stored secret as credential_store_list describes it: never its value. */
24export type SecretRow = {
25 name: string
26 /** 'persistent' survives a server restart; 'session' does not. */
27 scope: string
28 /** '*' or a comma-separated list of member names. */
29 members: string
30 /** 'none', 'expired', or e.g. '3h 12m remaining'. */
31 expiry: string
32}
33
34export type SecretsView = {
35 items: SecretRow[]
36 /** Epoch ms of the last list that answered, 0 before any. */
37 fetchedAt: number
38 error?: string
39}
40
41/**
42 * The Secrets section's controls. The secret's value is never held here:
43 * 'link' carries only the one-time link to the server's own entry form.
44 */
45export type SecretForm = {
46 mode: 'closed' | 'form' | 'link'
47 name: string
48 /** '' (all members), '*', or a comma-separated list of member names. */
49 members: string
50 keep: 'persistent' | 'session'
51 ttl: 'none' | '1h' | '1d' | '7d'
52 /** The name is an existing secret's: the form replaces its value. */
53 replacing?: boolean
54 link?: string
55 linkExpires?: string
56 /** Shown beside the link when it is neither https nor loopback. */
57 warning?: string
58 /** The secret awaiting a confirm press before it is deleted. */
59 confirmDelete?: string
60 /** One line about the last action (deleted, failed, ...). */
61 note?: string
62}
63
64/** The members list's Remove control: which member awaits a confirm, and the last outcome. */
65export type MemberAction = {
66 /** Name of the member awaiting a confirm press before it is removed. */
67 confirmRemove?: string
68 /** One line about the last removal (removed, refused as busy or held, ...). */
69 note?: string
70}
71
72/** What the pane shows: which sections are open, and which show their less-used fields. */
73export type PaneLayout = {
74 membersOpen: boolean
75 secretsOpen: boolean
76 /** Members: a state note (quiet time, stall, free, unreachable), holder and short id beside each row. */
77 moreMembers: boolean
78 /** Secrets: scope, allowed members and expiry beside each row. */
79 moreSecrets: boolean
80 /** The add/replace form: members, keep and expiry (all members, persistent, never by default). */
81 moreOptions: boolean
82}
83
84declare module 'claude-code' {
85 interface PluginState {
86 'fleet-status': { view: FleetView; secrets: SecretsView; secretForm: SecretForm; memberAction: MemberAction; layout: PaneLayout }
87 }
88}
89