SLOPSHOPPER

fleet-status

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

paneguardcommandstatustimer
★ 105v0.2.0NOASSERTIONupdated 2026-10-26Apra-Labs/apra-fleet/plugins/claude-code/fleet-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fleet-status
│ ┃ Fleet ✕ › fix the failing auth test and add an audit log call │ ┃ apra-fleet as o │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ▾ Members 0 ⎿ Read 6 lines │ ┃ connecting... ⏺ Update(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ▸ 🔑 Secrets 0 ⏺ Bash(bun test) │ ┃ refresh 30s · press name to insert token ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fleet-status │ ⎿ fleet-status: connecting... │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ fleet-status: connecting...

Draws

Pane · Fleet
apra-fleet as of 17:11:02 ────────────────────────────────────────────────────── ▾ Members 0 connecting... ────────────────────────────────────────────────────── ▸ 🔑 Secrets 0 add refresh 30s · press name to insert token
README

<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%">

apra-fleet

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.

CI License: Apache 2.0 Platform MCP Ask DeepWiki

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.


Why a fleet?

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:

  • Which machine runs which agent? Real devices, not throwaway sandboxes: registered, credentialed, health-checked members you already own.
  • Which model does which job? Claude for review, a cheap tier for mechanical edits, a local vLLM model for private data -- all in one fleet, routed by cost tier, switchable per task.
  • Who watches the agents? Durable workflows with supervisors, watchdogs, reservations, and live dashboards. Agents that die get detected. Work that stalls gets resumed. Nothing runs silently.
  • Who holds the keys? Secrets entered out-of-band, never visible to any model. Per-provider permission composition. Network egress policy per credential.

One control plane. Any device. Any model. Any workflow. Any domain.

What you get

PillarConcretely
Any deviceRegister 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 modelClaude, 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 workflowWorkflows 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 domainNot 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%">

Watch a fleet work

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:

  • Multi-cycle sprints running for hours, unattended
  • 2,300+ unit tests and an 81-file integration suite against real backends
  • Files bugs against itself, decomposes them, fixes them, and blocks its own release until quality gates pass
  • Every dispatch, verdict, and dollar visible live on the dashboard
  • Every sprint's raw child stdout/stderr is captured to a per-sprint log and linked from the dashboard, so a run's output is traceable even if it crashes before reporting anything back

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).

Quick start (5 minutes)

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 called reviewer. 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.

How it works

Layered Architecture

<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

Fleet Dispatch Topology

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)"]
  • Fleet server: the control plane. Registers members, dispatches commands and prompts, moves files, brokers credentials. Speaks MCP, so any MCP-capable agent can drive a fleet. 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.
  • Members: real machines running provider CLIs. Composes provider-native permissions before every dispatch; unattended modes are scoped, never blanket.
  • Workflow engine: runs workflow programs with phases, retries, turn budgets, resumable sessions, per-activity persistent state, and a cooperative pause/resume gate any workflow can hook into.
  • Supervisor: always-on layer -- launch, pause/resume, & stop sprints over HTTP, member reservation ledger, crash watchdog (including a live "paused" state and base-branch-drift indicator), run history, and a supervisor-owned LLM-less backlog member that every launch is pinned to. The dashboard and backlog read a cached, tip-checked beads view and show when it was last refreshed ("Beads as of"); launch always forces a fresh beads check and answers 503, without reserving members or starting a sprint, when beads cannot be verified.

Knowledge Layer

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:

ToolWhat it does
kb_session_primePrime a session: stale files, fresh summaries, GitNexus call list
kb_captureStore a learning, context-cache, runbook, or knowledge entry
kb_queryTwo-level FTS retrieval (L1: title+summary, L2: full content)
kb_listAudit-list entries by confidence/type/module/symbol (read-only, no use_count bump)
kb_contextBatch file freshness check (single git call for N files)
kb_invalidateMark files stale immediately (also called by the git hook), or discard own entries by ids
kb_promoteAdvance confidence: UNVERIFIED -> INFERRED -> CONFIRMED (FULL session, or the sprint's kb_maintainer member session only)
kb_harvestExtract learnings from a session transcript (auto-fires after execute_prompt)
kb_exportAdditively 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_commitMerge 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_setupInstall 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.

Explore with agents. Operate with programs.

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:

  • Exploration mode. While a workflow is still being discovered, let an LLM orchestrate: flexible, adaptive, and token-hungry -- every step is a decision, and every decision costs thinking.
  • Operation mode. Once you know what must happen, the control flow becomes a deterministic workflow program. Shell, git, and file steps run through 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 flowthe model decides each step (tokens)deterministic program (free)
Shell / git / file stepsnarrated through the modelexecute_command, zero tokens
Where the model runseverywherejudgment nodes only (execute_prompt)
Cost curvescales with every stepscales with thinking only
Failure modedrift and silent retriestyped 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.

Compare to alternatives

ToolOverlapWhere apra-fleet differs
Single-agent coding assistantsAI writes codeA fleet adds agents that review, test, and deploy each other's work -- across vendors.
CI self-hosted runnersRuns work on other machinesConversational and stateful, not pipeline-triggered; agents carry context between phases.
SkyPilot / dstackMulti-machine computeCoordinates agents and their context, credentials, and permissions -- not just jobs.
Google A2AAgent-to-agent messagingAn opinionated orchestration and operations layer, not just a transport.
Agent frameworks (LangGraph, CrewAI, ...)Multi-agent logicThose 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.

Security model, in one paragraph

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.

Email Configuration

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.

Storing secrets (one-time setup)

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.

Sending email from a workflow

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 {
Source 2 files
hooks/register.tsx 907 lines
1// 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}
907
types/index.d.ts 89 lines
1export 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