SLOPSHOPPER

specialists

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

newpanebandcommandtoasttimer
★ 4v4.0.9MITupdated 2026-10-04xtrm-dev/specialists/plugins/specialists
A shopper browsing a rack in a slop shop
README

Specialists

npm version License: MIT TypeScript

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 orchestrator stays the central executive — it owns the user intent, task identity, evidence, and publication decision;
  • the pinned Substrate Issue revision is the executable work contract — problem, scope, success criteria, validation, dependencies, and authorized work live there; Journal/settlement evidence records what happened without rewriting that authority;
  • specialists are fresh, scoped cognitive faculties — explorer, debugger, executor, test-engineer, reviewer, sync-docs, researcher, and domain roles each get only the context, tools, rules, and output contract they need;
  • structured handoffs flow back to the orchestrator — results are evidence to consume, not conversational vibes to remember;
  • workspaces and gates keep changes publishable — edit-capable roles work in branches/worktrees, reviewer/QA/security roles judge against the contract.

The result is a shared project mind: continuity without hoarding every detail in one agent’s context.

Specialists sits in the xt/xtrm stack:

  • pi coding agent executes model sessions and exposes tool events/RPC boundaries.
  • xtrm-tools provides operator workflow: worktree sessions, .xtrm/ skills/hooks, reports, update tooling, and gates.
  • Substrate (@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.


Why not one big agent chat?

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.


The common-mind model

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.


What Specialists lets you do

NeedUse
Turn vague work into an executable task contractSubstrate Issue + planner / orchestrator
Map unfamiliar local codesp run explorer --bead <id>
Diagnose a bug with unknown causesp run debugger --bead <id>
Implement a scoped change in an isolated workspacesp run executor --bead <id> --worktree
Add tests from the actual implementation difftest-engineer
Run and classify validation commandstest-runner
Check scope/quality before final reviewseconder
Review implementation evidence against the pinned Issue contractreviewer/seconder against the same pinned revision; legacy sp run ... --bead remains a compatibility surface
Research current docs, repos, APIs, papers, or domain evidenceresearcher, quant-researcher, transcriber
Sync one stale doc safelysync-docs
Keep service-expert skill docs aligned with code driftservice-knowledge-sync
Generate immediate JSON/text from a specialistsp script or sp serve
Watch all active specialist work across repossp console
Inspect runtime evidence and telemetrysp feed, sp log, sp forensic, sp metrics
Configure package specialists for your machinesp init --global, sp edit --global, sp setup

The live catalog is authoritative:

sp list
sp list --compact
sp list-rules
sp help

Install and bootstrap

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.

Claude Code plugin (optional)

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.

The /specialists command and the fleet band

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

CommandEffect
/specialistsopen 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.

Updates

The plugins and the runtime update separately. Both carry the package version.

  • Plugins come from GitHub. Claude Code does not auto-update a third-party marketplace by default. To turn it on, run /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.
  • The runtime comes from npm, and Claude Code never updates it. Run 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.
What the plugins need
PieceNeeded forSet up by
Specialists runtime (@jaggerxtrm/specialists)the MCP server; the plugin launcher starts itnpm install -g @jaggerxtrm/specialists (see Install and bootstrap)
Bun on PATHthe MCP server and every plugin hook scriptinstall Bun
Substrate work storespecialist_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 rowsa current Claude Code; hooks must not be turned off with disableAllHooks
specialists@xtrmMCP tools, session-start state, the fallback wake, the band, pane and commandclaude plugin install specialists@xtrm
specialists-ui@xtrmteammate-style wake rows and native tool rowsclaude plugin install specialists-ui@xtrm
--channels plugin:specialists@xtrmthe channel wake (primary)launch flag; xt claude passes it for you
Managed settings channelsEnabled + allowedChannelPluginsthe 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.

Enable the channel wake

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.

  1. Launch the session with the plugin's server named as a channel:
   claude --channels plugin:specialists@xtrm
  1. As root, allow the plugin in Claude Code's managed settings, in /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.

Global model config

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:

  1. package canonical specialist JSON;
  2. ~/.config/specialists/user.json global overrides;
  3. .specialists/user/ repo-local overrides.

See docs/installation.md, docs/bootstrap.md, and docs/authoring.md.


First tracked run

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"

Operator console

sp console is the multi-repo terminal dashboard for live specialist work.

It provides:

  • an ALL view aggregating active jobs across configured repos;
  • per-repo tabs and persistent repo registry (~/.config/specialists/console.json);
  • job list, feed, result, bead, diff, config, and repo-config views;
  • cursor navigation and direct actions (↵, r, i, b, d, g, R, x, 0, tab, 1-9);
  • TUI-styled rows shared with 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

Publication and review

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.
  • Reviewer PASS is the normal publish gate for implementation work.
  • 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.


Script and service specialists

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.


Observability and telemetry

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.

Project-pack Service Knowledge

Source 1 files
hooks/register.tsx 687 lines
1/* @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