Live A2A pane for Claude Code: dispatcher state, tasks in flight, who talks to whom, the latest messages and the questions waiting for you.

<!-- markdownlint-disable-next-line MD041 --> <a id="top"></a>
<img src="images/a2a_banner.png" alt="codetyper.a2a banner" width="100%">
<img src="images/a2a_logo.png" alt="codetyper.a2a logo" width="120">
<h1>codetyper.a2a</h1>
<a href="https://github.com/CarGDev/codetyper.a2a/releases"><img src="https://img.shields.io/github/v/release/CarGDev/codetyper.a2a?style=for-the-badge&label=release" alt="Latest release"></a> <img src="https://img.shields.io/badge/version-2.1.17-2ac3de?style=for-the-badge" alt="Version 2.1.17"> <img src="https://img.shields.io/badge/Rust-1.85%2B-f74c00?style=for-the-badge&logo=rust&logoColor=white" alt="Rust 1.85+"> <img src="https://img.shields.io/badge/macOS%20%C2%B7%20Linux-7aa2f7?style=for-the-badge" alt="Platform"> <a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg?style=for-the-badge" alt="License: Apache 2.0"></a>
<a href="#use"><strong>Use</strong></a> • <a href="#install"><strong>Install</strong></a> • <a href="a2a/PROTOCOL.md"><strong>Protocol</strong></a> • <a href="CHANGELOG.md"><strong>Changelog</strong></a> • <a href="https://cargdev.io/"><strong>Website</strong></a> • <a href="https://cargdev.io/blog"><strong>Blog</strong></a>
Your agents work one at a time and report only to you. A single long session carries everything, forgets it on compaction, and has no second opinion. <strong>codetyper.a2a gives each agent its own session, a task board to talk through, and a dispatcher that wakes the right one.</strong> An orchestrator plans, routes and delegates; the result comes back to the session that asked. One Rust binary, no Node.
<sub><strong>WORKS WITH</strong> <a href="https://opencode.ai">opencode v2</a> · speaks the task model of the <a href="https://github.com/a2aproject/A2A">Agent2Agent (A2A) protocol</a> · macOS and Linux · needs tmux</sub>
<img src="images/oc-running.gif" alt="opencode running with a2a: the orchestrator sends tasks, agents open as tabs, the status tab follows along" width="100%">
Start a tmux session first (a2a needs tmux), then run opencode in it:
tmux
opencode # or: oc
Whenever opencode starts, the tabs plugin runs oc ensure: the dispatcher is started if needed and a detached a2a-status tab opens with the live status (outside tmux it stops with a message instead). No alias needed (codetypera2a init --shell only adds alias opencode='oc', which does the same through oc).
opencode opens on the orchestrator. Ask it for something; it plans with plan, routes with brain, sends tasks with a2a_send_message and ends its turn. The dispatcher opens the assignee's own session (first time only) and the tabs plugin shows it as a new tab; when the agent finishes, the orchestrator's tab is woken with the result. Switch tabs with Ctrl+Tab.
| Command | What it does |
|---|---|
oc help | oc's own help (opencode's is opencode --help) |
oc status | dispatcher state, running sessions, open tasks |
oc watch | live view of the dispatcher log (what the tmux window shows) |
oc dispatch | run the dispatcher in this pane (foreground) |
oc stop | stop every dispatcher of this database (after an upgrade oc then starts the current one) |
oc continue <ctx> | allow more hops for a context that hit the hop cap |
oc trace <task> | timeline of one task (id or 8-character prefix): sender, claim, delivery, state changes, duration |
oc shutdown | end everything: cancel open tasks, interrupt running turns, retire sessions, stop the dispatcher (runs automatically when the last opencode exits, unless a Claude Code turn or an open task on a Claude agent is still live, in which case the dispatcher and the a2a-status window stay up; A2A_KEEP_RUNNING=1 disables, --force ends everything) |
oc cancel | cancel open tasks (--all, task ids, --context, --dir) and interrupt the turns working on them |
oc stats | coloured per-agent table, most tokens first, with a share bar: tasks, average duration (3m 46s), tokens in/out (1.3M/13.1k) and cost (opencode's figure, or an estimate from the price table when it reports 0); unknown values stay blank |
oc stats --model | the same table per model, one titled table per tool (opencode, claude code), each with its own total (read from the events.model / events.source columns of the hub database, schema v6): tokens in/out, share bar, cost, turns, tasks and agents that used it; opencode events without a recorded model are grouped as unknown. It first reads new Claude Code transcripts ($CLAUDE_CONFIG_DIR or ~/.claude, projects/**/*.jsonl) into the hub, once per message id, so reruns add nothing; the claude code table shows in (non-cached input), out and cache r/w apart, with sessions instead of tasks (schema v6), and its cost is an estimate that prices cache reads at 0.1x and cache writes at 1.25x (5 minutes) / 2x (1 hour) of the input price, blank for models the price table does not list. Transcripts omit some calls Claude Code bills (side queries), so totals can sit below /usage. A transcript that cannot be read only prints a warning |
oc stats --ingest | only the transcript step above: prints how many new claude code usage rows were added |
oc status --json | the same state as JSON (tasks, questions, progress, today's cost); --dir <folder> |
oc messages --json | recent task messages as JSON, newest first (--since <iso>, --limit N); what the Claude Code pane reads |
oc follow <log> | render a Claude agent's live turn log (what its tmux window runs) |
oc answer <task> <text> | reply to a task that waits for you (input-required) |
oc agent <name> | switch this folder's newest session to another agent |
oc open-project <dir> | open another project in its own tmux tab (--prompt <text> sends it to that orchestrator) |
oc ensure | start the dispatcher and the status tab without starting opencode |
codetypera2a models | choose each agent's model (--show, --recommended) |
codetypera2a init --md-only | refresh only the agent/command markdown (alias codetypera2a update) |
codetypera2a eval | replay the deterministic scenarios (routing, hop cap, no duplicate nudges, seeded-bug reviewer); --list shows them |
codetypera2a --version | print the version (also -V, version; oc --version too) |
codetypera2a check-models | lint models against model-policy.json |
codetypera2a e2e-test | hub end-to-end test (also run by cargo test) |
oc stats looks like this:
<img src="images/oc-stats.png" alt="oc stats: agents sorted by tokens, with share bars, cost and average duration" width="100%">
oc stats --model (alias --models) gives the same view per model, with one titled table per tool (opencode, claude code); this is the opencode table:
<img src="images/oc-stats-models.png" alt="oc stats --models: models sorted by tokens, with share bars, cost, turns, tasks and agents" width="100%">
a2a needs tmux (one tab per agent): oc refuses to start when tmux is not installed or the pane is not inside it. opencode's own flags and plain subcommands (models, run, --version) still work without it.
The plugin adds a few things to the opencode TUI (they come from plugins/a2a/panels.tsx; if they do not show up, the tabs still work):
██████────── 6.0 / 12) and one row per task with a state symbol, the agent in its own colour (the color: of its agent file) and a ⚠ hop cap marker.a2a ● N open · ~$cost · tokens today./a2a-answer (pick one of the numbered options of an input-required task, or write your own), /a2a-continue and /a2a-cancel.The orchestrator makes work go plan, design, task, implementation, and each implementation task is one work unit of at most 400 changed lines with its tests and docs (a migration that cannot be split is the only exception). Plans are saved to Engram first (a2a/plans/<project>/<slug>); when Engram is not available they are files in ~/.config/opencode/plans/<project>/<number>/ (plan.md, design.md, tasks.md; <number> is the next free integer) or .opencode/plans/<slug>/ in the project.
Protocol details: a2a/PROTOCOL.md.
<img src="images/a2a_flow.jpeg" alt="Flow of a task: user, orchestrator, dispatcher, hub, specialist agents and back" width="100%">
Like the A2A protocol it borrows from, a task has an id, a contextId, messages made of parts, and a state (submitted, working, input-required, completed, failed). The hub keeps them in SQLite and exposes them to agents as MCP tools (a2a_*), so no agent needs to know how another one is built. The wire protocol of the A2A project (HTTP agent cards and JSON-RPC) is not implemented here; this hub is the local, opencode-native version of the same idea.
codetyper.a2a is a small agent harness: everything around the model (prompts, tools, memory, delegation, checks) is plain files you can read and edit. The design follows the practices in Google Research's harness2 and the awesome-harness-engineering list:
| Practice | Where it lives here |
|---|---|
| Context: give each agent only what it needs | One session per agent and project; tasks carry a message with parts and a contextId; codegraph and engram MCP servers for retrieval instead of reading whole trees |
| Tools: narrow, well-described interfaces | The hub's a2a_* MCP tools (send_message, inbox, get_task, update_task, ...); playwright_* denied unless an agent needs a browser |
| Guardrails: least privilege and bounded loops | tools: / permission: blocks per agent template; read-only shells for reviewers; hop cap per context (oc continue to allow more); model-policy.json lint |
| Verification: independent checks | Every code change is checked by code-reviewer and test-runner, and reviewers use a different model family than the author |
| Parallel probes (harness2) | For uncertain work the orchestrator sends 2-3 read-only probes to different agents and compares their artifacts before choosing |
| Evidence-backed lessons (harness2) | Lessons are saved to Engram (a2a/lessons/<project>) only with evidence, and brain reads them when routing |
| Memory and state | SQLite hub (hub.db) for tasks and nudges; Engram for long-term lessons |
| Orchestration: plan, decompose, hand off | orchestrator -> brain -> plan -> architect -> implementers -> tests, route chosen per request (debug only, debugger first, tests first), plans saved to Engram and ~/.config/opencode/plans/; oc open-project <dir> opens another project in a tmux tab |
| Observability | oc status, oc watch, the live a2a-status tmux window and the dispatcher log; oc trace <task> for a per-task timeline and oc stats per agent |
| Evals | codetypera2a eval replays fixed scenarios (routing, hop cap, no duplicate nudges, seeded-bug reviewer) offline with a fake opencode; cargo test and CI can gate on its exit code |
| Human in the loop | Plan approval gates; the dispatcher reports back to the session the task came from |
kill, ps, symlinks and process groups; Windows is not supported).opencode --version. Or Claude Code (tested with 2.1.310): see Claude Code below.brew install rust or rustup. SQLite is bundled, nothing else to install.tmux: a2a opens one tab per agent and the live a2a-status window in tmux, so oc and the plugin refuse to start without it (brew install tmux, then run opencode inside a tmux session).The formula lives in the tap CarGDev/homebrew-codetypera2a. Homebrew installs the Rust toolchain it needs to build, so you do not need cargo or a clone:
brew install CarGDev/codetypera2a/codetypera2a
codetypera2a --version # codetypera2a 2.1.17
Update later with brew update && brew upgrade codetypera2a. To build the latest master instead of the last release: brew install --HEAD CarGDev/codetypera2a/codetypera2a.
If you previously ran cargo install codetypera2a, remove it first (cargo uninstall codetypera2a), otherwise ~/.cargo/bin can shadow the Homebrew binary in your PATH. If the binary moved (for example from cargo to Homebrew), you do not have to edit anything by hand: codetypera2a init or any oc run repairs a dead a2a path in opencode.jsonc and a2a/codetypera2a.path by itself (a backup is kept as opencode.jsonc.bak-a2a).
git clone https://git.cargdev.io/CarGDev/codetyper.a2a.git
cd codetyper.a2a
cargo install --path . --force # installs codetypera2a into ~/.cargo/bin
Or without cloning: cargo install --git https://git.cargdev.io/CarGDev/codetyper.a2a.git --force. Make sure ~/.cargo/bin is on your PATH.
codetypera2a init --shell # install the agents/commands/plugin/MCP entries; --shell adds alias opencode='oc'
Restart opencode (or run opencode reload), then check:
codetypera2a help # the binary runs
oc status # dispatcher: running (it is started by the a2a MCP server)
init is safe to re-run (existing files are skipped unless --force). After upgrading the binary, codetypera2a init --md-only (alias codetypera2a update) refreshes only the agent, command and PROTOCOL markdown and keeps your saved models. Useful options: --tools runs the companion tools' own opencode setup first (engram setup opencode, codegraph install), --no-link skips the oc symlink, --dir <path> / $OPENCODE_CONFIG_DIR targets another config dir.
init can set up Claude Code too. On a terminal it opens a screen that lists the tools it knows, marks the ones it found and lets you pick (space toggles, a all, enter confirms, esc cancels); tools it cannot set up yet are listed as "coming soon":
Which agents should codetypera2a configure?
› ■ Claude Code (detected) — global only
■ opencode (detected) — global only
□ Codex CLI (coming soon)
□ Cursor (coming soon)
...
codetypera2a init is the one command: whatever you pick (or it detects with --yes or without a terminal; opencode when it finds none) is set up completely, with nothing left to run by hand. --claude or --opencode limit it to one tool and give the same result as picking only that tool on the screen; --all does both.
| For Claude Code it installs | Where |
|---|---|
the agents, converted (agents/) | ~/.claude/agents/<name>.md (model tier, tool list and color translated; mode, steps and permission have no Claude Code equivalent and are dropped) |
slash commands, converted (commands/) | ~/.claude/commands/ (/plan-new, /plan-continue, /plan-status; opencode's agent: becomes "delegate to that subagent") |
MCP servers a2a, codegraph, context7, engram, playwright | ~/.claude.json (mcpServers, the user scope claude mcp add --scope user writes; backup ~/.claude.json.bak-a2a; existing entries are never overwritten, except that a dead a2a binary path is repaired) |
a footer status line and four hooks (SubagentStart, SubagentStop, UserPromptSubmit, SessionStart) | ~/.claude/settings.json (statusLine, hooks; backup settings.json.bak-a2a; only added when absent, your own status line and hooks are kept) |
The footer reads like a2a ● · 2 tasks: orchestrator>coder, coder>tester · 1 subagent: Explore: whether the dispatcher is up, the hub tasks in flight (from/to) and the subagents Claude Code is running right now. It is oc statusline, a single fast line that reads the hub read-only and exits 0 whatever it is given; the two hooks run oc statusline --hook start|stop, which keep one small file per running subagent in ~/.codetypera2a/statusline-subagents/ (entries older than 10 minutes are ignored). To turn it off, remove the statusLine key and the four oc statusline hooks from settings.json; init re-adds them on its next run unless you already have a status line of your own. A hook or status line of ours whose binary path is gone (the binary moved) is repaired on the next run; yours are never touched.
Re-running is safe and also an upgrade. init remembers what it wrote in <claude dir>/.a2a-manifest.json. On a rerun a file that is missing is written, a file you did not edit is replaced when this release changed it, and a file you edited is kept and named in one line (--force replaces those too). Files from an install made before the manifest existed are replaced once, and the old copy is kept next to them as <file>.bak-a2a. An up-to-date install writes 0 files.
It is the same source files as for opencode, so there is nothing extra to maintain. --claude-dir <path> or $CLAUDE_CONFIG_DIR target another config dir (its MCP file is then <dir>/.claude.json, which Claude Code reads when started with CLAUDE_CONFIG_DIR=<dir>). codetypera2a update --claude refreshes the converted markdown by the same rules.
Agents that work by themselves. Start the dispatcher with DISPATCH_BACKEND=claude (all agents) or DISPATCH_BACKEND_<AGENT>=claude (one agent, name upper-cased, other characters _, e.g. DISPATCH_BACKEND_CODE_REVIEWER=claude); CLAUDE_BIN points at another claude. Without it nothing changes: opencode runs every agent. A task sent to such an agent runs one headless turn in the project folder (claude -p --agent <name> --session-id <uuid>, later --resume <uuid>, --allowedTools from the agent's tools: plus mcp__a2a__*), with the same rules as for opencode: one wake per task and status message, hop cap, retries, stall check. A failed turn (non-zero exit or an error result) fails the task with agent_error:, oc cancel stops a running turn, oc status shows claude turns running, and oc stats --model counts the turns from their transcripts (the dispatcher does not record them, so nothing is counted twice). Set DISPATCH_BACKEND_ORCHESTRATOR=opencode if your orchestrator runs in opencode while the rest runs in Claude Code. The UserPromptSubmit hook (oc statusline --hook inbox) adds the finished results to your next prompt in an interactive Claude Code session and marks them seen; set A2A_REQUESTER if your requester is not orchestrator.
Claude Code starts on the orchestrator by itself: init writes "agent": "orchestrator" to <claude dir>/settings.json (user scope, every project) when the file has no agent key and agents/orchestrator.md is installed. An agent you already set is kept (init prints a note); a project's .claude/settings.json or claude --agent <name> still wins. init --no-default-agent skips it; to undo it, delete the agent line (the original file is saved as settings.json.bak-a2a).
Check it: claude mcp list shows a2a, and a new claude session opens on the orchestrator (its stream-json init event lists only the orchestrator's tools). Every agent also works as a subagent of any Claude Code session.
Known limits:
claude -p cannot inject into it). Results reach it on its next prompt through the UserPromptSubmit hook and the N results waiting part of the footer, so an orchestrator that must react the moment a task finishes still works best in opencode.oc status and oc watch still work from a shell.DISPATCH_BACKEND / DISPATCH_BACKEND_<AGENT>); the default stays opencode.DISPATCH_ONLY=claude (or opencode) for the hub and the dispatcher: the other runtime is never started or woken and new tasks route to the chosen one.plan agent and the /plan-* commands still look for plans in .opencode/plans/.init --claude also writes a Claude Code "mod" (a plugin of function hooks, plugins/claude-a2a/ in this repo, plugin name a2a-pane) into a local marketplace folder, ~/.claude/a2a-marketplace/ (or <dir>/a2a-marketplace/ for --claude-dir / $CLAUDE_CONFIG_DIR). Once enabled it polls oc status --json and oc messages --json every 4 seconds and shows:
? 1a2b3c4d orchestrator -> code-reviewer input-required, coloured by state) with the last three messages of that task under it;oc answer <task> <option>), and an input to type your own answer;/a2a opens or closes the pane.The pane docks beside the chat from about 110 to 144 columns; below that, or while it is closed, a one-line band above the prompt (a2a ● 2 open · 1 waiting: 1a2b3c4d code-reviewer: Which branch?) takes its place, and the footer status line is unchanged. In a terminal that is too narrow /a2a still opens it inline.
init also runs claude plugin marketplace add and claude plugin install a2a-pane@codetypera2a --scope user --config exe=<binary> itself (against the same config dir: CLAUDE_CONFIG_DIR is passed to the child), skipping a step that is already done and running claude plugin update when the pane version changed. If claude is missing or a step fails, init prints one failure line with the exact commands and carries on. Restart Claude Code to see the pane. exe is the binary the mod runs (<exe> oc status --json); change it later in /config (plugin a2a-pane).
tmux. Inside tmux, Claude Code behaves like opencode with its tabs plugin. A SessionStart hook (oc statusline --hook ensure) starts the dispatcher and opens the detached a2a-status window when a session starts; it prints nothing, returns at once and does nothing outside tmux. With DISPATCH_BACKEND=claude, ea
hooks/register.tsx 137 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { EMPTY, clip, isWaiting, lastOf, parse, sorted, style, summary } from './model'
5
6const PANE = 'a2a'
7const POLL_MS = 4000
8const LINES_PER_TASK = 3
9
10let isPolling = false
11
12// Change the tag when the snapshot fields change: it declines what an earlier version stored.
13const snapshot = atom({ plugin: 'a2a-pane', key: 'snapshot' } as const, EMPTY, { shape: 'snapshot-1' })
14
15type Dollar = EngineInterface
16
17const oc = (exe: string, ...args: string[]) => [exe, 'oc', ...args]
18
19async function poll($: Dollar, exe: string) {
20 if (isPolling) return
21 isPolling = true
22 try {
23 const [status, messages] = await Promise.all([
24 $.process.run(oc(exe, 'status', '--json'), { timeoutMs: 8000 }),
25 $.process.run(oc(exe, 'messages', '--json', '--limit', '60'), { timeoutMs: 8000 }),
26 ])
27 const failed = [status, messages].find(r => r.exitCode !== 0)
28 await update($, snapshot, last =>
29 failed ? { ...last, error: clip(failed.stderr || `exit ${failed.exitCode}`, 80) } : parse(status.stdout, messages.stdout, last),
30 )
31 } catch (e) {
32 await update($, snapshot, last => ({ ...last, error: clip(String(e), 80) }))
33 } finally {
34 isPolling = false
35 }
36}
37
38async function answer($: Dollar, exe: string, task: string, text: string) {
39 const out = await $.process.run(oc(exe, 'answer', task, text), { timeoutMs: 15000 })
40 $.ui.toast(out.exitCode === 0 ? out.stdout.trim() : `answer failed: ${clip(out.stderr, 80)}`)
41 await poll($, exe)
42}
43
44export const register: Register = (on, options) => {
45 const exe = String(options.exe ?? 'codetypera2a')
46 on('session.start', async ($, e, next) => {
47 await $.command.register({ name: 'a2a', description: 'Show or hide the A2A pane (tasks, messages, questions)' })
48 $.clock.every(POLL_MS, () => poll($, exe))
49 void poll($, exe)
50
51 return next(e)
52 })
53
54 on('command.run', { command: 'a2a' }, async $ => {
55 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
56 if (isOpen) {
57 await $.ui.close({ id: PANE })
58
59 return { text: 'A2A pane closed.' }
60 }
61 const opened = await $.ui.open({ id: PANE, title: 'A2A' })
62
63 return { text: opened.isPlaced ? 'A2A pane opened.' : 'A2A pane waits for a wider terminal; the line above the prompt shows the summary.' }
64 })
65
66 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
67 const table = $.ui.resolve(e)
68 const { Box, Button, Text } = table
69 // Mobile has no Input: there the options and the `oc answer` line are the way.
70 const Input = 'Input' in table ? table.Input : null
71 const s = await read($, snapshot)
72 const width = Math.max(20, e.props.bodyColumns)
73 const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
74
75 return (
76 <Box flexDirection="column">
77 <Text color={s.isDispatcherUp ? 'success' : 'error'}>
78 {s.isDispatcherUp ? '● dispatcher running' : '○ dispatcher stopped'}
79 </Text>
80 {s.error && <Text color="warning">{clip(s.error, width)}</Text>}
81 {s.tasks.length === 0 && <Text dimColor>No tasks in the last hour.</Text>}
82 {sorted(s.tasks).slice(0, room).map(t => {
83 const st = style(t.state)
84
85 return (
86 <Box flexDirection="column" marginTop={1}>
87 <Text color={st.color}>
88 {st.symbol} {t.short} {clip(`${t.from} -> ${t.to}`, width - 12)} <Text dimColor>{t.state}</Text>
89 </Text>
90 {lastOf(s.messages, t.id, LINES_PER_TASK).map(m => (
91 <Text dimColor>
92 {' '}
93 {clip(`${m.from}: ${m.text}`, width - 2)}
94 </Text>
95 ))}
96 {isWaiting(t) && t.question && (
97 <Box flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
98 <Text bold color="error">
99 Waiting for you
100 </Text>
101 <Text>{clip(t.question.text, width * 3)}</Text>
102 {t.question.options.map((o, i) => (
103 <Button key={`${t.id}-o${i}`} label={`${i + 1}) ${clip(o, width - 8)}`} onPress={() => answer($, exe, t.id, o)} />
104 ))}
105 {Input && (
106 <Input
107 key={`${t.id}-in`}
108 placeholder={`answer ${t.short}…`}
109 submitLabel="Send"
110 onSubmit={(text: string) => (text.trim() ? answer($, exe, t.id, text) : undefined)}
111 />
112 )}
113 <Text dimColor>or: oc answer {t.short} <text></Text>
114 </Box>
115 )}
116 </Box>
117 )
118 })}
119 </Box>
120 )
121 })
122
123 // Fallback: a one-line band above the prompt while the pane is not placed (narrow terminal or closed).
124 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
125 const line = summary(await read($, snapshot))
126 const isPlaced = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
127 if (e.props.hasSurvey || isPlaced || !line) return next(e)
128 const { Box, Text } = $.ui.resolve(e)
129
130 return (
131 <Box>
132 <Text color="warning">{clip(line, e.props.bodyColumns)}</Text>
133 </Box>
134 )
135 })
136}
137hooks/model.ts 67 lines1import type { Message, Snapshot, Task } from '../types'
2
3// One row per task state: the symbol and theme colour its row is drawn with.
4export const STATES: Record<string, { symbol: string; color: string }> = {
5 submitted: { symbol: '○', color: 'subtle' },
6 working: { symbol: '●', color: 'warning' },
7 'input-required': { symbol: '?', color: 'error' },
8 'auth-required': { symbol: '!', color: 'error' },
9 completed: { symbol: '✓', color: 'success' },
10 failed: { symbol: '✗', color: 'error' },
11 canceled: { symbol: '–', color: 'inactive' },
12 rejected: { symbol: '–', color: 'inactive' },
13}
14const FALLBACK = { symbol: '·', color: 'subtle' }
15const WAITING = ['input-required', 'auth-required']
16const OPEN = ['submitted', 'working', ...WAITING]
17
18export const EMPTY: Snapshot = { isDispatcherUp: false, tasks: [], messages: [], error: null }
19
20export const style = (state: string) => STATES[state] ?? FALLBACK
21export const isWaiting = (t: Task) => WAITING.includes(t.state)
22export const isOpen = (t: Task) => OPEN.includes(t.state)
23
24/** First line of `text`, cut to `max` cells with an ellipsis. */
25export function clip(text: string, max: number): string {
26 const line = (text.split('\n')[0] ?? '').trim()
27 return line.length <= max ? line : `${line.slice(0, Math.max(1, max - 1))}…`
28}
29
30/** The last `n` messages of one task, oldest first (the feed is newest first). */
31export function lastOf(messages: Message[], task: string, n: number): Message[] {
32 return messages.filter(m => m.task === task).slice(0, n).reverse()
33}
34
35/** Open tasks first (waiting ones on top), then the rest; each group keeps the hub's order. */
36export function sorted(tasks: Task[]): Task[] {
37 const rank = (t: Task) => (isWaiting(t) ? 0 : isOpen(t) ? 1 : 2)
38
39 return [...tasks].sort((a, b) => rank(a) - rank(b))
40}
41
42/** Parses the two CLI outputs; any failure becomes `error` and keeps the last good data. */
43export function parse(status: string, messages: string, last: Snapshot): Snapshot {
44 try {
45 const s = JSON.parse(status)
46 const m = JSON.parse(messages)
47
48 return { isDispatcherUp: Boolean(s.dispatcher), tasks: s.tasks ?? [], messages: m.messages ?? [], error: null }
49 } catch (e) {
50 return { ...last, error: `oc output not readable: ${String(e).slice(0, 80)}` }
51 }
52}
53
54/** The one-line summary the band shows when no pane is placed. */
55export function summary(s: Snapshot): string | null {
56 if (s.error) return `a2a: ${s.error}`
57 const open = s.tasks.filter(isOpen)
58 const waiting = s.tasks.filter(isWaiting)
59 if (open.length === 0) return null
60 const head = `a2a ${s.isDispatcherUp ? '●' : '○'} ${open.length} open`
61 const first = waiting[0]
62 if (!first) return head
63 const ask = first.question ? clip(first.question.text, 60) : first.state
64
65 return `${head} · ${waiting.length} waiting: ${first.short} ${first.to}: ${ask}`
66}
67types/index.d.ts 33 lines1export type Question = { text: string; options: string[] }
2
3export type Task = {
4 id: string
5 short: string
6 from: string
7 to: string
8 state: string
9 question: Question | null
10}
11
12export type Message = {
13 task: string
14 short: string
15 from: string
16 to: string
17 text: string
18 ts: string
19}
20
21export type Snapshot = {
22 isDispatcherUp: boolean
23 tasks: Task[]
24 messages: Message[]
25 error: string | null
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'a2a-pane': { snapshot: Shaped<Snapshot> }
31 }
32}
33