Specialists execution runtime in Claude Code: specialist activation and dispatch over MCP, session continuity hooks, and the supervising-activations skill…

Deep material lives in docs/ — start with docs/installation.md, docs/bootstrap.md, docs/workflow.md, and docs/cli-reference.md; sp --help is authoritative for flags.
Specialists is an agent-mind runtime for getting real work done.
Two surfaces: native XTRM activation, where the Substrate Issue is the durable executable work authority (see the specialists Claude Code plugin below); and the Beads-backed job workflow, which remains available through the legacy sp CLI (sp run, sp feed, sp result, sp resume, sp steer, sp stop).
It is not just “run many agents”. The core idea is that a long single-agent chat becomes cognitively contaminated: old hypotheses, abandoned plans, tool residue, self-review bias, forgotten constraints, and context-window noise all accumulate in one mind. Quality drops because the same context tries to be explorer, implementer, tester, reviewer, security auditor, memory keeper, and release operator at once.
Specialists gives an AI workflow a healthier shape:
The result is a shared project mind: continuity without hoarding every detail in one agent’s context.
Specialists sits in the xt/xtrm stack:
.xtrm/ skills/hooks, reports, update tooling, and gates.@jaggerxtrm/substrate) provides durable Issues/revisions, claims, Journal continuity, ExecutionBinding, provenance, and explicit Closure. Beads remains migration/legacy compatibility only.See specialists.scheme.md for the full rationale.
flowchart LR
Long[One long agent session] --> Residue[Context residue]
Long --> Bias[Self-review bias]
Long --> Drift[Goal drift]
Long --> Noise[Tool/output noise]
Long --> Fatigue[Instruction fatigue]
Residue --> Bad[Lower-quality decisions]
Bias --> Bad
Drift --> Bad
Noise --> Bad
Fatigue --> Bad
Bad --> Symptoms[Symptoms]
Symptoms --> Vibes[Reviews become vibes]
Symptoms --> Mirrors[Tests mirror implementation]
Symptoms --> Scope[Scope silently widens]
Symptoms --> Forgotten[Constraints disappear]
The problem is not only token count. It is cognitive contamination. A single context window carries every role’s history, including false starts and stale assumptions. The agent starts defending its own implementation, testing what it built instead of what was requested, and treating completion claims as proof.
Specialists replaces context hoarding with contract-bound cognition.
flowchart TD
U[User / project need] --> O[Orchestrator\ncentral executive]
O --> B[Substrate Issue revision\nproblem · scope · success · validation]
B --> Check{Contract ready?}
Check -->|repair needed| Refine[Refine scope / constraints / outputs]
Refine --> B
Check -->|ready| O
O --> Choose{Choose faculty}
Choose --> E[Explorer\nfresh read-only context]
Choose --> D[Debugger\nfresh root-cause context]
Choose --> X[Executor\nfresh implementation context]
Choose --> QA[Test-engineer / test-runner\nfresh validation context]
Choose --> R[Seconder / reviewer\nfresh judgment context]
Choose --> Docs[Sync-docs / service-skills\nfresh documentation context]
Choose --> Research[Researcher / domain specialist\nfresh external evidence]
B --> E
B --> D
B --> X
B --> QA
B --> R
B --> Docs
B --> Research
Rules[Mandatory rules\npermissions · tools · output schema] --> E
Rules --> D
Rules --> X
Rules --> QA
Rules --> R
Rules --> Docs
Rules --> Research
E --> H[Structured handoff / evidence]
D --> H
X --> H
QA --> H
R --> H
Docs --> H
Research --> H
H --> O
O --> Decision{Next decision}
Decision -->|resume / steer| Choose
Decision -->|fix loop| B
Decision -->|publish| Merge[Merge / PR / release]
Decision -->|done| Close[Explicit Issue Closure\nafter verified evidence]
This is close to how a human mind works: a central executive does not consciously compute every perception, motor skill, language move, and memory lookup at once. It activates specialized faculties, receives summaries/evidence, and decides what to do next.
Specialists gives an AI workflow the same structure. The orchestrator remains the “self”; specialists are bounded capabilities that can be activated without permanently polluting the central context.
| Need | Use |
|---|---|
| Turn vague work into an executable task contract | Substrate Issue + planner / orchestrator |
| Map unfamiliar local code | sp run explorer --bead <id> |
| Diagnose a bug with unknown cause | sp run debugger --bead <id> |
| Implement a scoped change in an isolated workspace | sp run executor --bead <id> --worktree |
| Add tests from the actual implementation diff | test-engineer |
| Run and classify validation commands | test-runner |
| Check scope/quality before final review | seconder |
| Review implementation evidence against the pinned Issue contract | reviewer/seconder against the same pinned revision; legacy sp run ... --bead remains a compatibility surface |
| Research current docs, repos, APIs, papers, or domain evidence | researcher, quant-researcher, transcriber |
| Sync one stale doc safely | sync-docs |
| Keep service-expert skill docs aligned with code drift | service-knowledge-sync |
| Generate immediate JSON/text from a specialist | sp script or sp serve |
| Watch all active specialist work across repos | sp console |
| Inspect runtime evidence and telemetry | sp feed, sp log, sp forensic, sp metrics |
| Configure package specialists for your machine | sp init --global, sp edit --global, sp setup |
The live catalog is authoritative:
sp list
sp list --compact
sp list-rules
sp help
Specialists is Bun-first and expects xtrm-tools to be installed explicitly. xtrm-tools is a runtime prerequisite, not an npm dependency of this package.
# 1. Bun
curl -fsSL https://bun.sh/install | bash
bun --version
# 2. xtrm-tools
npm install -g xtrm-tools
xt install
xt init
# 3. Specialists
npm install -g @jaggerxtrm/specialists
sp init --global # machine-level user config and model defaults
sp setup --discovery # inspect available models/config gaps
sp setup --plan cheap # optional: propose model assignments
sp init # per-repo wiring: MCP, hooks, skills, db paths
sp doctor --specialists
sp list
sp is an alias for specialists.
Specialists ships two Claude Code plugins in the xtrm marketplace. specialists exposes specialist activation as MCP tools and injects live activation state at session start. specialists-ui is optional and draws the rows specialists raises as teammate-style transcript rows. Both are opt-in: nothing installs or enables them for you.
claude plugin marketplace add xtrm-dev/specialists
claude plugin install specialists@xtrm
claude plugin install specialists-ui@xtrm # optional: teammate-style transcript rows
specialists-ui draws the rows the specialists plugin raises in Claude Code's own style. A channel wake becomes a teammate row, ● Specialist @explorer:d65bbed4 completed, with the wake's brief under it: run cost and purpose, the first result lines, the failing model and error, or the question. ctrl+o on a finished or failed wake shows the full result. A Specialists MCP call becomes a native tool row, ● Dispatch(executor · XTRM-4), with its result on an indented line under it. It is a separate plugin because Claude Code never runs a plugin's own drawing hooks on a row that plugin raised, and the wake comes from the specialists MCP server. Without it, everything works and the rows show as raw text.
/specialists command and the fleet bandThe specialists plugin draws the live fleet above the prompt and opens a fleet pane with /specialists. Select a row to read its result or its event feed, or to reply, steer, resume or stop it. A toast reports each activation that finishes, fails or asks a question.
| Command | Effect | |||
|---|---|---|---|---|
/specialists | open or close the fleet pane | |||
/specialists status [activation] | the fleet report, or one activation in detail | |||
/specialists result <activation> | the full result of a settled activation | |||
/specialists feed <activation> [lines] | the activation's event feed, like sp feed | |||
/specialists reply <message_id> <answer> | answer a waiting activation | |||
| `/specialists steer\ | resume\ | stop <activation> …` | act on an activation | |
| `/specialists show\ | hide\ | expand\ | collapse` | band visibility |
<activation> is a full id or a short prefix. result and feed also reach activations from earlier sessions. The coordinator has the same feed as the specialist_feed tool.
To turn every wake off (the channel push, the wake-watch fallback and the toasts), start Claude Code with SPECIALISTS_WAKE=off. Everything stays readable through specialist_status. This is the Claude Code equivalent of the Pi extension's --no-specialist-wake.
The plugins and the runtime update separately. Both carry the package version.
/plugin, open Marketplaces, select xtrm, and choose Enable auto-update. For a whole fleet, set "autoUpdate": true on the xtrm entry of extraKnownMarketplaces in managed settings. Without auto-update, run claude plugin marketplace update xtrm, then claude plugin update specialists@xtrm (and specialists-ui@xtrm). Run /reload-plugins or restart to apply.npm i -g @jaggerxtrm/specialists@latest, then restart Claude Code. When the plugin and the runtime versions differ, the plugin's MCP launcher writes a warning to stderr with the exact install command, and starts anyway.| Piece | Needed for | Set up by |
|---|---|---|
Specialists runtime (@jaggerxtrm/specialists) | the MCP server; the plugin launcher starts it | npm install -g @jaggerxtrm/specialists (see Install and bootstrap) |
Bun on PATH | the MCP server and every plugin hook script | install Bun |
| Substrate work store | specialist_dispatch (the contract) | @jaggerxtrm/substrate installed, or XTRM_SUBSTRATE_DIR (below) |
| Claude Code with plugin hooks modules (verified on 2.1.288) | the fleet band, the fleet pane, /specialists, and all specialists-ui rows | a current Claude Code; hooks must not be turned off with disableAllHooks |
specialists@xtrm | MCP tools, session-start state, the fallback wake, the band, pane and command | claude plugin install specialists@xtrm |
specialists-ui@xtrm | teammate-style wake rows and native tool rows | claude plugin install specialists-ui@xtrm |
--channels plugin:specialists@xtrm | the channel wake (primary) | launch flag; xt claude passes it for you |
Managed settings channelsEnabled + allowedChannelPlugins | the channel wake (primary) | root, /etc/claude-code/managed-settings.json (below) |
The plugin hooks API (the band, pane and rows) is early access in Claude Code and can change between releases. If a row or the band stops drawing after a Claude Code update, run claude --debug and search the log for ui.render to see whether a hook was skipped or refused.
Verify the server is reachable from Claude Code:
claude mcp list | grep specialists
# plugin:specialists:specialists: ... - ✔ Connected
Dispatch needs the Substrate work store, which supplies the contract. It is resolved in this order: an explicit XTRM_SUBSTRATE_DIR, then normal module resolution of the installed @jaggerxtrm/substrate package, then the npm global prefix, then failure. No environment variable is required as long as the package is installed in one of those places:
npm install @jaggerxtrm/substrate # in the project the session runs in
npm install --global @jaggerxtrm/substrate # or beside a globally installed Specialists
The npm global prefix is searched because a global install from a FOLDER is a symlink: default resolution dereferences it and walks the checkout's ancestors, which never reach <prefix>/lib/node_modules, so a sibling global Substrate would otherwise be invisible. xt init enrolls Substrate into that prefix, which is why the XTRM-managed path needs no extra step.
Set XTRM_SUBSTRATE_DIR only to override that with a local checkout, for example when developing Substrate itself. It wins over the installed package when set.
If neither is available, specialist_dispatch is refused with work_item_store_unavailable while specialist_status and specialist_list keep working — so the plugin looks healthy until you try to dispatch. An override must be in the environment the session is launched with; a shell export afterwards does not reach the already-running MCP server.
export XTRM_SUBSTRATE_DIR=/path/to/xtrm/packages/substrate
claude
The plugin requires Bun on PATH and reads Substrate's canonical store, resolved from SUBSTRATE_DB, else XTRM_STATE_DB, else ~/.xtrm/state.db. SUBSTRATE_DB comes first because the store belongs to Substrate and is shared with sb and Pi. To develop against a checkout instead of an install, load both plugins: claude --plugin-dir ./plugins/specialists --plugin-dir ./plugins/specialists-ui. Loading only ./plugins/specialists gives the runtime without the transcript rows.
When an activation settles, fails, escalates or asks a question, the plugin's MCP server pushes a short notice into the coordinator session (a Claude Code Channel). The notice wakes an idle session; the authoritative read of a result is specialist_result (specialist_status for asks and state). Claude Code drops the push without any error unless all of the following are true, so configure both parts.
claude --channels plugin:specialists@xtrm
/etc/claude-code/managed-settings.json or a file under /etc/claude-code/managed-settings.d/: {
"channelsEnabled": true,
"allowedChannelPlugins": [{ "plugin": "specialists", "marketplace": "xtrm" }]
}
Set both keys. ~/.claude/settings.json is not read for either key. Without a managed allowlist, Claude Code uses a default list fetched from Anthropic that does not include this plugin. claude.ai Team and Enterprise plans need channelsEnabled: true in every case. For a login that is not claude.ai (an API key, for example), any managed-settings file without channelsEnabled: true turns channels off, so a file that only holds the allowlist makes delivery worse, not better.
For local development without root, launch with claude --dangerously-load-development-channels plugin:specialists@xtrm instead of --channels. A channel loaded this way skips the allowlist. Use it only for a plugin you build yourself.
The push also needs the legacy (2025-11-25) MCP protocol revision, because the stateless 2026-07-28 revision has no path for a message the server sends unprompted. You do not need to configure this: the plugin's launcher serves the legacy revision only, so Claude Code downgrades this one server and keeps negotiating normally with every other server. Do not set MCP_PROTOCOL_NEGOTIATION=legacy for this; it pins every MCP server to the legacy handshake.
If the channel wake is not available, a fallback still wakes the session: the plugin's wake-watch hook starts with each session, watches the Substrate store, and wakes the session once when an activation settles or asks a question. It stops after that first wake or after about 15 minutes, so it is a safety net, not a replacement for the channel. The deployed companion specialists-ui plugin acknowledges each channel wake (a marker under ~/.xtrm/wake-acks), and this watcher drops any event that marker covers, so a push that arrived is not woken a second time; without that plugin it falls back to today's behaviour. specialist_result is the authoritative read either way.
Check the result with specialists doctor --channels, which reports the first closed gate. Channels work only in an interactive session; claude -p never receives them. Behaviour was read from Claude Code 2.1.288; see docs/claude-channel-constraints.md for the full gate chain.
Package specialist definitions ship with execution.model = null. This is intentional: the package defines roles, tools, contracts, and safety boundaries; your machine-level config defines provider/model choices.
Use:
sp init --global
sp edit --global
sp setup --fetch-benchmarks --json
sp setup --plan <budget-preset>
sp doctor --specialists
The loader merges configuration in this order:
~/.config/specialists/user.json global overrides;.specialists/user/ repo-local overrides.See docs/installation.md, docs/bootstrap.md, and docs/authoring.md.
bd create "Investigate flaky checkout flow" -t bug -p 1 --json
bd update <id> --claim --json
sp run explorer --bead <id> --context-depth 2
sp feed <job-id> --follow
sp result <job-id>
sp run debugger --bead <id> --context-depth 3
sp run executor --bead <id> --worktree
sp run reviewer --bead <id> --job <executor-job>
bd close <id> --reason "Root cause found, fix reviewed" --json
Ad-hoc one-offs are still supported, but tracked work should use beads:
sp run explorer --prompt "Map the CLI architecture"
sp console is the multi-repo terminal dashboard for live specialist work.
It provides:
~/.config/specialists/console.json);↵, r, i, b, d, g, R, x, 0, tab, 1-9);sp ps.sp console
# Press R, then + to add a repository; select one and press d to remove it.
For shell-only workflows:
sp ps
sp feed <job-id>
sp feed -f
sp result <job-id>
sp log <job-id>
sp steer <job-id> "focus only on the API boundary"
sp resume <job-id> "continue with this additional evidence"
sp stop <job-id>
sp clean --reap-orphans --dry-run
Specialists separates doing work from publishing work.
executor, debugger, test-engineer, and sync-docs may create changes.seconder, test-runner, and reviewer produce evidence/verdicts.sp merge and sp epic merge exist but are currently marked broken. Do not use them.Follow the merge and integration procedure for reviewed publication work. sp epic status <epic-id> remains available for inspection.
Keep-alive specialists may stop in waiting after producing a result. Use sp result <job-id> to read the handoff, then sp stop <job-id> when no follow-up is needed.
Use sp run for interactive agent orchestration. Use sp script / sp serve when you need an immediate one-shot generation contract from a specialist.
sp script <name> --vars key=value --json
sp serve --port 8000 --readiness-canary warn
curl -sS http://localhost:8000/v1/generate \
-H 'content-type: application/json' \
-d '{"specialist":"hello","variables":{"name":"world"}}'
sp script flags:
sp script <name> [--vars k=v ...] [--template <text> | --template-field <name>] \
[--model <override>] [--thinking <level>] [--json] \
[--allow-local-scripts] [--allow-write-capable] [--single-instance <lockpath>]
sp serve flags (HTTP sidecar for the same runtime path):
sp serve [--port <n>] [--concurrency <n>] [--project-dir <path>] \
[--allow-skills] [--allow-skills-roots <p1>:<p2>]
Script/service mode is useful for CI, internal services, deterministic JSON generation, and sidecar deployments. Only sp script supports trusted local scripts or write-capable execution through --allow-local-scripts and --allow-write-capable. sp serve supports neither — it remains READ_ONLY. Skills remain disabled unless --allow-skills is set; --allow-skills-roots restricts accepted canonical skill sources when supplied, but it is not a filesystem read boundary. Specialists 3.21.6 does not provide host-read isolation: allowed tools, extensions, MCP processes, and child processes can read paths visible to the runtime identity. Its bounded waiver permits only trusted single-tenant callers with private authenticated ingress, a dedicated container or OS account, minimal mounts, least-privilege credentials, trusted definitions, and reviewed extension sources. Untrusted, public, cross-tenant, and multi-tenant deployments are excluded. The waiver does not authorize publication and expires at 3.21.7.
See docs/specialists-service.md and docs/specialists-service-install.md.
Specialists is DB-first. Runtime state lives in .specialists/db/observability.db; file mirrors under .specialists/jobs/ are legacy/operator recovery surfaces.
Useful surfaces:
sp ps # dashboard row view
sp feed <job-id> # event stream replay
sp log <job-id> # control/status/error log
sp forensic <job-id> --json # persisted forensic envelopes
sp metrics --prometheus # low-cardinality metrics
sp serve --port 8000 # exposes /metrics and job feed endpoints
Telemetry uses bounded labels and avoids high-cardinality IDs in Prometheus labels. Forensic events retain drill-down detail in SQLite/JSON output where IDs are appropriate.
hooks/register.tsx 687 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { Elements, EngineInterface, McpToolResult, On } from 'claude-code'
5
6// The live Specialists fleet of THIS session, drawn in the band above the prompt: the
7// Claude-native twin of the Pi native-specialists footer section
8// (config/pi-extensions/native-specialists). Presentation and control only — every read
9// and every action goes through the session's own Specialists MCP server, whose in-process
10// host is the fleet this session dispatched. No state of its own beyond the last read.
11
12/** The plugin's MCP server as /mcp lists it (`plugin:<plugin>:<server>`). */
13export const MCP_SERVER = 'plugin:specialists:specialists'
14export const COMMAND = 'specialists'
15/**
16 * Fallback cadence when the server has no blocking wait (an older runtime strips the
17 * wait arguments and answers instantly) or when it is failing fast: the band then polls
18 * at this interval, exactly the pre-4218 behaviour. With a server that blocks, calls run
19 * ~WAIT_TIMEOUT_S apart and this timer only re-arms the watch between calls.
20 */
21export const POLL_MS = 2000
22/** Server-side block per watch call. Under the common 30s MCP client tool timeout. */
23export const WAIT_TIMEOUT_S = 25
24/** A call that returns faster than this did not block: the server lacks the wait (or answered a change instantly). */
25const FAST_RETURN_MS = 1000
26/** Consecutive instant returns before the watch parks on the POLL_MS tick: three instant answers are an unsupported wait, not three coincidences. */
27const FAST_STREAK_MAX = 3
28export const FLEET_MAX_ROWS = 6
29/** Above this many activations the band folds to one line and the pane holds the list. */
30export const COLLAPSE_AT = 3
31export const PANE_ID = 'specialists-fleet'
32export const PURPOSE_ROW_MAX = 60
33
34const SPINNER_FRAMES = ['◐', '◓', '◑', '◒']
35const ACCENT = '#9a8bff'
36
37export type Activation = {
38 activation_id: string
39 specialist?: string
40 bead_id?: string
41 state?: string
42 resolved_model?: string
43 thinking_level?: string
44 elapsed_s?: number
45 turn_count?: number
46 token_usage?: Record<string, number | undefined>
47 purpose?: string
48 result_status?: string
49}
50
51export type Ask = {
52 message_id: string
53 kind?: string
54 activation_id: string
55 from?: string
56 body?: string
57 asked_at?: number
58}
59
60export type Fleet = { activations: Activation[]; asks: Ask[] }
61
62const USAGE =
63 'Usage: /specialists [status [activation]|show|hide|expand|collapse] · result <activation> · ' +
64 'feed <activation> [lines] · reply <message_id> <answer> · steer <activation> <message> · ' +
65 'resume <activation> <prompt> · stop <activation> [reason]'
66
67/** Feed lines the pane's feed view asks for; the newest are kept. */
68export const FEED_LINES = 30
69/** Detail lines drawn in the pane; the band draws fewer so the prompt stays in view. */
70export const DETAIL_PANE_LINES = 24
71export const DETAIL_BAND_LINES = 6
72/** How long a transition toast stays up. */
73export const TOAST_MS = 6000
74
75const isActive = (state?: string) => state === 'running' || state === 'starting'
76
77function textOf(result: McpToolResult): string {
78 return result.content
79 .map(block => ('text' in block && typeof block.text === 'string' ? block.text : ''))
80 .filter(Boolean)
81 .join('\n')
82}
83
84/**
85 * One MCP tool call, its JSON payload or the reason it failed. A transport error, an
86 * `isError` result and a `{ status: 'error' }` payload are all failures: the fleet must
87 * never read an unreachable server as an empty one.
88 */
89export async function callTool(
90 $: EngineInterface,
91 tool: string,
92 args: Record<string, unknown> = {},
93): Promise<{ ok: true; value: Record<string, unknown> } | { ok: false; error: string }> {
94 let result: McpToolResult
95 try {
96 result = await $.mcp.call(MCP_SERVER, tool, args)
97 } catch (cause) {
98 return { ok: false, error: cause instanceof Error ? cause.message : String(cause) }
99 }
100 const text = textOf(result)
101 if (result.isError) return { ok: false, error: text || `${tool} failed` }
102 let value: unknown
103 try {
104 value = JSON.parse(text)
105 } catch {
106 return { ok: false, error: `${tool} returned non-JSON: ${text.slice(0, 120)}` }
107 }
108 if (!value || typeof value !== 'object') return { ok: false, error: `${tool} returned no object` }
109 const record = value as Record<string, unknown>
110 if (record.status === 'error') return { ok: false, error: String(record.error ?? `${tool} failed`) }
111 return { ok: true, value: record }
112}
113
114export function fleetOf(value: Record<string, unknown>): Fleet {
115 const activations = Array.isArray(value.activations) ? (value.activations as Activation[]) : []
116 const asks = Array.isArray(value.pending_asks) ? (value.pending_asks as Ask[]) : []
117 return {
118 activations: activations.filter(a => a && typeof a.activation_id === 'string'),
119 asks: asks.filter(a => a && typeof a.message_id === 'string'),
120 }
121}
122
123/** Buckets are exclusive, so they sum to the entry count (as the Pi header does). */
124export function summaryOf({ activations, asks }: Fleet) {
125 const blocked = new Set(asks.map(a => a.activation_id))
126 const running = activations.filter(a => isActive(a.state) && !blocked.has(a.activation_id)).length
127 const blockedCount = activations.filter(a => blocked.has(a.activation_id)).length
128 return {
129 running,
130 blocked: blockedCount,
131 waiting: Math.max(0, activations.length - running - blockedCount),
132 total: activations.length,
133 }
134}
135
136export function headerOf(fleet: Fleet): string {
137 const { running, waiting, blocked, total } = summaryOf(fleet)
138 if (total === 0) return 'idle'
139 const parts: string[] = []
140 if (running > 0) parts.push(`${running} running`)
141 if (waiting > 0) parts.push(`${waiting} waiting`)
142 if (blocked > 0) parts.push(`! ${blocked} blocked`)
143 return parts.join(' • ')
144}
145
146/** `42s`, `2m14s`, `1h05m`. */
147export function elapsedShort(seconds?: number): string {
148 const total = Math.max(0, Math.floor(seconds ?? 0))
149 if (total < 60) return `${total}s`
150 if (total < 3600) return `${Math.floor(total / 60)}m${String(total % 60).padStart(2, '0')}s`
151 return `${Math.floor(total / 3600)}h${String(Math.floor((total % 3600) / 60)).padStart(2, '0')}m`
152}
153
154/** Spend counts only; `total_tokens` is a rollup, never a summand. */
155export function spendShort(usage?: Record<string, number | undefined>): string {
156 if (!usage) return ''
157 const keys = ['input_tokens', 'output_tokens', 'cache_creation_tokens', 'cache_read_tokens', 'reasoning_tokens', 'tool_tokens']
158 const total = keys.reduce((n, k) => n + (usage[k] ?? 0), 0)
159 if (total <= 0) return ''
160 if (total < 1000) return `${total}`
161 if (total < 10000) return `${(total / 1000).toFixed(1)}k`
162 return `${Math.round(total / 1000)}k`
163}
164
165export function purposeShort(purpose?: string): string {
166 const flat = String(purpose ?? '').replace(/\s+/g, ' ').trim()
167 return flat.length <= PURPOSE_ROW_MAX ? flat : `${flat.slice(0, PURPOSE_ROW_MAX - 1)}…`
168}
169
170const shortId = (id: string) => (id.startsWith('act:') ? id.slice(4, 12) : id.slice(0, 8))
171
172export function markerOf(row: Activation, ask: Ask | undefined, nowMs: number): string {
173 if (ask) return '!'
174 if (row.state === 'settled') return '✓'
175 if (row.state === 'failed') return '✕'
176 if (isActive(row.state)) return SPINNER_FRAMES[Math.floor(nowMs / 250) % SPINNER_FRAMES.length]!
177 return '●'
178}
179
180export function metricsOf(row: Activation, ask: Ask | undefined, nowMs: number): string {
181 if (ask) return `waiting ${elapsedShort((nowMs - (ask.asked_at ?? nowMs)) / 1000)}`
182 const parts = [elapsedShort(row.elapsed_s)]
183 if (row.turn_count != null) parts.push(`${row.turn_count}t`)
184 const spend = spendShort(row.token_usage)
185 if (spend) parts.push(spend)
186 if (row.result_status) parts.push(row.result_status)
187 return parts.join(' • ')
188}
189
190/** Blocked first, then live work, then the rest; each group keeps the host's order. */
191export function orderedOf({ activations, asks }: Fleet): Activation[] {
192 const blocked = new Set(asks.map(a => a.activation_id))
193 const rank = (a: Activation) => (blocked.has(a.activation_id) ? 0 : isActive(a.state) ? 1 : 2)
194 return [...activations].sort((a, b) => rank(a) - rank(b))
195}
196
197/** The plain-text fleet report `/specialists` prints (every surface, not only the band). */
198export function reportOf(fleet: Fleet, error: string | null, nowMs: number): string {
199 const lines = [`Specialists · ${headerOf(fleet)}`]
200 if (error) lines.push(`status unavailable · ${error}`)
201 for (const row of orderedOf(fleet)) {
202 const ask = fleet.asks.find(a => a.activation_id === row.activation_id)
203 lines.push(
204 ` ${markerOf(row, ask, nowMs)} ${row.specialist ?? 'specialist'} ${row.activation_id} ${row.bead_id ?? '—'} ${metricsOf(row, ask, nowMs)}`,
205 )
206 if (ask) lines.push(` ${ask.kind ?? 'ask'} ${ask.message_id}: ${ask.body ?? ''}`)
207 }
208 return lines.join('\n')
209}
210
211/**
212 * The toasts one fleet read owes the operator: an activation that newly finished or failed,
213 * and an ask that newly appeared — the Claude Code twin of the Pi extension's `ui.notify`.
214 * Pure over two reads, so a missed read cannot replay old events: the FIRST read after start
215 * (prev null) owes nothing, since everything in it predates this session's view.
216 */
217export function transitionsOf(prev: Fleet | null, next: Fleet): string[] {
218 if (!prev) return []
219 const before = new Map(prev.activations.map(a => [a.activation_id, a.state]))
220 const askedBefore = new Set(prev.asks.map(a => a.message_id))
221 const out: string[] = []
222 for (const row of next.activations) {
223 if (before.get(row.activation_id) === row.state) continue
224 const who = `${row.specialist ?? 'specialist'}:${shortId(row.activation_id)}`
225 if (row.state === 'settled') out.push(`Specialist ${who} finished${row.bead_id ? ` · ${row.bead_id}` : ''}`)
226 if (row.state === 'failed') out.push(`Specialist ${who} FAILED${row.bead_id ? ` · ${row.bead_id}` : ''}`)
227 }
228 for (const ask of next.asks) {
229 if (askedBefore.has(ask.message_id)) continue
230 const row = next.activations.find(a => a.activation_id === ask.activation_id)
231 const who = `${row?.specialist ?? ask.from ?? 'specialist'}:${shortId(ask.activation_id)}`
232 out.push(`Specialist ${who} ${ask.kind === 'escalation' ? 'escalated' : 'asked a question'}`)
233 }
234 return out
235}
236
237/** The lines a result read draws: the output, or why there is none yet. */
238export function resultLinesOf(value: Record<string, unknown>): string[] {
239 if (typeof value.output === 'string') {
240 const head = [value.status, value.resolved_model].filter(v => typeof v === 'string' && v).join(' · ')
241 const body = value.output.trim() ? value.output.replace(/\n+$/, '').split('\n') : ['(empty output)']
242 return head ? [head, ...body] : body
243 }
244 if (typeof value.next === 'string') return [`${String(value.state ?? 'not settled')} · use ${value.next}`]
245 return ['no result']
246}
247
248/** The lines a feed read draws. */
249export function feedLinesOf(value: Record<string, unknown>): string[] {
250 const events = Array.isArray(value.events) ? value.events.filter((e): e is string => typeof e === 'string') : []
251 if (events.length === 0) return ['no events yet']
252 return value.truncated === true ? [`… ${Number(value.total ?? 0) - events.length} earlier events`, ...events] : events
253}
254
255/** An activation by its full id or an unambiguous prefix (with or without `act:`). */
256export function resolveActivation(fleet: Fleet, ref: string): Activation | string {
257 const needle = ref.startsWith('act:') ? ref : `act:${ref}`
258 const hits = fleet.activations.filter(a => a.activation_id === ref || a.activation_id.startsWith(needle))
259 if (hits.length === 1) return hits[0]!
260 return hits.length === 0 ? `Unknown activation: ${ref}` : `Ambiguous activation: ${ref}`
261}
262
263/** What the band draws from: the last read and the operator's view choices. */
264type State = {
265 fleet: Fleet
266 error: string | null
267 visible: boolean
268 expanded: boolean
269 selected: string | null
270 flash: string | null
271 polling: boolean
272 timer: { cancel: () => void } | null
273 paneOpen: boolean
274 /** A blocking specialist_status call is in flight (SPECIALISTS-4218). */
275 watching: boolean
276 /** Consecutive sub-second watch returns; parking on the tick after FAST_STREAK_MAX. */
277 fastStreak: number
278 /** The result or feed view open under the selected row; null when none is. */
279 detail: { kind: 'result' | 'feed'; id: string; lines: string[] } | null
280 /** The previous successful read, for transition toasts; null until the first one. */
281 last: Fleet | null
282 /** SPECIALISTS_WAKE=off silences toasts, as the Pi extension's --no-specialist-wake does. */
283 quiet: boolean
284}
285
286/** Apply one specialist_status read to the band state and redraw. */
287function apply($: EngineInterface, s: State, read: { ok: true; value: Record<string, unknown> } | { ok: false; error: string }): void {
288 if (read.ok) {
289 s.fleet = fleetOf(read.value)
290 s.error = null
291 if (!s.quiet) for (const text of transitionsOf(s.last, s.fleet)) $.ui.toast(text, { timeoutMs: TOAST_MS })
292 s.last = s.fleet
293 if (s.selected && !s.fleet.activations.some(a => a.activation_id === s.selected)) s.selected = null
294 if (s.detail && s.detail.id !== s.selected) s.detail = null
295 // A live feed keeps up with the activation; a settled one is read once.
296 const row = s.detail?.kind === 'feed' ? s.fleet.activations.find(a => a.activation_id === s.detail!.id) : undefined
297 if (row && isActive(row.state)) void loadDetail($, s, 'feed', row.activation_id)
298 } else {
299 s.error = read.error
300 }
301 $.ui.invalidate('ui.render')
302}
303
304async function refresh($: EngineInterface, s: State): Promise<void> {
305 if (s.polling) return
306 s.polling = true
307 try {
308 apply($, s, await callTool($, 'specialist_status'))
309 } finally {
310 s.polling = false
311 }
312}
313
314/**
315 * One blocking-watch cycle (SPECIALISTS-4218): call specialist_status with
316 * wait_for_change so the SERVER parks until the fleet changes or the timeout passes, then
317 * redraw and re-arm. Replaces the fixed 2s poll — the steady request stream that cost every
318 * idle session 0.03-0.1 core of MCP-server CPU. Self-sustaining while the server blocks; a
319 * server that answers instantly (no wait support, or failing fast) is detected by the
320 * streak and parks on the POLL_MS tick instead of spinning.
321 */
322async function watchOnce($: EngineInterface, s: State): Promise<void> {
323 if (s.watching) return
324 s.watching = true
325 try {
326 const startedAt = Date.now()
327 const read = await callTool($, 'specialist_status', { wait_for_change: true, timeout_s: WAIT_TIMEOUT_S })
328 const took = Date.now() - startedAt
329 apply($, s, read)
330 if (took >= FAST_RETURN_MS) {
331 s.fastStreak = 0
332 void watchOnce($, s) // blocked server: re-arm immediately, one call per WAIT_TIMEOUT_S
333 } else if (s.fastStreak < FAST_STREAK_MAX) {
334 s.fastStreak += 1 // maybe a real change answered in <1s: allow a couple before concluding
335 void watchOnce($, s)
336 } // else: instant answers are an unsupported wait — park on the POLL_MS tick
337 } finally {
338 s.watching = false
339 }
340}
341
342/** Read a result or feed into the detail view under the selected row. */
343async function loadDetail($: EngineInterface, s: State, kind: 'result' | 'feed', id: string): Promise<void> {
344 const read = kind === 'result'
345 ? await callTool($, 'specialist_result', { activation_id: id })
346 : await callTool($, 'specialist_feed', { activation_id: id, limit: FEED_LINES })
347 // The operator may have closed or switched the view while the call was in flight.
348 if (s.detail && (s.detail.id !== id || s.detail.kind !== kind)) return
349 s.detail = {
350 kind,
351 id,
352 lines: read.ok ? (kind === 'result' ? resultLinesOf(read.value) : feedLinesOf(read.value)) : [`${kind} unavailable · ${read.error}`],
353 }
354 $.ui.invalidate('ui.render')
355}
356
357function toggleDetail($: EngineInterface, s: State, kind: 'result' | 'feed', id: string): void {
358 if (s.detail?.kind === kind && s.detail.id === id) {
359 s.detail = null
360 $.ui.invalidate('ui.render')
361 return
362 }
363 s.detail = { kind, id, lines: ['loading…'] }
364 $.ui.invalidate('ui.render')
365 void loadDetail($, s, kind, id)
366}
367
368/** One control action; its answer is what the operator sees. */
369async function act($: EngineInterface, s: State, tool: string, args: Record<string, unknown>, done: string): Promise<string> {
370 const result = await callTool($, tool, args)
371 s.flash = result.ok ? done : `${tool} refused: ${result.error}`
372 await refresh($, s)
373 return s.flash
374}
375
376/** The elements both sites draw with; the terminal and desktop tables have all four. */
377type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Input'>
378
379/** Opens the fleet pane: docked beside a fullscreen transcript, else inline above the prompt. */
380async function openPane($: EngineInterface, s: State): Promise<void> {
381 await $.ui.open({
382 id: PANE_ID,
383 title: 'Specialists',
384 focus: true,
385 closeOnEscape: true,
386 rows: Math.min(48, s.fleet.activations.length * 2 + 8 + DETAIL_PANE_LINES),
387 })
388 s.paneOpen = true
389 $.ui.invalidate('ui.render')
390}
391
392async function closePane($: EngineInterface, s: State): Promise<void> {
393 await $.ui.close({ id: PANE_ID }).catch(() => undefined)
394 s.paneOpen = false
395 $.ui.invalidate('ui.render')
396}
397
398/**
399 * Rows and the selected row's controls: the one drawing the band and the pane share, so
400 * both sites read and act the same way. `site` keeps element keys apart between them.
401 */
402function fleetBody($: EngineInterface, s: State, ui: Kit, rows: Activation[], site: string, detailLines: number) {
403 const { Box, Text, Button, Input } = ui
404 const nowMs = Date.now()
405 const selectedRow = rows.find(a => a.activation_id === s.selected)
406 const selectedAsk = selectedRow && s.fleet.asks.find(a => a.activation_id === selectedRow.activation_id)
407
408 return (
409 <Box flexDirection="column">
410 {rows.map(row => {
411 const ask = s.fleet.asks.find(a => a.activation_id === row.activation_id)
412 const purpose = purposeShort(row.purpose)
413 const isSelected = row.activation_id === s.selected
414 return (
415 <Box key={`${site}:${row.activation_id}`} flexDirection="column">
416 <Button
417 plain
418 key={`row:${row.activation_id}`}
419 onPress={() => { s.selected = isSelected ? null : row.activation_id; s.flash = null; $.ui.invalidate('ui.render') }}
420 >
421 {`${isSelected ? '▾' : ' '} ${markerOf(row, ask, nowMs)} ${row.specialist ?? 'specialist'}:${shortId(row.activation_id)} ${row.bead_id ?? '—'}${purpose ? ` ${purpose}` : ''}`}
422 </Button>
423 <Text dimColor wrap="truncate-end">
424 {` ${row.resolved_model ?? '?model'}${row.thinking_level ? ` · ${row.thinking_level}` : ''} • ${metricsOf(row, ask, nowMs)}`}
425 </Text>
426 </Box>
427 )
428 })}
429
430 {selectedRow ? (
431 <Box flexDirection="column" paddingLeft={4}>
432 <Text dimColor>{selectedRow.activation_id} · {selectedRow.state ?? 'unknown'}</Text>
433 {selectedAsk ? (
434 <>
435 <Text color={ACCENT} wrap="wrap">{selectedAsk.kind ?? 'ask'}: {selectedAsk.body ?? ''}</Text>
436 <Input
437 key={`reply:${selectedAsk.message_id}`}
438 label="reply "
439 placeholder="answer the specialist"
440 submitLabel="reply"
441 autoFocus
442 onSubmit={value => { if (value.trim()) void act($, s, 'specialist_reply', { message_id: selectedAsk.message_id, body: value.trim() }, `Answered ${selectedAsk.message_id}.`) }}
443 />
444 </>
445 ) : isActive(selectedRow.state) ? (
446 <Input
447 key={`steer:${selectedRow.activation_id}`}
448 label="steer "
449 placeholder="redirect the running specialist"
450 submitLabel="steer"
451 onSubmit={value => { if (value.trim()) void act($, s, 'specialist_steer', { activation_id: selectedRow.activation_id, message: value.trim() }, `Steered ${selectedRow.activation_id}.`) }}
452 />
453 ) : (
454 <Input
455 key={`resume:${selectedRow.activation_id}`}
456 label="resume "
457 placeholder="next instruction, same session"
458 submitLabel="resume"
459 onSubmit={value => { if (value.trim()) void act($, s, 'specialist_resume', { activation_id: selectedRow.activation_id, prompt: value.trim() }, `Resumed ${selectedRow.activation_id}.`) }}
460 />
461 )}
462 <Box>
463 <Button key={`result:${selectedRow.activation_id}`} hotkey="r" onPress={() => toggleDetail($, s, 'result', selectedRow.activation_id)}>
464 {s.detail?.kind === 'result' ? 'hide result' : 'result'}
465 </Button>
466 <Text> </Text>
467 <Button key={`feed:${selectedRow.activation_id}`} hotkey="f" onPress={() => toggleDetail($, s, 'feed', selectedRow.activation_id)}>
468 {s.detail?.kind === 'feed' ? 'hide feed' : 'feed'}
469 </Button>
470 <Text> </Text>
471 <Button
472 key={`stop:${selectedRow.activation_id}`}
473 hotkey="x"
474 onPress={() => void act($, s, 'specialist_stop_activation', { activation_id: selectedRow.activation_id, reason: 'operator request' }, `Stopped ${selectedRow.activation_id}.`)}
475 >
476 stop
477 </Button>
478 </Box>
479 {s.detail && s.detail.id === selectedRow.activation_id ? (
480 <Box flexDirection="column">
481 {(s.detail.kind === 'feed' ? s.detail.lines.slice(-detailLines) : s.detail.lines.slice(0, detailLines)).map((line, i) => (
482 <Text key={`${site}:detail:${i}`} dimColor={s.detail!.kind === 'feed'} wrap="truncate-end">{line}</Text>
483 ))}
484 {s.detail.lines.length > detailLines ? (
485 <Text dimColor italic>{`… ${s.detail.lines.length - detailLines} more lines · /specialists ${s.detail.kind} ${shortId(selectedRow.activation_id)}`}</Text>
486 ) : null}
487 </Box>
488 ) : null}
489 </Box>
490 ) : null}
491
492 {s.flash ? <Text dimColor> {s.flash}</Text> : null}
493 </Box>
494 )
495}
496
497export function register(on: On) {
498 const s: State = {
499 fleet: { activations: [], asks: [] },
500 error: null,
501 visible: true,
502 expanded: true,
503 selected: null,
504 flash: null,
505 polling: false,
506 timer: null,
507 paneOpen: false,
508 watching: false,
509 fastStreak: 0,
510 detail: null,
511 last: null,
512 quiet: false,
513 }
514
515 on('session.start', async ($, e, next) => {
516 try {
517 await $.command.register({
518 name: COMMAND,
519 description: 'Live Specialists fleet: status, result, feed, reply, steer, resume, stop',
520 argumentHint: '[status|result|feed|show|hide|expand|collapse|reply|steer|resume|stop] …',
521 immediate: true,
522 })
523 } catch {
524 // Another Specialists surface may already serve the command.
525 }
526 s.quiet = String((await $.env.get('SPECIALISTS_WAKE').catch(() => undefined)) ?? '').toLowerCase() === 'off'
527 s.timer?.cancel()
528 // The tick is the skeleton, not the heartbeat: it re-arms the blocking watch whenever
529 // no call is in flight (server without wait support, or between re-arms), while a
530 // server that blocks makes each call last WAIT_TIMEOUT_S — the request rate of an
531 // idle session drops from one per POLL_MS to one per WAIT_TIMEOUT_S (SPECIALISTS-4218).
532 s.timer = $.clock.every(POLL_MS, () => void watchOnce($, s))
533 void refresh($, s)
534 void watchOnce($, s)
535 return next(e)
536 })
537
538 on('command.run', { command: COMMAND }, async ($, e) => {
539 const trimmed = e.args.trim()
540 const [verb = '', ref = '', ...rest] = trimmed.split(/\s+/)
541 const tail = rest.join(' ')
542
543 if (verb === '') {
544 await refresh($, s)
545 if (s.paneOpen) {
546 await closePane($, s)
547 return { text: 'Specialists pane hidden' }
548 }
549 await openPane($, s)
550 return { text: 'Specialists pane shown' }
551 }
552
553 if (verb === 'status' && !ref) {
554 await refresh($, s)
555 return { text: reportOf(s.fleet, s.error, Date.now()) }
556 }
557
558 if (verb === 'status' || verb === 'result' || verb === 'feed') {
559 if (!ref) return { text: USAGE }
560 // result and feed reach earlier-session activations too, so an id the live fleet does
561 // not know goes to the server as given; only an ambiguous live prefix is refused here.
562 await refresh($, s)
563 const target = resolveActivation(s.fleet, ref)
564 if (typeof target === 'string' && target.startsWith('Ambiguous')) return { text: target }
565 const id = typeof target === 'string' ? ref : target.activation_id
566 if (verb === 'status') {
567 if (typeof target === 'string') return { text: target }
568 const ask = s.fleet.asks.find(a => a.activation_id === id)
569 return {
570 text: [
571 `${markerOf(target, ask, Date.now())} ${target.specialist ?? 'specialist'} ${id} · ${target.state ?? 'unknown'}`,
572 ` issue ${target.bead_id ?? '—'} · ${target.resolved_model ?? '?model'}${target.thinking_level ? ` · ${target.thinking_level}` : ''}`,
573 ` ${metricsOf(target, ask, Date.now())}`,
574 ...(target.purpose ? [` purpose: ${target.purpose}`] : []),
575 ...(ask ? [` ${ask.kind ?? 'ask'} ${ask.message_id}: ${ask.body ?? ''}`] : []),
576 ].join('\n'),
577 }
578 }
579 if (verb === 'result') {
580 const read = await callTool($, 'specialist_result', { activation_id: id })
581 return { text: read.ok ? resultLinesOf(read.value).join('\n') : `specialist_result refused: ${read.error}` }
582 }
583 const lines = Math.min(200, Math.max(1, Number(rest[0]) || FEED_LINES))
584 const read = await callTool($, 'specialist_feed', { activation_id: id, limit: lines })
585 return { text: read.ok ? feedLinesOf(read.value).join('\n') : `specialist_feed refused: ${read.error}` }
586 }
587
588 if (verb === 'show' || verb === 'hide' || verb === 'expand' || verb === 'collapse') {
589 if (verb === 'show') s.visible = true
590 if (verb === 'hide') s.visible = false
591 if (verb === 'expand') s.expanded = true
592 if (verb === 'collapse') s.expanded = false
593 $.ui.invalidate('ui.render')
594 return { text: `Specialists band: ${verb}` }
595 }
596
597 if (verb === 'reply') {
598 if (!ref || !tail) return { text: USAGE }
599 const text = await act($, s, 'specialist_reply', { message_id: ref, body: tail }, `Answered ${ref}.`)
600 return { text, context: [`The operator answered specialist ask ${ref} directly: ${tail}`] }
601 }
602
603 if (verb === 'steer' || verb === 'resume' || verb === 'stop') {
604 if (!ref || (verb !== 'stop' && !tail)) return { text: USAGE }
605 await refresh($, s)
606 const target = resolveActivation(s.fleet, ref)
607 if (typeof target === 'string') return { text: target }
608 const id = target.activation_id
609 const text =
610 verb === 'steer'
611 ? await act($, s, 'specialist_steer', { activation_id: id, message: tail }, `Steered ${id}.`)
612 : verb === 'resume'
613 ? await act($, s, 'specialist_resume', { activation_id: id, prompt: tail }, `Resumed ${id}.`)
614 : await act($, s, 'specialist_stop_activation', { activation_id: id, reason: tail || 'operator request' }, `Stopped ${id}.`)
615 return { text, context: [`The operator ran /specialists ${verb} on ${id}${tail ? `: ${tail}` : ''}. Result: ${text}`] }
616 }
617
618 return { text: USAGE }
619 })
620
621 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
622 // The band is raised on the terminal only; narrowing here also types `Input`.
623 if (e.surface !== 'terminal') return next(e)
624 if (!s.visible || e.props.hasSurvey || (s.fleet.activations.length === 0 && !s.error)) return next(e)
625
626 const { Box, Text, Button, Input } = await $.ui.resolve(e)
627 const ordered = orderedOf(s.fleet)
628 // Folded above COLLAPSE_AT, and while the pane holds the list: the band keeps the
629 // header and the blocked rows only, since an ask must never hide behind a count.
630 const isFolded = s.paneOpen || ordered.length > COLLAPSE_AT
631 const blocked = new Set(s.fleet.asks.map(a => a.activation_id))
632 const rows = !s.expanded
633 ? []
634 : isFolded
635 ? (s.paneOpen ? [] : ordered.filter(a => blocked.has(a.activation_id)))
636 : ordered.slice(0, FLEET_MAX_ROWS)
637
638 return (
639 <Box flexDirection="column">
640 <Box>
641 <Text dimColor>╰─</Text>
642 <Text inverse bold> SPECIALISTS </Text>
643 <Text> {headerOf(s.fleet)}</Text>
644 {isFolded ? (
645 <Button plain key="open" hotkey="o" onPress={() => void (s.paneOpen ? closePane($, s) : openPane($, s))}>
646 {s.paneOpen ? 'close' : 'open'}
647 </Button>
648 ) : (
649 <Button plain dimColor key="toggle" onPress={() => { s.expanded = !s.expanded; $.ui.invalidate('ui.render') }}>
650 {s.expanded ? ' [-]' : ' [+]'}
651 </Button>
652 )}
653 </Box>
654 {isFolded && !s.paneOpen ? <Text dimColor> tap open or /specialists for the full fleet</Text> : null}
655 {s.error ? <Text color="yellow" wrap="truncate-end"> status unavailable · {s.error}</Text> : null}
656 {rows.length > 0 || s.flash ? fleetBody($, s, { Box, Text, Button, Input }, rows, 'band', DETAIL_BAND_LINES) : null}
657 {!isFolded && s.expanded && ordered.length > rows.length ? <Text dimColor> +{ordered.length - rows.length} more</Text> : null}
658 </Box>
659 )
660 })
661
662 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
663 // The mobile app draws no Input; it keeps /specialists status.
664 if (e.requestId !== PANE_ID || e.surface === 'mobile') return next(e)
665
666 const { Box, Text, Button, Input } = await $.ui.resolve(e)
667 return (
668 <Box flexDirection="column">
669 <Text bold>{headerOf(s.fleet)}</Text>
670 {s.error ? <Text color="yellow" wrap="truncate-end">status unavailable · {s.error}</Text> : null}
671 {s.fleet.activations.length === 0 ? <Text dimColor>No live activations.</Text> : null}
672 {fleetBody($, s, { Box, Text, Button, Input }, orderedOf(s.fleet), 'pane', DETAIL_PANE_LINES)}
673 <Text dimColor>esc closes · select a row to read its result or feed, or to reply, steer, resume or stop</Text>
674 </Box>
675 )
676 })
677
678 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
679 const result = await next(e)
680 if (result.deny === undefined) {
681 s.paneOpen = false
682 $.ui.invalidate('ui.render')
683 }
684 return result
685 })
686}
687