SLOPSHOPPER

specialists-ui

Teammate-style transcript rows for Specialists in Claude Code: channel wakes, the fallback wake, and Specialists MCP tool calls under native labels. Companion…

newrowsprompt
★ 4v4.0.9MITupdated 2026-10-04xtrm-dev/specialists/plugins/specialists-ui
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 456 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { EngineInterface, On } from 'claude-code'
5
6// Teammate-style transcript rows for Specialists. The Specialists wakes and MCP calls reach
7// the coordinator as raw machine text; these hooks draw them like Claude Code's own teammate
8// rows. Presentation only: the stored row, and everything the model reads, is untouched.
9// ctrl+o on a finished or failed wake shows the full result, as the Pi wake card's expanded
10// view does; on any other row it shows the original.
11//
12// Why a plugin of its own: Claude Code never runs a plugin's ui.render hooks on a row that
13// plugin raised ("skipped: re-entry ... the plugin's own code raised it"). The channel wake
14// comes from the specialists plugin's own MCP server, so only another plugin can draw it.
15
16/** The specialists plugin's MCP server as /mcp lists it (`plugin:<plugin>:<server>`). */
17export const MCP_SERVER = 'plugin:specialists:specialists'
18
19/** The rewakeSummary the specialists plugin's wake-watch hook raises (its hooks.json). */
20export const FALLBACK_WAKE_TEXT = 'Specialist activation needs attention'
21
22const ACCENT = '#9a8bff'
23
24/** The expanded wake shows at most this much of a result, as the Pi wake card does. */
25export const EXPANDED_RESULT_CHARS = 4000
26
27/**
28 * The full result an expanded wake draws, from one specialist_result read: a result is
29 * immutable once settled, so one read per activation per session is enough.
30 */
31export function expandedLinesOf(value: Record<string, unknown> | null, error?: string): string[] {
32  if (!value) return [`result unavailable · ${error ?? 'no answer'}`]
33  if (value.status === 'error') return [`result unavailable · ${String(value.error ?? 'error')}`]
34  if (typeof value.output !== 'string') return [`no settled result${typeof value.next === 'string' ? ` · use ${value.next}` : ''}`]
35  const text = value.output.slice(0, EXPANDED_RESULT_CHARS).replace(/\n+$/, '')
36  const lines = text.trim() ? text.split('\n') : ['(empty output)']
37  return value.output.length > EXPANDED_RESULT_CHARS
38    ? [...lines, `… ${value.output.length - EXPANDED_RESULT_CHARS} more characters in specialist_result`]
39    : lines
40}
41
42const expanded = new Map<string, string[] | 'loading'>()
43
44/** Read one result for an expanded wake; redraws when it lands. Never throws. */
45async function loadExpanded($: EngineInterface, activationId: string): Promise<void> {
46  expanded.set(activationId, 'loading')
47  try {
48    const result = await $.mcp.call(MCP_SERVER, 'specialist_result', { activation_id: activationId })
49    const text = result.content.map(b => ('text' in b && typeof b.text === 'string' ? b.text : '')).join('')
50    let value: Record<string, unknown> | null = null
51    try {
52      value = JSON.parse(text) as Record<string, unknown>
53    } catch {
54      /* non-JSON answer: reported below */
55    }
56    expanded.set(activationId, expandedLinesOf(value, result.isError ? text : 'unreadable answer'))
57  } catch (cause) {
58    expanded.set(activationId, expandedLinesOf(null, cause instanceof Error ? cause.message : String(cause)))
59  }
60  $.ui.invalidate('ui.render')
61}
62
63/**
64 * The marker class a channel wake is acknowledged under: 'settled' for a
65 * finished activation, 'needs_reply' for one waiting on the coordinator.
66 * Mirrors wake-watch.mjs's own class for the same states.
67 */
68export function ackClassFor(event: string): string {
69  return event === 'escalation' || event === 'needs_reply' ? 'needs_reply' : 'settled'
70}
71
72/**
73 * Records that a Specialists channel wake for `activationId` reached this
74 * session, so the wake-watch fallback can drop its duplicate. Best-effort:
75 * false when HOME is unreadable or the write fails; never throws.
76 */
77/**
78 * The activation and event a queued channel prompt carries. On prompt.submit the text is
79 * the whole `<channel source=… activation_id=… event=…>frame</channel>` element, not the
80 * bare frame the transcript row shows, so the tag's attributes are read first and the
81 * frame inside is the fallback.
82 */
83export function channelWakeOf(text: string): { activationId: string; event: string } | null {
84  const tag = /^<channel\b([^>]*)>/.exec(text.trimStart())
85  if (tag) {
86    const activationId = /\bactivation_id="([^"]*)"/.exec(tag[1]!)?.[1]
87    const event = /\bevent="([^"]*)"/.exec(tag[1]!)?.[1]
88    if (activationId && event) return { activationId, event }
89  }
90  const inner = text.replace(/^\s*<channel\b[^>]*>\s*/, '').replace(/\s*<\/channel>\s*$/, '')
91  const frame = parseChannelFrame(inner)
92  return frame ? { activationId: frame.activationId, event: frame.event } : null
93}
94
95export async function recordWakeAck($: EngineInterface, activationId: string, event: string): Promise<boolean> {
96  const home = await $.env.get('HOME')
97  if (!home) return false
98  await $.fs.write(`${home}/.xtrm/wake-acks/${activationId}.${ackClassFor(event)}`, '')
99  return true
100}
101
102const SPECIALISTS_TOOL = /^mcp__(?:plugin_specialists_)?specialists__(.+)$/
103
104const shortId = (id: string) => (id.startsWith('act:') ? id.slice(4, 12) : id.slice(0, 8))
105
106/**
107 * The channel frame `buildChannelFrame` writes, as far as a reader needs:
108 * `Specialist <specialist>[ on <issue>]: <event> (<activation_id>). <action>`.
109 *
110 * Deliberately strict: a row the parser cannot read falls back to the engine's
111 * own drawing rather than guessing at a half-frame.
112 */
113export function parseChannelFrame(
114  text: string,
115): { specialist: string; issue?: string; event: string; activationId: string } | null {
116  const match = /^Specialist (.+?)(?: on (.+?))?: (\S+) \((act:[^)]+)\)\. [\s\S]*$/.exec(text)
117  if (!match) return null
118  const [, specialist, issue, event, activationId] = match
119  return issue
120    ? { specialist: specialist!, issue, event: event!, activationId: activationId! }
121    : { specialist: specialist!, event: event!, activationId: activationId! }
122}
123
124const DONE_COLOR = 'green'
125const FAILED_COLOR = 'red'
126const WAITING_COLOR = 'yellow'
127
128/** The ● colour a wake row's header takes: done green, failed red, waiting on you yellow. */
129export function eventColor(event: string): string {
130  if (event === 'completed') return DONE_COLOR
131  if (event === 'failed') return FAILED_COLOR
132  if (event === 'escalation' || event === 'needs_reply') return WAITING_COLOR
133  return ACCENT
134}
135
136const EVENT_MARKER: Record<string, string> = {
137  completed: '✓',
138  failed: '✕',
139  escalation: '!',
140  needs_reply: '!',
141}
142
143export function markerForEvent(event: string): string {
144  return EVENT_MARKER[event] ?? '●'
145}
146
147/** The next move a collapsed wake row names, by event. */
148const EVENT_HINT: Record<string, string> = {
149  completed: 'use specialist_result for full result',
150  failed: 'specialist_result for detail · specialist_retry if transient',
151  escalation: 'specialist_status for the message_id, then specialist_reply',
152  needs_reply: 'specialist_status for the message_id, then specialist_reply',
153}
154
155/** Collapsed rows show at most this many brief lines; ctrl+o shows the whole frame. */
156export const BRIEF_ROW_LINES = 5
157
158/**
159 * The parts one teammate-style channel row draws, or null when unreadable. `detail` is the
160 * brief under the frame's identity line (context, then quoted Specialist text).
161 */
162export type ChannelRow = { marker: string; identity: string; event: string; issue?: string; detail: string[]; hint: string }
163
164export function channelRow(text: string): ChannelRow | null {
165  const frame = parseChannelFrame(text)
166  if (!frame) return null
167  const detail = text.split('\n').slice(1).filter((line) => line.trim() !== '')
168  const shown = detail.slice(0, BRIEF_ROW_LINES)
169  if (detail.length > BRIEF_ROW_LINES) shown.push(`… +${detail.length - BRIEF_ROW_LINES} lines · ctrl+o expands`)
170  return {
171    marker: markerForEvent(frame.event),
172    identity: `@${frame.specialist}:${shortId(frame.activationId)}`,
173    event: frame.event,
174    ...(frame.issue ? { issue: frame.issue } : {}),
175    detail: shown,
176    // The brief's own "… +N more lines in specialist_result" already says where the rest is.
177    hint: shown.some(line => line.startsWith('… ') && line.includes('specialist_result'))
178      ? ''
179      : EVENT_HINT[frame.event] ?? 'use specialist_status for authoritative state',
180  }
181}
182
183export function isSpecialistsTool(tool: string): boolean {
184  return SPECIALISTS_TOOL.test(tool)
185}
186
187const stringArg = (value: unknown): string | undefined => (typeof value === 'string' && value ? value : undefined)
188
189/** One Specialists call as a native tool row heads it: `Name(args)`. */
190export type ToolCall = { name: string; args?: string }
191
192const joined = (...parts: (string | undefined)[]) => parts.filter(Boolean).join(' · ') || undefined
193
194/**
195 * The native head one Specialists tool call draws under, or null when the name
196 * is not a Specialists tool this Mod knows. Covers both MCP name spellings.
197 */
198export function specialistToolCall(tool: string, input: unknown): ToolCall | null {
199  const match = SPECIALISTS_TOOL.exec(tool)
200  if (!match) return null
201  const args = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
202  const activation = stringArg(args.activation_id)
203  const short = activation ? shortId(activation) : undefined
204  const head = (name: string, text?: string): ToolCall => (text ? { name, args: text } : { name })
205  switch (match[1]) {
206    case 'specialist_dispatch':
207      return head(
208        'Dispatch',
209        joined(stringArg(args.specialist) ?? 'specialist', stringArg(args.issue_ref) ?? stringArg(args.bead_id) ?? 'inline contract'),
210      )
211    case 'specialist_status':
212      return head('Status', short)
213    case 'specialist_result':
214      return head('Result', short)
215    case 'specialist_feed':
216      return head('Feed', joined(short, args.view === 'forensic' ? 'forensic' : undefined, typeof args.since_seq === 'number' ? `since #${args.since_seq}` : undefined))
217    case 'specialist_lease_reconcile':
218      return head('Leases', stringArg(args.op) ?? stringArg(args.workspace))
219    case 'specialist_list':
220      return head('List specialists', stringArg(args.name))
221    case 'specialist_reply':
222      return head('Reply', stringArg(args.message_id))
223    case 'specialist_steer':
224      return head('Steer', short)
225    case 'specialist_resume':
226      return head('Resume', short)
227    case 'specialist_stop_activation':
228      return head('Stop', short)
229    case 'specialist_retry':
230      return head('Retry', short)
231    case 'substrate_issue':
232      return head('Issue', joined(stringArg(args.op), stringArg(args.ref) ?? stringArg(args.issue_id)))
233    case 'substrate_journal':
234      return head('Journal', joined(stringArg(args.op), stringArg(args.issue_id)))
235    case 'substrate_provenance':
236      return head(
237        'Provenance',
238        joined(stringArg(args.op), stringArg(args.issue_id) ?? stringArg(args.pr) ?? stringArg(args.sha)?.slice(0, 8) ?? stringArg(args.receipt_id)),
239      )
240    default:
241      return null
242  }
243}
244
245/** The text an MCP result carries: its text blocks joined, or the string itself. */
246function payloadText(output: unknown): string {
247  if (typeof output === 'string') return output
248  const blocks = Array.isArray(output)
249    ? output
250    : output && typeof output === 'object' && Array.isArray((output as { content?: unknown }).content)
251      ? (output as { content: unknown[] }).content
252      : null
253  if (!blocks) return ''
254  return blocks
255    .map(block => (block && typeof block === 'object' && (block as { type?: unknown }).type === 'text' ? String((block as { text?: unknown }).text ?? '') : ''))
256    .join('\n')
257}
258
259function payloadOf(output: unknown): Record<string, unknown> | null {
260  if (output && typeof output === 'object' && !Array.isArray(output) && !('content' in output)) return output as Record<string, unknown>
261  try {
262    const value: unknown = JSON.parse(payloadText(output))
263    return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
264  } catch {
265    return null
266  }
267}
268
269const firstLine = (text: string) => text.trim().split('\n')[0]!.slice(0, 120)
270
271/** The text an errored call's `output` carries, for the row that shows it. */
272export function errorTextOf(output: unknown): string {
273  const record = payloadOf(output)
274  const text = record ? stringArg(record.error) ?? stringArg(record.message) ?? stringArg(record.text) : undefined
275  if (text) return text
276  const raw = payloadText(output)
277  if (raw) return firstLine(raw)
278  return output == null ? 'failed' : JSON.stringify(output)
279}
280
281const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
282
283/**
284 * The one line under a finished Specialists call (the indented result line), read from its JSON
285 * result. A `{ status: 'error' }` payload is an error even when the call itself was not.
286 */
287export function toolResultLine(tool: string, output: unknown): { text: string; isError: boolean } | null {
288  const match = SPECIALISTS_TOOL.exec(tool)
289  if (!match || output == null) return null
290  const record = payloadOf(output)
291  if (!record) {
292    const raw = payloadText(output)
293    return raw ? { text: firstLine(raw), isError: false } : null
294  }
295  if (record.status === 'error') return { text: errorTextOf(output), isError: true }
296  switch (match[1]) {
297    case 'specialist_dispatch': {
298      const id = stringArg(record.activation_id)
299      const text = joined(id ? shortId(id) : undefined, stringArg(record.created_issue_ref) ?? stringArg(record.bead_id), stringArg(record.state))
300      return text ? { text, isError: false } : null
301    }
302    case 'specialist_status': {
303      const activations = Array.isArray(record.activations) ? (record.activations as { state?: unknown }[]) : []
304      const asks = Array.isArray(record.pending_asks) ? record.pending_asks.length : 0
305      const running = activations.filter(a => a?.state === 'running' || a?.state === 'starting').length
306      const parts = [count(activations.length, 'activation'), running ? `${running} running` : undefined, asks ? `${asks} waiting on you` : undefined]
307      return { text: parts.filter(Boolean).join(', '), isError: false }
308    }
309    case 'specialist_result': {
310      if (record.output == null) {
311        const pending = stringArg(record.state)
312        return pending ? { text: pending, isError: false } : null
313      }
314      const body = typeof record.output === 'string' ? record.output : JSON.stringify(record.output)
315      const lines = body === '' ? 0 : body.split('\n').length
316      return { text: joined(stringArg(record.status), count(lines, 'line')), isError: false }
317    }
318    case 'specialist_feed': {
319      const events = Array.isArray(record.events) ? record.events.length : 0
320      const total = typeof record.total === 'number' ? record.total : events
321      const last = typeof record.last_seq === 'number' ? `last #${record.last_seq}` : undefined
322      return { text: joined(record.truncated === true ? `${events} of ${total} events` : count(events, 'event'), last)!, isError: false }
323    }
324    case 'specialist_list':
325      return Array.isArray(record.specialists) ? { text: count(record.specialists.length, 'specialist'), isError: false } : null
326    default: {
327      const text = stringArg(record.state) ?? stringArg(record.status)
328      return text ? { text, isError: false } : null
329    }
330  }
331}
332
333export function register(on: On) {
334  // A channel wake's prompt.submit is how the coordinator actually receives the
335  // push; record it so the slow fallback watcher does not wake the session again
336  // for the same activation. The prompt always passes through unchanged.
337  on('prompt.submit', { origin: { kind: 'channel', server: MCP_SERVER } }, async ($, e, next) => {
338    const wake = channelWakeOf(e.text)
339    if (wake) await recordWakeAck($, wake.activationId, wake.event).catch(() => {})
340    return next(e)
341  })
342
343  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'channel' } } }, async ($, e, next) => {
344    if (e.props.origin.kind !== 'channel' || e.props.origin.server !== MCP_SERVER) return next(e)
345    const row = channelRow(e.props.text)
346    if (!row) return next(e)
347    const settled = row.event === 'completed' || row.event === 'failed'
348    if (e.props.isExpanded && !settled) return next(e)
349    let full: string[] | null = null
350    if (e.props.isExpanded) {
351      const activationId = parseChannelFrame(e.props.text)!.activationId
352      const cached = expanded.get(activationId)
353      if (cached === undefined) void loadExpanded($, activationId)
354      full = Array.isArray(cached) ? cached : ['loading result…']
355    }
356    const { Box, Text } = await $.ui.resolve(e)
357    // Shaped like Claude Code's own teammate row: a coloured ● header, then a dim detail line.
358    return (
359      <Box flexDirection="column" marginTop={1}>
360        <Box>
361          <Text color={eventColor(row.event)}>● </Text>
362          <Text>Specialist </Text>
363          <Text bold color={ACCENT}>{row.identity}</Text>
364          <Text> {row.event}</Text>
365          {row.issue ? <Text dimColor> · {row.issue}</Text> : null}
366        </Box>
367        {full ? (
368          <>
369            {row.detail.filter(line => !line.startsWith('> ') && !line.startsWith('… ')).map((line, i) => (
370              <Text key={`c${i}`} dimColor>  {line}</Text>
371            ))}
372            {full.map((line, i) => (
373              <Text key={`r${i}`}>  {line}</Text>
374            ))}
375          </>
376        ) : (
377          <>
378            {row.detail.map((line, i) =>
379              line.startsWith('> ') ? (
380                <Text key={i} italic>  {line.slice(2)}</Text>
381              ) : (
382                <Text key={i} dimColor>  {line}</Text>
383              ),
384            )}
385            {row.hint ? <Text dimColor italic>  {row.hint}</Text> : null}
386          </>
387        )}
388      </Box>
389    )
390  })
391
392  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, async ($, e, next) => {
393    if (e.props.isExpanded || e.props.text !== FALLBACK_WAKE_TEXT) return next(e)
394    const { Box, Text } = await $.ui.resolve(e)
395    return (
396      <Box flexDirection="column" marginTop={1}>
397        <Box>
398          <Text color={WAITING_COLOR}>● </Text>
399          <Text bold color={ACCENT}>Specialists</Text>
400          <Text> fallback wake</Text>
401        </Box>
402        <Text dimColor italic>  use specialist_result for full result</Text>
403      </Box>
404    )
405  })
406
407  // A Specialists call never draws on its own inside a folded group: unfold the group
408  // where it is, then each call draws as a ToolUse row the hook below labels.
409  on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
410    if (e.props.isExpanded || !e.props.calls.some(call => isSpecialistsTool(call.tool))) return next(e)
411    return next({ ...e, props: { ...e.props, isExpanded: true } })
412  })
413
414  // Drawn the way Claude Code draws its own tools: a state-coloured ●, the bold name with
415  // its arguments, and an indented line with the result, the running state or the error.
416  on('ui.render', { component: 'ToolUse', props: { tool: /^mcp__(?:plugin_specialists_)?specialists__/ } }, async ($, e, next) => {
417    const call = specialistToolCall(e.props.tool, e.props.input)
418    if (!call) return next(e)
419    const { Box, Text } = await $.ui.resolve(e)
420    const result = toolResultLine(e.props.tool, e.props.output)
421    const failed = e.props.isErrored || result?.isError === true
422    const line = e.props.isRunning
423      ? { text: 'Running…', color: undefined }
424      : e.props.isInterrupted
425        ? { text: 'Interrupted', color: FAILED_COLOR }
426        : failed
427          ? { text: result?.isError ? result.text : errorTextOf(e.props.output), color: FAILED_COLOR }
428          : result
429            ? { text: result.text, color: undefined }
430            : null
431    const dot = e.props.isRunning ? undefined : e.props.isInterrupted || failed ? FAILED_COLOR : DONE_COLOR
432    return (
433      <Box flexDirection="column" marginTop={1}>
434        <Box>
435          <Text color={dot} dimColor={e.props.isRunning}>● </Text>
436          <Text bold>{call.name}</Text>
437          {call.args ? <Text>({call.args})</Text> : null}
438        </Box>
439        {line ? (
440          <Box>
441            <Text>  </Text>
442            <Text color={line.color} dimColor={!line.color}>{line.text}</Text>
443          </Box>
444        ) : null}
445      </Box>
446    )
447  })
448
449  // A standalone call's result row would repeat the result line the call row already drew.
450  on('ui.render', { component: 'ToolResult', props: { tool: /^mcp__(?:plugin_specialists_)?specialists__/ } }, async ($, e, next) => {
451    if (!specialistToolCall(e.props.tool, undefined)) return next(e)
452    const { Box } = await $.ui.resolve(e)
453    return <Box />
454  })
455}
456