SLOPSHOPPER

a2a-pane

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

newpanebandcommandtoastprocess
v0.1.0Apache-2.0updated 2026-10-26CarGDev/codetyper.a2a/plugins/claude-a2a
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · a2a-pane
│ ┃ A2A ✕ › fix the failing auth test and add an audit log call │ ┃ ○ dispatcher stopped │ ┃ oc output not readable: SyntaxError: JSON ⏺ Read(src/auth.ts) │ ┃ Parse error: … ⎿ Read 6 lines │ ┃ No tasks in the last hour. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /a2a │ ⎿ a2a-pane: A2A pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · A2A
○ dispatcher stopped oc output not readable: SyntaxError: JSON Parse error: … No tasks in the last hour.
README

<!-- 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> &bull; <a href="#install"><strong>Install</strong></a> &bull; <a href="a2a/PROTOCOL.md"><strong>Protocol</strong></a> &bull; <a href="CHANGELOG.md"><strong>Changelog</strong></a> &bull; <a href="https://cargdev.io/"><strong>Website</strong></a> &bull; <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> &middot; speaks the task model of the <a href="https://github.com/a2aproject/A2A">Agent2Agent (A2A) protocol</a> &middot; macOS and Linux &middot; needs tmux</sub>


Use

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

CommandWhat it does
oc helpoc's own help (opencode's is opencode --help)
oc statusdispatcher state, running sessions, open tasks
oc watchlive view of the dispatcher log (what the tmux window shows)
oc dispatchrun the dispatcher in this pane (foreground)
oc stopstop 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 shutdownend 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 cancelcancel open tasks (--all, task ids, --context, --dir) and interrupt the turns working on them
oc statscoloured 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 --modelthe 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 --ingestonly the transcript step above: prints how many new claude code usage rows were added
oc status --jsonthe same state as JSON (tasks, questions, progress, today's cost); --dir <folder>
oc messages --jsonrecent 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 ensurestart the dispatcher and the status tab without starting opencode
codetypera2a modelschoose each agent's model (--show, --recommended)
codetypera2a init --md-onlyrefresh only the agent/command markdown (alias codetypera2a update)
codetypera2a evalreplay the deterministic scenarios (routing, hop cap, no duplicate nudges, seeded-bug reviewer); --list shows them
codetypera2a --versionprint the version (also -V, version; oc --version too)
codetypera2a check-modelslint models against model-policy.json
codetypera2a e2e-testhub 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.

Inside opencode (the panels plugin)

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

  • Sidebar: a progress bar (██████────── 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.
  • Footer: a2a ● N open · ~$cost · tokens today.
  • Banner above the prompt when a task waits for you or a context hit the hop cap.
  • Notifications: a toast and, with the window in the background, a system notification and sound when a task needs you, finishes or fails.
  • Slash commands: /a2a-answer (pick one of the numbered options of an input-required task, or write your own), /a2a-continue and /a2a-cancel.

Plans and small chunks

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.

How a task travels

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

Harness engineering

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:

PracticeWhere it lives here
Context: give each agent only what it needsOne 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 interfacesThe 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 loopstools: / 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 checksEvery 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 stateSQLite hub (hub.db) for tasks and nudges; Engram for long-term lessons
Orchestration: plan, decompose, hand offorchestrator -> 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
Observabilityoc 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
Evalscodetypera2a 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 loopPlan approval gates; the dispatcher reports back to the session the task came from

Install

Requirements

  • macOS or Linux (it uses kill, ps, symlinks and process groups; Windows is not supported).
  • opencode v2 (developed against 2.0.32). Check with opencode --version. Or Claude Code (tested with 2.1.310): see Claude Code below.
  • A Rust toolchain (edition 2024, so Rust 1.85 or newer) to build it: 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).
  • Optional: the companion MCP tools below.

Option A: Homebrew

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

Option B: download the repo

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.

Then, on either 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.

Claude Code

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 installsWhere
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:

  • An interactive Claude Code session is never woken by the dispatcher (a headless 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.
  • The opencode tabs plugin has no Claude Code version; the A2A pane below is the Claude Code counterpart of the panels, and oc status and oc watch still work from a shell.
  • Agents run on Claude only when routed there (DISPATCH_BACKEND / DISPATCH_BACKEND_<AGENT>); the default stays opencode.
  • To keep the two runtimes apart, set 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.
  • The plan agent and the /plan-* commands still look for plans in .opencode/plans/.
The A2A pane (Claude Code)

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:

  • the dispatcher dot, then one row per task (? 1a2b3c4d orchestrator -> code-reviewer input-required, coloured by state) with the last three messages of that task under it;
  • for every task that waits for you, a highlighted "Waiting for you" block with the question, one button per numbered option (press it to run 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

Source 3 files
hooks/register.tsx 137 lines
1import { 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} &lt;text&gt;</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}
137
hooks/model.ts 67 lines
1import 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}
67
types/index.d.ts 33 lines
1export 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