A2A protocol client for Claude Code: Claude hands tasks to non-Claude worker agents over Agent2Agent and picks up their results.

Hand work to other AI agents from Claude Code. Keep going. Get the result back.
<img src="docs/assets/demo.gif" alt="Claude Code hands a slow build to a worker, keeps working, and gets the result when it lands" width="900">
In Claude Code 2.1.287 or later:
/plugin marketplace add NovusEdge/a2a-mod
/plugin install a2a-mod@a2a-mod
You don't need a real agent. The repo ships a fake worker (needs Node 24 and pnpm).
sh git clone https://github.com/NovusEdge/a2a-mod && cd a2a-mod pnpm i && pnpm worker `` /a2a add http://127.0.0.1:41241 fake `` send "slow 20 build" to the fake worker ``The task takes 20 seconds. Claude gets a task id after about 7 and moves on. When the worker is done, the mod wakes Claude with the result.
a2a-mod: ⠋ fake slow 20 build 0:12. It stays visible next to /diff.⇄ a2a pane that opens with /a2a, as a tab next to /diff. It shows each worker, its tasks, and buttons to copy a result, reply to a question or cancel.send and each result.<table align="center"> <tr> <td align="center"><img src="docs/assets/screens/running.png" alt="The status line and the hand-off band while a task runs" width="440"><br><sub>A task running</sub></td> <td align="center"><img src="docs/assets/screens/cards.png" alt="Boxed cards in the transcript for a send and its result" width="440"><br><sub>Cards in the transcript</sub></td> </tr> <tr> <td align="center"><img src="docs/assets/screens/wake.png" alt="The message that wakes Claude with a finished task's result" width="440"><br><sub>The result lands</sub></td> <td align="center"><img src="docs/assets/screens/waiting.png" alt="The a2a pane with a task waiting for your reply" width="440"><br><sub>A worker asks a question</sub></td> </tr> </table>
Pick full, pane or minimal in /config to show more or less. The UI has the details.
/a2a add https://worker.example.com research --token-cmd "pass show research"
The token is read from a command, a file or a setting, and never typed into Claude. Worker tokens covers all three. You can also just ask Claude to add a worker; it shows a dialog, and nothing is added until you approve it.
How it works · Write a worker · Troubleshooting · Security · Protocol support
Contributions are welcome: see CONTRIBUTING.md. To report a vulnerability, see SECURITY.md. MIT licensed: see LICENSE.
hooks/register.ts 430 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer, UiCopyResult } from 'claude-code'
3import type { PaneView, RecentState, RecentTask, SentReply, Worker } from '../types/index.d.ts'
4import { A2AError, cancelTask, getTask, send } from './client.ts'
5import { runCommand, USAGE } from './command.ts'
6import { describeOutcome, FULL_MAX, wakeDetail, wakeLine } from './format.ts'
7import { addWorkerTool, removeWorkerTool, type Confirm } from './manage.ts'
8import { changeRecent, isOpen, isWaiting, noteCall, noteSent, noteState, readRecent, visible } from './recent.ts'
9import { loadWorkers, noteFailure, own, parseTokens, targetOf, workerOr, type Host, type SettingTokens } from './registry.ts'
10import { resume, showStatus, track } from './tracker.ts'
11import { palette, type Palette } from './ui/color.ts'
12import { bandRows, bandTree, nextRedraw } from './ui/band.tsx'
13import { resultCard, textOf, useCard } from './ui/cards.tsx'
14import { paneTree, type PaneActions } from './ui/pane.tsx'
15import { parseSettings, type UiSettings } from './ui/settings.ts'
16import { isLive } from './wire.ts'
17
18type Engine = EngineInterface
19
20const TASKS = { plugin: 'a2a-mod', key: 'tasks' } as const
21const RECENT = { plugin: 'a2a-mod', key: 'recent' } as const
22const CALLS = { plugin: 'a2a-mod', key: 'calls' } as const
23const PANE = { id: 'a2a-workers', title: 'A2A workers' } as const
24// Wanted sizes: a slim dock beside /diff, a short block when inline.
25const PANE_COLUMNS = 32
26const PANE_ROWS = 12
27// -1, not 0: a clock that starts at 0 can end a task at 0, and Clear done at that moment still has to hide it.
28const EMPTY_VIEW: PaneView = { open: [], replying: [], collapsed: [], all: [], clearedAt: -1 }
29const paneView = atom({ plugin: 'a2a-mod', key: 'pane' } as const, EMPTY_VIEW)
30const replies = atom({ plugin: 'a2a-mod', key: 'replies' } as const, [] as SentReply[])
31const played = atom({ plugin: 'a2a-mod', key: 'played' } as const, [] as string[])
32const PLAYED_MAX = 200
33const TOKEN_CMD_TIMEOUT_MS = 10_000
34// A pane button's own deadline for the worker calls it makes.
35const PRESS_MS = 9000
36const NOT_COPIED: Record<Extract<UiCopyResult, { isCopied: false }>['reason'], string> = {
37 'no-surface': 'nothing is drawing',
38 'no-clipboard': 'this surface has no clipboard Claude Code can write to',
39 refused: 'another mod refused it',
40}
41
42// The status line and the band age by this clock, so a test's mock clock moves them. Another mod's
43// clock.now hook may refuse; the wall clock is the fallback.
44async function nowOf($: Engine): Promise<number> {
45 try { return await $.clock.now() } catch { return Date.now() }
46}
47
48// A wake is shown as `text` alone; its detail waits here for our own prompt.submit hook, which
49// attaches it as context. Whether the engine runs that hook for this mod's own submit is unverified
50// (the test kit does not), and a reload empties the list. Either way Claude gets the line alone, so
51// the send result tells it to fetch the result with the task tool.
52const pendingWakes: { text: string; detail: string }[] = []
53
54function hostOf($: Engine, settingTokens: SettingTokens, ui: UiSettings): Host {
55 return {
56 fetch: (url, init) => $.http.fetch(url, init),
57 readWorkers: () => $.store.get('workers'),
58 writeWorkers: all => $.store.set('workers', all),
59 settingTokens,
60 run: argv => $.process.run(argv, { timeoutMs: TOKEN_CMD_TIMEOUT_MS }),
61 readFile: path => $.fs.read(path),
62 readTasks: () => $.state.get(TASKS),
63 writeTasks: async (tasks, ifVersion) => (await $.state.set(TASKS, tasks, { ifVersion })).isSet,
64 readRecent: () => $.state.get(RECENT),
65 writeRecent: async (recent, ifVersion) => (await $.state.set(RECENT, recent, { ifVersion })).isSet,
66 readCalls: () => $.state.get(CALLS),
67 writeCalls: async (calls, ifVersion) => (await $.state.set(CALLS, calls, { ifVersion })).isSet,
68 readDurations: async () => ((await $.store.get('durations')) as Record<string, number[]> | undefined) ?? {},
69 writeDurations: all => $.store.set('durations', all),
70 status: text => $.ui.status(text),
71 anim: ui.animations,
72 now: () => nowOf($),
73 every: (ms, fn) => $.clock.every(ms, fn),
74 sleep: ms => $.clock.sleep(ms),
75 wake: async (text, detail) => {
76 const entry = { text, detail }
77 pendingWakes.push(entry)
78 try { await $.prompt.submit({ text, asUser: true }) } finally {
79 const at = pendingWakes.indexOf(entry)
80 if (at >= 0) pendingWakes.splice(at, 1)
81 }
82 },
83 }
84}
85
86const TOOLS = [
87 { name: 'workers', description: 'List the A2A worker agents the user registered, with their skills. Call before send to pick a worker.', inputSchema: { type: 'object', properties: {} } },
88 {
89 name: 'send',
90 description: 'Send a task to an A2A worker agent. Returns the answer if it finishes within a few seconds; otherwise returns a task id, and you get a message when it finishes. Do not poll. Pass taskId to answer a worker that asked for input, contextId to continue a conversation.',
91 inputSchema: {
92 type: 'object',
93 properties: {
94 worker: { type: 'string', description: 'Worker alias from the workers tool' },
95 message: { type: 'string', description: 'What the worker should do, self-contained' },
96 taskId: { type: 'string' },
97 contextId: { type: 'string' },
98 },
99 required: ['worker', 'message'],
100 },
101 },
102 {
103 name: 'task',
104 description: 'Get the state and full result of an A2A task, or cancel it.',
105 inputSchema: {
106 type: 'object',
107 properties: { worker: { type: 'string' }, taskId: { type: 'string' }, cancel: { type: 'boolean' } },
108 required: ['worker', 'taskId'],
109 },
110 },
111 {
112 name: 'add_worker',
113 description: 'Register an A2A worker from its URL. The user is asked to approve it first. Tokens set by command or file, and --trust-endpoint, need the user to run /a2a add themselves.',
114 inputSchema: {
115 type: 'object',
116 properties: {
117 url: { type: 'string', description: 'The worker base URL, or its Agent Card URL' },
118 alias: { type: 'string', description: 'Short name to call it by; defaults to the card name' },
119 },
120 required: ['url'],
121 },
122 },
123 {
124 name: 'remove_worker',
125 description: 'Forget a registered A2A worker. The user is asked to approve it first.',
126 inputSchema: { type: 'object', properties: { alias: { type: 'string', description: 'Worker alias from the workers tool' } }, required: ['alias'] },
127 },
128]
129
130// The dialog shows on every call, whatever the permission mode. A reply that is not exactly the
131// Add/Remove label (free text under Other) is a no.
132const confirmOf = ($: Engine): Confirm => async (question, yes) => {
133 try { return (await $.ui.ask(question, { header: 'a2a', options: [yes, 'Cancel'] })) === yes ? 'yes' : 'no' } catch { return 'none' }
134}
135
136export const INLINE_POLLS_MS = [500, 1000, 2000, 4000]
137// Sleeps count against the hook's 10 s budget; this keeps the last getTask and the reply inside it.
138const BUDGET_RESERVE_MS = 1500
139// What a bounded call leaves of the budget for tracking the task and replying.
140const REPLY_MS = 500
141
142// Waiting on $.http.fetch does not spend the hook budget, so a silent worker would hold Claude's turn
143// with no limit. Race it against what is left of the budget.
144async function bounded<T>($: Engine, remainingMs: number, signal: AbortSignal, alias: string, work: Promise<T>): Promise<T> {
145 const stop = new AbortController()
146 const onAbort = () => stop.abort()
147 signal.addEventListener('abort', onAbort)
148 const ms = Math.max(0, remainingMs - REPLY_MS)
149 try {
150 return await Promise.race([
151 work,
152 // An aborted or failed timer leaves the race to the work.
153 $.clock.sleep(ms, { signal: stop.signal }).then(
154 (): never => { throw new A2AError(`worker ${alias} did not answer within ${Math.round(ms / 1000)} s. Try again later, or ask the user to check it.`) },
155 () => new Promise<never>(() => {}),
156 ),
157 ])
158 } finally {
159 signal.removeEventListener('abort', onAbort)
160 stop.abort()
161 }
162}
163
164async function paletteOf($: Engine): Promise<Palette> {
165 // Another mod's config.list hook may refuse or fail; the dark palette is the fallback.
166 try { return palette((await $.config.list()).find(row => row.key === 'theme')?.value) } catch { return palette(undefined) }
167}
168
169const without = (ids: string[], id: string) => ids.filter(x => x !== id)
170const toggled = (ids: string[] | undefined, id: string) => ((ids ?? []).includes(id) ? without(ids ?? [], id) : [...(ids ?? []), id])
171
172// The card is indented two columns, so this leaves two spare on the right.
173const cardWidth = (columns: number | undefined) => Math.max(1, Math.min(100, (columns ?? 80) - 4))
174
175async function cancelFromPane($: Engine, host: Host, t: RecentTask): Promise<void> {
176 const w = own(await loadWorkers(host), t.worker)
177 if (!w) { $.ui.toast(`a2a: ${t.worker} was removed.`); return }
178 await changeRecent(host, t.taskId, held => held && { ...held, canceling: true })
179 const until = Date.now() + PRESS_MS
180 const never = new AbortController().signal
181 try {
182 const target = await bounded($, until - Date.now(), never, w.alias, targetOf(host, w))
183 await bounded($, until - Date.now(), never, w.alias, cancelTask(host.fetch, target, t.taskId))
184 // A waiting task is not polled. Tracking it again lets the next poll report the end and wake Claude.
185 if (isWaiting(t.state)) await track(host, { worker: w.alias, taskId: t.taskId, contextId: t.contextId, state: 'working', startedAt: t.startedAt })
186 } catch (err) {
187 noteFailure(w, err)
188 await changeRecent(host, t.taskId, held => { if (!held) return held; const { canceling, ...rest } = held; return rest })
189 if (err instanceof A2AError) $.ui.toast(`a2a: ${err.message}`)
190 else throw err
191 }
192}
193
194async function replyFromPane($: Engine, host: Host, t: RecentTask, text: string): Promise<boolean> {
195 const w = own(await loadWorkers(host), t.worker)
196 if (!w) { $.ui.toast(`a2a: ${t.worker} was removed.`); return false }
197 const until = Date.now() + PRESS_MS
198 const never = new AbortController().signal
199 try {
200 const target = await bounded($, until - Date.now(), never, w.alias, targetOf(host, w))
201 const out = await bounded($, until - Date.now(), never, w.alias, send(host.fetch, target, { text, taskId: t.taskId, contextId: t.contextId }))
202 const state = out.kind === 'task' ? out.state : 'completed'
203 await noteSent(host, { worker: w.alias, taskId: t.taskId, contextId: out.contextId, text, state }, 'user')
204 if (out.kind === 'task' && isLive(out.state)) {
205 await track(host, { worker: w.alias, taskId: t.taskId, contextId: out.contextId, state: out.state, startedAt: t.startedAt })
206 return true
207 }
208 // Claude heard the question, not this answer, so an answer that ends the task wakes it.
209 await noteState(host, w.alias, t.taskId, state, out.text)
210 await showStatus(host)
211 const item = { worker: w.alias, taskId: t.taskId, state, body: describeOutcome(w.alias, out) }
212 await host.wake(wakeLine([item]), wakeDetail([item]))
213 return true
214 } catch (err) {
215 noteFailure(w, err)
216 if (err instanceof A2AError) { $.ui.toast(`a2a: ${err.message}`); return false }
217 throw err
218 }
219}
220
221export const register: Register = (on, options) => {
222 const settingTokens = parseTokens(options.tokens)
223 const ui = parseSettings(options)
224 // Not in $.state: a write per keystroke would redraw the pane under the person's typing.
225 const drafts = new Map<string, string>()
226 let paneExpiry: Timer | undefined
227 let bandExpiry: Timer | undefined
228
229 on('session.start', async ($, e, next) => {
230 await Promise.all([
231 ...TOOLS.map(t => $.tool.register(t)),
232 $.command.register({ name: 'a2a', description: 'Manage A2A worker agents', argumentHint: 'add <url> [alias] [--token-setting | --token-cmd "<cmd>" | --token-file <path>] | list | remove <alias>' }),
233 ])
234 await resume(hostOf($, settingTokens, ui))
235 // The engine keeps a pane open across reloads, so one opened under another layout would stay.
236 if (ui.layout === 'minimal') try { await $.ui.close({ id: PANE.id }) } catch {}
237 return next(e)
238 })
239
240 on('command.run', { command: 'a2a' }, async ($, e) => {
241 if (e.args.trim()) return { text: await runCommand(hostOf($, settingTokens, ui), e.args) }
242 // Only the person opens the pane, so a running task never switches the dock away from /diff.
243 // Asked, it is placed at any width; another mod's ui.open hook may still deny it.
244 if (ui.layout !== 'minimal') try { await $.ui.open({ ...PANE, columns: PANE_COLUMNS, rows: PANE_ROWS }) } catch {}
245 return { text: USAGE }
246 })
247
248 on('prompt.submit', async ($, e, next) => {
249 if (e.origin.kind === 'plugin' && e.origin.name === 'a2a-mod') {
250 const at = pendingWakes.findIndex(w => w.text === e.text)
251 if (at < 0) return next(e)
252 const [wake] = pendingWakes.splice(at, 1)
253 return next({ ...e, context: [...(e.context ?? []), wake!.detail] })
254 }
255 if (e.origin.kind !== 'composer') return next(e)
256 const sent = await read($, replies)
257 if (!sent.length) return next(e)
258 await update($, replies, () => [])
259 const told = sent.map(r => `You answered ${r.worker}'s question on task ${r.taskId}: "${r.text}"`)
260 return next({ ...e, context: [...(e.context ?? []), ...told] })
261 })
262
263 on('ui.render', { component: 'ToolUse', props: { tool: 'mcp__a2a-mod__send' } }, async ($, e, next) => {
264 if (ui.layout !== 'full') return next(e)
265 const host = hostOf($, settingTokens, ui)
266 const input = (e.props.input ?? {}) as { worker?: unknown; message?: unknown }
267 const alias = String(input.worker ?? '')
268 const [workers, calls, pal] = await Promise.all([loadWorkers(host), host.readCalls(), paletteOf($)])
269 const now = await nowOf($)
270 return useCard($.ui.resolve(e), {
271 surface: e.surface, width: cardWidth(e.viewport?.columns), anim: ui.animations, pal, now, alias, worker: own(workers, alias),
272 message: String(input.message ?? ''), isRunning: e.props.isRunning, startedAt: calls.value?.[e.props.tool_use_id]?.startedAt ?? now,
273 })
274 })
275
276 on('ui.render', { component: 'ToolResult', props: { tool: 'mcp__a2a-mod__send' } }, async ($, e, next) => {
277 if (ui.layout !== 'full') return next(e)
278 const host = hostOf($, settingTokens, ui)
279 const [calls, done, list] = await Promise.all([host.readCalls(), read($, played), readRecent(host)])
280 const call = calls.value?.[e.props.tool_use_id]
281 const playId = call?.taskId ?? e.props.tool_use_id
282 // The output is what Claude was told at the time; the task may have moved on since.
283 const now = call?.taskId ? list.find(t => t.taskId === call.taskId) : undefined
284 const moved = now !== undefined && now.state !== call?.state
285 return resultCard($.ui.resolve(e), {
286 surface: e.surface, width: cardWidth(e.viewport?.columns), anim: ui.animations,
287 state: moved ? now.state : call?.state, isErrored: e.props.isErrored, text: moved ? (now.result ?? '') : textOf(e.props.output),
288 playId, played: done.includes(playId), taskId: call?.taskId,
289 })
290 })
291
292 // The band is shared: another mod may draw there, and the first tree in the chain wins. So it
293 // draws only while a task runs or waits (and for the return packet after), yields to surveys,
294 // and stacks next(e)'s tree under its own.
295 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
296 if (ui.layout !== 'full' || e.props.hasSurvey) return next(e)
297 const now = await nowOf($)
298 const rows = bandRows(await readRecent(hostOf($, settingTokens, ui)), now)
299 if (!rows.length) return next(e)
300 // The return packet rests with no state change to redraw the band, so a timer does it.
301 bandExpiry?.cancel()
302 const due = nextRedraw(rows, now, true)
303 bandExpiry = due === undefined ? undefined : $.clock.after(due, () => $.ui.invalidate('ui.render'))
304 const [pal, theirs] = await Promise.all([paletteOf($), next(e)])
305 return bandTree($.ui.resolve(e), { surface: e.surface, width: e.props.bodyColumns, now, anim: ui.animations, pal, rows }, theirs)
306 })
307
308 on('ui.message', async ($, e) => {
309 const id = (e.data as { played?: unknown } | null)?.played
310 if (typeof id === 'string') await update($, played, ids => (ids.includes(id) ? ids : [...ids, id].slice(-PLAYED_MAX)))
311 return {}
312 })
313
314 on('ui.render', { component: 'Pane', requestId: 'a2a-workers' }, async ($, e) => {
315 const host = hostOf($, settingTokens, ui)
316 const now = await nowOf($)
317 const [workers, list, stored, durations, pal] = await Promise.all([loadWorkers(host), readRecent(host), read($, paneView), host.readDurations(), paletteOf($)])
318 // State saved by an earlier version lacks the newer fields.
319 const view: PaneView = { ...EMPTY_VIEW, ...stored }
320 const act: PaneActions = {
321 cancel: t => cancelFromPane($, host, t),
322 // One row open at a time keeps the pane under the engine's 100,000-character tree limit.
323 toggleOpen: t => update($, paneView, v => ({ ...v, open: v.open.includes(t.taskId) ? [] : [t.taskId] })),
324 toggleBox: alias => update($, paneView, v => ({ ...v, collapsed: toggled(v.collapsed, alias) })),
325 toggleAll: alias => update($, paneView, v => ({ ...v, all: toggled(v.all, alias) })),
326 // Hides what ended by now from the pane only; the recent list keeps it for the cards.
327 clearDone: async () => { const at = await nowOf($); await update($, paneView, v => ({ ...v, clearedAt: at })) },
328 copy: async (t, press) => {
329 const copied = await $.ui.copy({ text: t.result ?? '', surface: press.surface })
330 if (!copied.isCopied) $.ui.toast(`a2a: nothing was copied: ${NOT_COPIED[copied.reason]}.`)
331 },
332 startReply: async t => {
333 await update($, paneView, v => ({ ...v, replying: [...without(v.replying, t.taskId), t.taskId] }))
334 // autoFocus applies only when the pane takes the keyboard, and a press does not hand it over.
335 // The field shows either way: claude plugin test 2.1.288 rejects every $.ui.focus.
336 try { await $.ui.focus({ requestId: PANE.id, key: `input:${t.taskId}` }) } catch {}
337 },
338 draft: (t, text) => { drafts.set(t.taskId, text) },
339 send: async (t, text) => {
340 if (!text.trim() || !(await replyFromPane($, host, t, text))) return
341 drafts.delete(t.taskId)
342 await update($, paneView, v => ({ ...v, replying: without(v.replying, t.taskId) }))
343 await update($, replies, r => [...r, { worker: t.worker, taskId: t.taskId, text }])
344 },
345 discard: async t => {
346 drafts.delete(t.taskId)
347 await update($, paneView, v => ({ ...v, replying: without(v.replying, t.taskId) }))
348 },
349 }
350 const shown = Object.values(workers).sort((a, b) => a.addedAt - b.addedAt)
351 const aging = visible(list, now)
352 // A row ages out with no state change to redraw the pane, so a timer does it.
353 paneExpiry?.cancel()
354 const due = nextRedraw(aging, now, false)
355 paneExpiry = due === undefined ? undefined : $.clock.after(due, () => $.ui.invalidate('ui.render'))
356 const rows = aging.filter(t => isOpen(t) || (t.endedAt ?? t.startedAt) > view.clearedAt)
357 return paneTree($.ui.resolve(e), { surface: e.surface, width: e.props.bodyColumns, height: e.props.scroll.bodyRows, now, anim: ui.animations, pal, workers: shown, rows, durations, view, drafts }, act)
358 })
359
360 on('tool.call', { tool: 'mcp__a2a-mod__workers' }, async $ => {
361 const all = Object.values(await loadWorkers(hostOf($, settingTokens, ui)))
362 if (!all.length) return { result: 'No workers registered. Ask the user to run /a2a add <url>.' }
363 return { result: all.map(w => `${w.alias}: ${w.name}. ${w.description}\n skills: ${w.skills.map(s => `${s.id} (${s.description || s.name})`).join('; ') || 'none listed'}`).join('\n') }
364 })
365
366 on('tool.call', { tool: 'mcp__a2a-mod__add_worker' }, async ($, e) =>
367 ({ result: await addWorkerTool(hostOf($, settingTokens, ui), confirmOf($), e as unknown as { url?: unknown; alias?: unknown }) }))
368
369 on('tool.call', { tool: 'mcp__a2a-mod__remove_worker' }, async ($, e) =>
370 ({ result: await removeWorkerTool(hostOf($, settingTokens, ui), confirmOf($), e as unknown as { alias?: unknown }) }))
371
372 on('tool.call', { tool: 'mcp__a2a-mod__send' }, async ($, e, next) => {
373 const input = e as unknown as { worker?: string; message?: string; taskId?: string; contextId?: string }
374 const host = hostOf($, settingTokens, ui)
375 const w = await workerOr(host, input.worker)
376 if (typeof w === 'string') return { result: w }
377 const startedAt = await host.now()
378 const call = (state: RecentState, taskId?: string) => noteCall(host, e.tool_use_id, { worker: w.alias, state, startedAt, ...(taskId ? { taskId } : {}) })
379 await call('submitted')
380 try {
381 const fetch = host.fetch
382 const t = await bounded($, next.budget.remainingMs, next.signal, w.alias, targetOf(host, w))
383 let out = await bounded($, next.budget.remainingMs, next.signal, w.alias, send(fetch, t, { text: String(input.message ?? ''), taskId: input.taskId, contextId: input.contextId }))
384 // A send answered with a message has no task id; its row is keyed by the call instead.
385 const id = out.kind === 'task' ? out.taskId : e.tool_use_id
386 await noteSent(host, { worker: w.alias, taskId: id, contextId: out.contextId, text: String(input.message ?? ''), state: out.kind === 'task' ? out.state : 'working' }, 'claude')
387 for (const ms of INLINE_POLLS_MS) {
388 if (out.kind !== 'task' || !isLive(out.state)) break
389 if (next.budget.remainingMs - ms < BUDGET_RESERVE_MS) break
390 try { await $.clock.sleep(ms, { signal: next.signal }) } catch { break }
391 // A slow poll keeps the last state, so the task is still tracked below.
392 try { out = await bounded($, next.budget.remainingMs, next.signal, w.alias, getTask(fetch, t, out.taskId)) } catch (err) { if (err instanceof A2AError) { noteFailure(w, err); break } throw err }
393 }
394 if (out.kind === 'task' && isLive(out.state)) {
395 await track(host, { worker: w.alias, taskId: out.taskId, contextId: out.contextId, state: out.state, startedAt })
396 await noteState(host, w.alias, out.taskId, out.state, out.text)
397 await call(out.state, out.taskId)
398 return { result: `${w.alias} is working on it (task ${out.taskId}, ${out.state}). You will get a message when it finishes, reading "a2a: ${w.alias} task ${out.taskId.slice(0, 4)}… completed". It comes from this mod, not the user. If it carries no result, call the task tool with this task id; do not poll. Carry on with other work.` }
399 }
400 const state = out.kind === 'task' ? out.state : 'completed'
401 await noteState(host, w.alias, id, state, out.text)
402 await call(state, out.kind === 'task' ? out.taskId : undefined)
403 await showStatus(host)
404 return { result: describeOutcome(w.alias, out) }
405 } catch (err) {
406 noteFailure(w, err)
407 await call('failed')
408 if (err instanceof A2AError) return { result: `a2a: ${err.message}` }
409 throw err
410 }
411 })
412
413 on('tool.call', { tool: 'mcp__a2a-mod__task' }, async ($, e, next) => {
414 const input = e as unknown as { worker?: string; taskId?: string; cancel?: boolean }
415 const host = hostOf($, settingTokens, ui)
416 const w = await workerOr(host, input.worker)
417 if (typeof w === 'string') return { result: w }
418 try {
419 const fetch = host.fetch
420 const t = await bounded($, next.budget.remainingMs, next.signal, w.alias, targetOf(host, w))
421 const out = await bounded($, next.budget.remainingMs, next.signal, w.alias, input.cancel ? cancelTask(fetch, t, String(input.taskId)) : getTask(fetch, t, String(input.taskId)))
422 return { result: describeOutcome(w.alias, out, FULL_MAX) }
423 } catch (err) {
424 noteFailure(w, err)
425 if (err instanceof A2AError) return { result: `a2a: ${err.message}` }
426 throw err
427 }
428 })
429}
430types/index.d.ts 106 lines1export type ProtocolVersion = '1.0' | '0.3'
2
3export type Skill = { id: string; name: string; description: string }
4
5/**
6 * Where a worker's token comes from. The value itself is never stored here:
7 * `setting` looks the alias up in the plugin's sensitive `tokens` setting.
8 */
9export type TokenSource =
10 | { kind: 'setting' }
11 | { kind: 'cmd'; cmd: string }
12 | { kind: 'file'; path: string }
13
14export type Worker = {
15 alias: string
16 name: string
17 description: string
18 cardUrl: string
19 endpoint: string
20 version: ProtocolVersion
21 skills: Skill[]
22 /** The card names a security requirement. */
23 needsAuth: boolean
24 auth: TokenSource
25 trustedOrigin?: string
26 /** The card's `provider.organization`. */
27 organization?: string
28 addedAt: number
29}
30
31/** A worker with its token resolved for one call. Never stored. */
32export type Target = Worker & { token?: string }
33
34export type TaskState =
35 | 'submitted' | 'working' | 'completed' | 'failed' | 'canceled'
36 | 'rejected' | 'input-required' | 'auth-required' | 'unknown'
37
38export type Outcome =
39 | { kind: 'message'; contextId?: string; text: string }
40 | { kind: 'task'; taskId: string; contextId?: string; state: TaskState; text: string }
41
42export type TaskOutcome = Extract<Outcome, { kind: 'task' }>
43
44export type TrackedTask = {
45 worker: string
46 taskId: string
47 contextId?: string
48 state: TaskState
49 startedAt: number
50 failures: number
51}
52
53/** A task as the UI lists it: its A2A state, or `removed` when its worker was removed mid-task. */
54export type RecentState = TaskState | 'removed'
55
56export type RecentTask = {
57 worker: string
58 /** The A2A task id; for a send answered with a message, the tool call's id. */
59 taskId: string
60 contextId?: string
61 /** The message that started the task. */
62 text: string
63 state: RecentState
64 startedAt: number
65 /** When `state` last changed: the row's dot pulses for a second after it. */
66 changedAt: number
67 endedAt?: number
68 /** The result, or the worker's question while waiting; at most 10,000 characters. */
69 result?: string
70 /** Who answered first while the task was waiting. */
71 answeredBy?: 'user' | 'claude'
72 /** Set by Cancel until a poll reports how the task ended. */
73 canceling?: true
74 /** The last `N%` a live task's status message carried. */
75 progress?: number
76 /** A live task's latest status message, cut short. */
77 message?: string
78}
79
80/** What one send tool call came to, keyed by its tool_use_id. */
81export type CallInfo = { worker: string; taskId?: string; state: RecentState; startedAt: number }
82
83/**
84 * What the person has done to the pane. `open` holds the one task whose detail shows and `replying` the
85 * tasks with a Reply field; `collapsed` and `all` hold worker aliases (box folded to one line; more than
86 * three tasks listed); `clearedAt` hides tasks that finished by then.
87 */
88export type PaneView = { open: string[]; replying: string[]; collapsed: string[]; all: string[]; clearedAt: number }
89
90/** A reply the user sent from the pane, told to Claude with their next prompt. */
91export type SentReply = { worker: string; taskId: string; text: string }
92
93declare module 'claude-code' {
94 interface PluginState {
95 'a2a-mod': {
96 tasks: TrackedTask[]
97 recent: RecentTask[]
98 calls: Record<string, CallInfo>
99 /** Task ids (or tool call ids) whose result has typed itself in once. */
100 played: string[]
101 pane: PaneView
102 replies: SentReply[]
103 }
104 }
105}
106hooks/client.ts 67 lines1import type { Outcome, Target, TaskOutcome } from '../types/index.d.ts'
2import { printable } from './format.ts'
3import { cardUrlFor, parseCard, parseSend, parseTask, rpcBody, type Op, type OpParams } from './wire.ts'
4
5export type Fetcher = (url: string, init: { method: string; headers: Record<string, string>; body?: string }) => Promise<{ status: number; ok: boolean; text: string }>
6
7export class A2AError extends Error {
8 /** The HTTP status when the worker refused the request (401 or 403). */
9 status?: number
10 constructor(message: string, status?: number) {
11 super(message)
12 this.status = status
13 }
14}
15
16export const scrub = (text: string, token?: string) => (token ? text.replaceAll(token, '[token]') : text)
17
18function parseJson(text: string, what: string): any {
19 try { return JSON.parse(text) } catch { throw new A2AError(`${what} did not return JSON`) }
20}
21
22export async function discover(fetch: Fetcher, url: string) {
23 const cardUrl = cardUrlFor(url)
24 // A 1.0 server with 0.3 compat serves the 0.3 card shape when this header is absent.
25 const res = await fetch(cardUrl, { method: 'GET', headers: { Accept: 'application/json', 'A2A-Version': '1.0' } })
26 if (!res.ok) throw new A2AError(`fetching the Agent Card at ${cardUrl} failed with HTTP ${res.status}`)
27 return { cardUrl, card: parseCard(parseJson(res.text, cardUrl), cardUrl) }
28}
29
30let nextId = 1
31
32async function rpc(fetch: Fetcher, t: Target, op: Op, params: OpParams): Promise<unknown> {
33 const origin = new URL(t.endpoint).origin
34 if (t.token && origin !== new URL(t.cardUrl).origin && origin !== t.trustedOrigin) {
35 throw new A2AError(`worker ${t.alias}'s endpoint ${origin} is on a different origin from its card, so its token is not sent there. If that is expected, the user re-adds it with --trust-endpoint.`)
36 }
37 const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' }
38 if (t.version === '1.0') headers['A2A-Version'] = '1.0'
39 if (t.token) headers.Authorization = `Bearer ${t.token}`
40 let res: Awaited<ReturnType<Fetcher>>
41 try {
42 res = await fetch(t.endpoint, { method: 'POST', headers, body: rpcBody(t.version, op, params, nextId++) })
43 } catch (err) {
44 throw new A2AError(scrub(`could not reach worker ${t.alias} at ${origin}: ${err instanceof Error ? err.message : String(err)}`, t.token))
45 }
46 if (res.status === 401 || res.status === 403) {
47 throw new A2AError(`worker ${t.alias} refused the request (HTTP ${res.status}); its token is ${t.token ? 'wrong or expired' : 'missing'}. The user should check its token source (/a2a list shows it).`, res.status)
48 }
49 if (!res.ok) throw new A2AError(`worker ${t.alias} answered HTTP ${res.status}`)
50 const body = parseJson(res.text, `worker ${t.alias}`)
51 // The message is the worker's text and reaches toasts, tool results and the pane unfiltered.
52 if (body?.error) throw new A2AError(printable(scrub(`worker ${t.alias} returned error ${body.error.code}: ${body.error.message}`, t.token)))
53 if (typeof body?.result !== 'object' || body.result === null) throw new A2AError(`worker ${t.alias} returned no result`)
54 return body.result
55}
56
57const clean = <O extends Outcome>(o: O, token?: string): O => ({ ...o, text: scrub(o.text, token) })
58
59export const send = async (fetch: Fetcher, t: Target, input: { text: string; contextId?: string; taskId?: string }): Promise<Outcome> =>
60 clean(parseSend(t.version, await rpc(fetch, t, 'send', input)), t.token)
61
62export const getTask = async (fetch: Fetcher, t: Target, id: string): Promise<TaskOutcome> =>
63 clean(parseTask(t.version, await rpc(fetch, t, 'get', { id })), t.token)
64
65export const cancelTask = async (fetch: Fetcher, t: Target, id: string): Promise<TaskOutcome> =>
66 clean(parseTask(t.version, await rpc(fetch, t, 'cancel', { id })), t.token)
67hooks/command.ts 128 lines1import type { TokenSource, Worker } from '../types/index.d.ts'
2import { discover, A2AError } from './client.ts'
3import { BAD_ALIAS, loadWorkers, own, removeWorker, saveWorker, splitArgs, type Host } from './registry.ts'
4import { noteRemoved } from './recent.ts'
5import { runningTasks, showStatus } from './tracker.ts'
6
7export const USAGE = [
8 'Usage:',
9 ' /a2a add <url> [alias] [--token-setting | --token-cmd "<command>" | --token-file <path>] [--trust-endpoint]',
10 ' (re-run to refresh; with no token flag the token comes from the tokens setting)',
11 ' /a2a list',
12 ' /a2a remove <alias>',
13 '',
14 'Never type a token here: slash commands are kept in the transcript.',
15].join('\n')
16
17const NO_TOKEN_FLAG = 'a2a: --token is not accepted, because slash commands are kept in the transcript. Use the tokens plugin setting (the default), --token-cmd or --token-file.'
18
19// Cut at 40: a card name is the worker's own text, and the alias is stored and shown everywhere.
20export const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40).replace(/-$/, '') || 'worker'
21
22export function configureHint(alias: string): string {
23 return [
24 'Set it from a shell, then restart Claude Code:',
25 ` claude plugin configure a2a-mod@a2a-mod --values-stdin <<< '{"tokens":"${alias}=<token>"}'`,
26 'This replaces the whole tokens setting, so list every worker in it: "w1=<token> w2=<token>".',
27 'Use the plugin id `claude plugin list` shows if yours is not a2a-mod@a2a-mod.',
28 ].join('\n')
29}
30
31function source(auth: TokenSource): string {
32 if (auth.kind === 'cmd') return `token from command: ${auth.cmd}`
33 if (auth.kind === 'file') return `token from file ${auth.path}`
34 return 'token from the tokens setting'
35}
36
37function describe(w: Worker, tokens: Readonly<Record<string, string>>): string {
38 const skills = w.skills.map(s => `${s.id}: ${s.description || s.name}`).join('; ') || 'no skills listed'
39 const hasToken = w.needsAuth || w.auth.kind !== 'setting' || own(tokens, w.alias) !== undefined
40 return `${w.alias} ${w.name} (A2A ${w.version}${hasToken ? `, ${source(w.auth)}` : ''})\n ${w.description}\n skills: ${skills}`
41}
42
43async function add(host: Host, words: string[]): Promise<string> {
44 const sources: TokenSource[] = []
45 let trust = false
46 const rest: string[] = []
47 for (let i = 0; i < words.length; i++) {
48 const word = words[i]!
49 if (word === '--token' || word.startsWith('--token=')) return NO_TOKEN_FLAG
50 if (word === '--token-setting') sources.push({ kind: 'setting' })
51 else if (word === '--token-cmd' || word === '--token-file') {
52 const value = words[++i]
53 if (!value) return `a2a: ${word} needs a value.\n\n${USAGE}`
54 sources.push(word === '--token-cmd' ? { kind: 'cmd', cmd: value } : { kind: 'file', path: value })
55 }
56 else if (word === '--trust-endpoint') trust = true
57 else if (word.startsWith('--')) return `a2a: unknown option ${word}.\n\n${USAGE}`
58 else rest.push(word)
59 }
60 if (sources.length > 1) return 'a2a: give a worker one of --token-setting, --token-cmd or --token-file, not several.'
61 const auth = sources[0] ?? { kind: 'setting' }
62 if (auth.kind === 'cmd' && !splitArgs(auth.cmd).length) return 'a2a: --token-cmd needs a command.'
63 const [url, alias] = rest
64 if (!url) return USAGE
65 if (alias === BAD_ALIAS) return `a2a: ${BAD_ALIAS} cannot be used as an alias.`
66 const fetched = await fetchWorker(host, url, alias, auth)
67 const w: Worker = trust ? { ...fetched, trustedOrigin: new URL(fetched.endpoint).origin } : fetched
68 await saveWorker(host, w)
69 return [`Added worker:\n${describe(w, host.settingTokens.map)}`, ...tokenNotes(host, w)].join('\n\n')
70}
71
72/** Discovery and the Worker record that `/a2a add` and the add_worker tool both store. */
73export async function fetchWorker(host: Host, url: string, alias: string | undefined, auth: TokenSource): Promise<Worker> {
74 const { cardUrl, card } = await discover(host.fetch, url)
75 return { ...card, cardUrl, alias: alias ?? slug(card.name), addedAt: Date.now(), auth }
76}
77
78/** What the person has still to do before a stored worker can authenticate. */
79export function tokenNotes(host: Host, w: Worker): string[] {
80 const notes: string[] = []
81 const endpointOrigin = new URL(w.endpoint).origin
82 const hasSettingToken = own(host.settingTokens.map, w.alias) !== undefined
83 if (w.auth.kind === 'setting' && !hasSettingToken && w.needsAuth) {
84 notes.push(`${w.alias}'s card asks for authentication, and the tokens setting has no token for it yet.\n${configureHint(w.alias)}`)
85 }
86 if ((w.auth.kind !== 'setting' || hasSettingToken) && endpointOrigin !== new URL(w.cardUrl).origin && w.trustedOrigin !== endpointOrigin) {
87 notes.push(`Its endpoint (${endpointOrigin}) is on another origin from its card, so the token is not sent until you re-add it with --trust-endpoint.`)
88 }
89 return notes
90}
91
92/** Removes a worker and what the app kept about its runs. False when there is no such worker. */
93export async function forgetWorker(host: Host, alias: string): Promise<boolean> {
94 if (!(await removeWorker(host, alias))) return false
95 await noteRemoved(host, alias)
96 const { [alias]: _gone, ...durations } = await host.readDurations()
97 await host.writeDurations(durations)
98 await showStatus(host)
99 return true
100}
101
102export async function runCommand(host: Host, args: string): Promise<string> {
103 try {
104 const [sub, ...words] = splitArgs(args.trim())
105 switch (sub) {
106 case 'add': return await add(host, words)
107 case 'list': {
108 const all = Object.values(await loadWorkers(host))
109 const workers = all.length ? all.map(w => describe(w, host.settingTokens.map)).join('\n\n') : 'No workers registered. Add one with /a2a add <url>.'
110 const warning = host.settingTokens.invalid
111 ? '\n\nWarning: the tokens setting is not valid (it should be alias=token pairs separated by spaces), so no worker gets a token from it.'
112 : ''
113 const now = Date.now()
114 const tasks = (await runningTasks(host)).map(t => ` ${t.worker} ${t.taskId} ${t.state}, ${Math.round((now - t.startedAt) / 1000)}s`)
115 return `${workers}${warning}${tasks.length ? `\n\nRunning:\n${tasks.join('\n')}` : ''}`
116 }
117 case 'remove': {
118 if (!words[0] || !(await forgetWorker(host, words[0]))) return `No worker named ${words[0] ?? '(none given)'}.`
119 return `Removed ${words[0]}.`
120 }
121 default: return USAGE
122 }
123 } catch (err) {
124 if (err instanceof A2AError || err instanceof Error) return `a2a: ${err.message}`
125 throw err
126 }
127}
128hooks/format.ts 61 lines1import type { Outcome, RecentState } from '../types/index.d.ts'
2
3const MAX = 8000
4// The task tool is where the note on a cut result sends Claude, so it gets a far larger limit.
5export const FULL_MAX = 100_000
6
7// A Text or Markdown holding ESC, BEL or another control character is refused, and the whole tree
8// with it. Worker output often carries ANSI colour codes.
9export function printable(text: string): string {
10 return text
11 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
12 .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
13 .replace(/\r\n?/g, '\n')
14 .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '')
15 .replace(/(?![])\p{Cf}/gu, '')
16}
17
18// Bidi overrides can make an endpoint or a name read as another one. Zero-width joiners stay in
19// printable text because emoji sequences and some scripts need them, but a one-line field has no use for them.
20export const plain = (text: string): string => printable(text).replace(/\p{Cf}/gu, '')
21
22export function truncate(text: string, max = MAX): string {
23 if (text.length <= max) return text
24 const rest = max < FULL_MAX ? 'call the task tool for the full result' : 'the rest is not shown'
25 return `${text.slice(0, max)}\n\n[truncated ${text.length - max} characters; ${rest}]`
26}
27
28/** One task that ended, for the wake: `body` is what Claude reads, `worker`/`taskId`/`state` make the line the person reads. */
29export type WakeItem = { worker: string; taskId?: string; state: RecentState; body: string; /** Replaces the state's word in the line. */ word?: string }
30
31const WAKE_LINE_MAX = 100
32const WAKE_WORD: Partial<Record<RecentState, string>> = {
33 'input-required': 'needs input', 'auth-required': 'needs auth', unknown: 'state unknown', removed: 'worker removed',
34}
35const wakeMark = (s: RecentState) => (s === 'completed' ? '✓' : s === 'input-required' || s === 'auth-required' ? '?' : '✕')
36const oneLine = (s: string) => plain(s).replace(/\s+/g, ' ').trim()
37
38/** The only text the person sees when a task wakes Claude: one line, no result, no token. */
39export function wakeLine(items: readonly WakeItem[]): string {
40 const [one] = items
41 const text = items.length === 1 && one
42 ? `a2a: ${oneLine(one.worker)} task${one.taskId ? ` ${oneLine(one.taskId).slice(0, 4)}…` : ''} ${one.word ?? WAKE_WORD[one.state] ?? one.state}`
43 : `a2a: ${items.length} tasks finished (${items.map(i => `${oneLine(i.worker)} ${wakeMark(i.state)}`).join(', ')})`
44 return [...text].length <= WAKE_LINE_MAX ? text : `${[...text].slice(0, WAKE_LINE_MAX - 1).join('')}…`
45}
46
47/** What Claude reads beside the wake line: the results, and how to answer a worker that asked a question. */
48export function wakeDetail(items: readonly WakeItem[]): string {
49 const notes = items.map(i => (i.state === 'input-required' && i.taskId
50 ? `${i.body}\n\nTo answer, call the send tool with worker "${i.worker}", taskId "${i.taskId}" and your answer as message.`
51 : i.body))
52 return `[a2a-mod] Task-completion notice from the a2a-mod mod, not the user's words.\n\nA2A ${items.length === 1 ? 'task finished' : 'tasks finished'}:\n\n${notes.join('\n\n---\n\n')}`
53}
54
55export function describeOutcome(alias: string, o: Outcome, max = MAX): string {
56 const ctx = o.contextId ? ` (contextId ${o.contextId})` : ''
57 if (o.kind === 'message') return truncate(`${alias} replied${ctx}:\n\n${o.text || '(empty reply)'}`, max)
58 const head = `${alias} task ${o.taskId} is ${o.state}${ctx}.`
59 return truncate(o.text ? `${head}\n\n${o.text}` : head, max)
60}
61hooks/manage.ts 102 lines1import { A2AError } from './client.ts'
2import { fetchWorker, forgetWorker, tokenNotes } from './command.ts'
3import type { Worker } from '../types/index.d.ts'
4import { plain, printable } from './format.ts'
5import { isOpen, readRecent } from './recent.ts'
6import { BAD_ALIAS, loadWorkers, own, saveWorker, workerOr, type Host } from './registry.ts'
7
8/** What the person answered: `none` when the dialog rejected (dismissed, or a -p run). */
9export type Confirm = (question: string, yes: string) => Promise<'yes' | 'no' | 'none'>
10
11const SKILLS_SHOWN = 3
12const ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/
13
14// One line per field: the card is the worker's own text, and a newline would let it start a fake field.
15const line = (s: unknown, max: number) => plain(String(s)).replace(/\s+/g, ' ').trim().slice(0, max)
16
17const ORIGIN_MAX = 100
18const originOf = (url: string) => new URL(url).origin
19
20/** What the dialog promises about the token: only what targetOf would really send. */
21function authLine(host: Host, w: Worker): string {
22 if (w.auth.kind === 'cmd') return `a token from the stored command is sent to this endpoint (${line(w.auth.cmd, 60)})`
23 if (w.auth.kind === 'file') return `a token from the stored file is sent to this endpoint (${line(w.auth.path, 60)})`
24 if (own(host.settingTokens.map, w.alias) !== undefined) return 'the token from the tokens setting is sent to this endpoint'
25 return w.needsAuth ? 'the card asks for a token; none is set in the tokens setting, so none is sent' : 'the card asks for none, and none is sent'
26}
27
28const DECLINED = 'The user declined.'
29const noOne = (cmd: string) => `No one to approve this; ask the user to run ${cmd} themselves.`
30
31const answer = (a: 'yes' | 'no' | 'none', cmd: string) => (a === 'none' ? noOne(cmd) : a === 'no' ? DECLINED : undefined)
32
33/**
34 * Only url and alias are read. A token, a token command or file, and --trust-endpoint stay in
35 * `/a2a add`: the worker's replies reach Claude, so they can carry an injected instruction, and a
36 * tool that accepted those would let one reach the user's credentials.
37 */
38export async function addWorkerTool(host: Host, confirm: Confirm, input: { url?: unknown; alias?: unknown }): Promise<string> {
39 const url = String(input.url ?? '')
40 const alias = input.alias === undefined || input.alias === '' ? undefined : String(input.alias)
41 try {
42 if (!/^https?:\/\//i.test(url)) return 'a2a: the URL must start with http:// or https://.'
43 if (alias === BAD_ALIAS) return `a2a: ${BAD_ALIAS} cannot be used as an alias.`
44 if (alias !== undefined && !ALIAS.test(alias)) return 'a2a: the alias may use letters, digits, ".", "_" and "-", up to 40 characters.'
45 let fetched: Worker
46 try {
47 fetched = await fetchWorker(host, url, alias, { kind: 'setting' })
48 } catch {
49 // The status or the error text would tell Claude what is listening at an address it picked.
50 return 'Could not read an Agent Card at that URL.'
51 }
52 // The clash is decided on the final alias, which the card name may have supplied.
53 const kept = own(await loadWorkers(host), fetched.alias)
54 const move = `the user must run /a2a add ${line(url, 200)} ${line(fetched.alias, 40)} themselves`
55 if (kept) {
56 // The kept token source and trusted origin would follow the endpoint to a host the user never saw.
57 // Origins alone are not enough: on a shared host another tenant's path has the same origin.
58 if (kept.cardUrl !== fetched.cardUrl || originOf(kept.endpoint) !== originOf(fetched.endpoint)) {
59 return `a2a: ${line(fetched.alias, 40)} is already registered at another address. Moving a worker to another host is not allowed here; ${move}.`
60 }
61 } else if (own(host.settingTokens.map, fetched.alias) !== undefined) {
62 // targetOf would send that token to whatever endpoint this card names.
63 return `a2a: the tokens setting already holds a token for ${line(fetched.alias, 40)}, so a new worker cannot take that alias here; ${move}.`
64 }
65 const w = kept ? { ...fetched, auth: kept.auth, ...(kept.trustedOrigin ? { trustedOrigin: kept.trustedOrigin } : {}) } : fetched
66 const endpointOrigin = originOf(w.endpoint)
67 if (endpointOrigin.length > ORIGIN_MAX) return `a2a: the worker's endpoint address is longer than ${ORIGIN_MAX} characters, so the dialog cannot show it whole.`
68 const skills = w.skills.slice(0, SKILLS_SHOWN).map(s => line(s.name || s.id, 40)).join(', ') || 'none listed'
69 const more = w.skills.length > SKILLS_SHOWN ? ` (+${w.skills.length - SKILLS_SHOWN} more)` : ''
70 const question = [
71 'Let Claude add this A2A worker?',
72 `Name: ${line(w.name, 80)}`,
73 `Alias: ${line(w.alias, 40)}${kept ? ' (already registered: this refreshes its card; its token source and trusted endpoint stay as they are)' : ''}`,
74 `Endpoint: ${plain(endpointOrigin)}`,
75 `A2A version: ${line(w.version, 10)}`,
76 `Skills: ${skills}${more}`,
77 `Authentication: ${authLine(host, w)}`,
78 ].join('\n')
79 const said = answer(await confirm(question, 'Add'), `/a2a add ${line(url, 200)}`)
80 if (said) return said
81 await saveWorker(host, w)
82 const notes = tokenNotes(host, w)
83 return [
84 `Added worker ${line(w.alias, 40)}: ${line(w.name, 80)} (A2A ${line(w.version, 10)}). Call the workers tool to see its skills.`,
85 ...notes.map(n => `Tell the user: ${printable(n)}`),
86 ].join('\n\n')
87 } catch (err) {
88 if (err instanceof A2AError || err instanceof Error) return `a2a: ${line(err.message, 300)}`
89 throw err
90 }
91}
92
93export async function removeWorkerTool(host: Host, confirm: Confirm, input: { alias?: unknown }): Promise<string> {
94 const w = await workerOr(host, input.alias)
95 if (typeof w === 'string') return w
96 const open = (await readRecent(host)).filter(t => t.worker === w.alias && isOpen(t)).length
97 const said = answer(await confirm(`Remove ${line(w.alias, 40)}? ${open} running task(s) stop being tracked.`, 'Remove'), '/a2a remove ' + line(w.alias, 40))
98 if (said) return said
99 await forgetWorker(host, w.alias)
100 return `Removed ${line(w.alias, 40)}.`
101}
102hooks/recent.ts 115 lines1import type { StateRead } from 'claude-code'
2import type { CallInfo, RecentState, RecentTask } from '../types/index.d.ts'
3import { printable } from './format.ts'
4import { own, type Host } from './registry.ts'
5
6export const RECENT_MAX = 50
7// Markdown draws at most 10,000 characters.
8export const RESULT_MAX = 10_000
9// 50 rows of unbounded messages would push the pane past the engine's 100,000-character tree limit.
10export const TEXT_MAX = 500
11export const SHOW_MS = 10 * 60_000
12export const DURATIONS_MAX = 10
13const CALLS_MAX = 200
14
15export const isRunning = (s: RecentState) => s === 'submitted' || s === 'working'
16export const isWaiting = (s: RecentState) => s === 'input-required'
17export const isOpen = (t: RecentTask) => isRunning(t.state) || isWaiting(t.state)
18
19export function cut(text: string): string {
20 const clean = printable(text)
21 return clean.length > RESULT_MAX ? `${clean.slice(0, RESULT_MAX - 1)}…` : clean
22}
23
24export function progressOf(text: string): number | undefined {
25 const m = /(\d{1,3})\s?%/.exec(text)
26 const n = m ? Number(m[1]) : NaN
27 return n >= 0 && n <= 100 ? n : undefined
28}
29
30// Past RECENT_MAX the oldest finished rows go first; a running or waiting row is dropped only
31// when every row is one.
32function trim(list: RecentTask[]): RecentTask[] {
33 if (list.length <= RECENT_MAX) return list
34 const done = list.filter(t => !isOpen(t))
35 const keepDone = new Set(done.slice(0, Math.max(0, RECENT_MAX - (list.length - done.length))))
36 return list.filter(t => isOpen(t) || keepDone.has(t)).slice(0, RECENT_MAX)
37}
38
39/** Newest first. A changed row keeps its place; a new one goes on top; `undefined` drops it. */
40export function put(list: readonly RecentTask[], taskId: string, change: (held: RecentTask | undefined) => RecentTask | undefined): RecentTask[] {
41 const held = list.find(t => t.taskId === taskId)
42 const next = change(held)
43 if (!held) return trim(next ? [next, ...list] : [...list])
44 return trim(list.flatMap(t => (t === held ? (next ? [next] : []) : [t])))
45}
46
47/** What the pane and band show: running and waiting rows, and rows that ended in the last 10 minutes. */
48export function visible(list: readonly RecentTask[], now: number): RecentTask[] {
49 return list.filter(t => isOpen(t) || now - (t.endedAt ?? t.startedAt) < SHOW_MS)
50}
51
52async function rewrite<T>(read: () => Promise<StateRead<T>>, write: (value: T, ifVersion: number) => Promise<boolean>, change: (held: T | undefined) => T): Promise<T> {
53 for (;;) {
54 const held = await read()
55 const next = change(held.value)
56 if (await write(next, held.version)) return next
57 }
58}
59
60export const readRecent = async (host: Host) => (await host.readRecent()).value ?? []
61
62export const changeRecent = (host: Host, taskId: string, change: (held: RecentTask | undefined) => RecentTask | undefined) =>
63 rewrite(host.readRecent, host.writeRecent, list => put(list ?? [], taskId, change))
64
65export async function noteCall(host: Host, id: string, info: CallInfo): Promise<void> {
66 await rewrite(host.readCalls, host.writeCalls, calls => {
67 const next = { ...calls, [id]: info }
68 const ids = Object.keys(next)
69 for (const old of ids.slice(0, Math.max(0, ids.length - CALLS_MAX))) delete next[old]
70 return next
71 })
72}
73
74/** A send went out: a new row, or a waiting row answered (the first answer is the one kept). */
75export async function noteSent(host: Host, t: Pick<RecentTask, 'worker' | 'taskId' | 'contextId' | 'text' | 'state'>, by?: 'user' | 'claude'): Promise<void> {
76 const now = await host.now()
77 await changeRecent(host, t.taskId, held => {
78 if (!held) return { ...t, text: printable(t.text).slice(0, TEXT_MAX), startedAt: now, changedAt: now }
79 const answeredBy = held.answeredBy ?? (isWaiting(held.state) ? by : undefined)
80 return { ...held, state: t.state, changedAt: held.state === t.state ? held.changedAt : now, ...(answeredBy ? { answeredBy } : {}) }
81 })
82}
83
84/** A task's latest state: progress while running, the question while waiting, the result once it ended. */
85export async function noteState(host: Host, worker: string, taskId: string, state: RecentState, text: string): Promise<void> {
86 const now = await host.now()
87 let ran: number | undefined
88 await changeRecent(host, taskId, held => {
89 const { canceling, progress, message, ...was } = held ?? { worker, taskId, text: '', state, startedAt: now, changedAt: now }
90 const base = { ...was, changedAt: was.state === state ? was.changedAt : now }
91 if (isRunning(state)) {
92 const pct = progressOf(text)
93 // 50 rows of full messages would push the pane past the tree limit, as with `text`.
94 const said = printable(text).trim().slice(0, TEXT_MAX)
95 return { ...base, state, ...(canceling ? { canceling } : {}), ...(pct === undefined ? {} : { progress: pct }), ...(said ? { message: said } : {}) }
96 }
97 if (isWaiting(state)) return { ...base, state, result: cut(text) }
98 ran = now - base.startedAt
99 return { ...base, state, endedAt: now, result: cut(text) }
100 })
101 if (state === 'completed' && ran !== undefined) {
102 const all = await host.readDurations()
103 await host.writeDurations({ ...all, [worker]: [...(own(all, worker) ?? []), ran].slice(-DURATIONS_MAX) })
104 }
105}
106
107/** `/a2a remove`: a waiting task is not polled, so the tracker would never mark it. */
108export async function noteRemoved(host: Host, alias: string): Promise<void> {
109 const now = await host.now()
110 await rewrite(host.readRecent, host.writeRecent, list =>
111 (list ?? []).map((t): RecentTask => (t.worker === alias && isOpen(t) ? { ...t, state: 'removed', changedAt: now, endedAt: now, result: 'worker removed' } : t)))
112}
113
114export const waitingCount = (list: readonly RecentTask[]) => list.filter(t => isWaiting(t.state)).length
115hooks/registry.ts 148 lines1import type { ProcessRunResult, StateRead, Timer } from 'claude-code'
2import type { CallInfo, RecentTask, Target, TrackedTask, Worker } from '../types/index.d.ts'
3import { A2AError, type Fetcher } from './client.ts'
4
5/** The `tokens` plugin setting, parsed once per load. `invalid` when a word was not `alias=token`. */
6export type SettingTokens = { map: Readonly<Record<string, string>>; invalid: boolean }
7
8// The engine as the other modules see it. The loader refuses $ passed across an import,
9// so register.ts builds this from $ in hostOf.
10export type Host = {
11 fetch: Fetcher
12 readWorkers(): Promise<unknown>
13 writeWorkers(all: Record<string, Worker>): Promise<void>
14 settingTokens: SettingTokens
15 /** Runs argv directly, no shell. */
16 run(argv: readonly string[]): Promise<ProcessRunResult>
17 readFile(path: string): Promise<string>
18 readTasks(): Promise<StateRead<TrackedTask[]>>
19 /** False when another write landed since `ifVersion`. */
20 writeTasks(tasks: TrackedTask[], ifVersion: number): Promise<boolean>
21 readRecent(): Promise<StateRead<RecentTask[]>>
22 writeRecent(recent: RecentTask[], ifVersion: number): Promise<boolean>
23 readCalls(): Promise<StateRead<Record<string, CallInfo>>>
24 writeCalls(calls: Record<string, CallInfo>, ifVersion: number): Promise<boolean>
25 /** Run times of completed tasks per worker, in ms, oldest first. */
26 readDurations(): Promise<Record<string, number[]>>
27 writeDurations(all: Record<string, number[]>): Promise<void>
28 status(text: string | undefined): void
29 /** The `animations` setting: a still glyph on the status line when off. */
30 anim: boolean
31 now(): Promise<number>
32 every(ms: number, fn: () => void): Timer
33 sleep(ms: number): Promise<void>
34 /** Wakes Claude: `text` is the one line the person sees, `detail` is read by Claude alone. */
35 wake(text: string, detail: string): Promise<void>
36}
37
38// `alias=token` pairs separated by whitespace. Not JSON: a JSON map inside the setting's own
39// JSON needs escaped quotes, and the transcript drops backslashes from the printed hint.
40// Split at the first `=` so base64 padding in a token survives.
41export function parseTokens(raw: unknown): SettingTokens {
42 const words = String(raw ?? '').split(/\s+/).filter(Boolean)
43 const map: Record<string, string> = {}
44 for (const word of words) {
45 const at = word.indexOf('=')
46 if (at <= 0 || at === word.length - 1) return { map: {}, invalid: true }
47 map[word.slice(0, at)] = word.slice(at + 1)
48 }
49 return { map, invalid: false }
50}
51
52/** Splits on whitespace; a double-quoted run is one word, with `\"` inside it a quote. */
53export function splitArgs(s: string): string[] {
54 const words: string[] = []
55 let cur = ''
56 let quoted = false
57 let started = false
58 for (let i = 0; i < s.length; i++) {
59 const c = s[i]!
60 if (quoted && c === '\\' && s[i + 1] === '"') { cur += '"'; i++ }
61 else if (c === '"') { quoted = !quoted; started = true }
62 else if (!quoted && /\s/.test(c)) { if (started) words.push(cur); cur = ''; started = false }
63 else { cur += c; started = true }
64 }
65 if (quoted) throw new A2AError('a double quote is not closed')
66 if (started) words.push(cur)
67 return words
68}
69
70export async function loadWorkers(host: Host): Promise<Record<string, Worker>> {
71 return ((await host.readWorkers()) as Record<string, Worker> | undefined) ?? {}
72}
73
74/** An own `__proto__` key written through $.store did not come back from it, so no worker can have this alias. */
75export const BAD_ALIAS = '__proto__'
76
77/** An alias is free text, so `constructor` and `__proto__` must not find Object.prototype members. */
78export const own = <V>(map: Readonly<Record<string, V>>, key: unknown): V | undefined =>
79 typeof key === 'string' && Object.hasOwn(map, key) ? map[key] : undefined
80
81/** The worker, or the sentence for Claude when there is none by that alias. */
82export async function workerOr(host: Host, alias: unknown): Promise<Worker | string> {
83 const all = await loadWorkers(host)
84 const w = own(all, alias)
85 if (w) return w
86 const names = Object.keys(all)
87 return names.length ? `No worker named ${String(alias)}. Known workers: ${names.join(', ')}.` : 'No workers registered. Ask the user to run /a2a add <url>.'
88}
89
90export async function saveWorker(host: Host, w: Worker): Promise<void> {
91 await host.writeWorkers({ ...(await loadWorkers(host)), [w.alias]: w })
92 forgetToken(w)
93}
94
95export async function removeWorker(host: Host, alias: string): Promise<boolean> {
96 const all = await loadWorkers(host)
97 const w = own(all, alias)
98 if (!w) return false
99 delete all[alias]
100 await host.writeWorkers(all)
101 forgetToken(w)
102 return true
103}
104
105export const TOKEN_TTL_MS = 5 * 60_000
106const cache = new Map<string, { token: string; at: number }>()
107
108export function forgetToken(w: Worker): void {
109 cache.delete(w.alias)
110}
111
112/** A 401 or 403 means the cached token is stale: the next call fetches it again. */
113export function noteFailure(w: Worker, err: unknown): void {
114 if (err instanceof A2AError && (err.status === 401 || err.status === 403)) forgetToken(w)
115}
116
117async function fetchToken(host: Host, w: Worker): Promise<string | undefined> {
118 const auth = w.auth
119 if (auth.kind === 'setting') return own(host.settingTokens.map, w.alias)
120 if (auth.kind === 'file') {
121 let text: string
122 try { text = await host.readFile(auth.path) } catch { throw new A2AError(`could not read the token file ${auth.path} for worker ${w.alias}`) }
123 if (!text.trim()) throw new A2AError(`the token file ${auth.path} for worker ${w.alias} is empty`)
124 return text.trim()
125 }
126 // The command's output is never put in a message: on failure it may be the token, or part of it.
127 let out: ProcessRunResult
128 try { out = await host.run(splitArgs(auth.cmd)) } catch { throw new A2AError(`the token command for worker ${w.alias} could not run (${auth.cmd})`) }
129 if (out.exitCode !== 0) throw new A2AError(`the token command for worker ${w.alias} failed with exit code ${out.exitCode} (${auth.cmd})`)
130 if (!out.stdout.trim()) throw new A2AError(`the token command for worker ${w.alias} printed nothing (${auth.cmd})`)
131 return out.stdout.trim()
132}
133
134export async function targetOf(host: Host, w: Worker): Promise<Target> {
135 let token: string | undefined
136 if (w.auth.kind === 'setting') {
137 token = await fetchToken(host, w)
138 } else {
139 const held = cache.get(w.alias)
140 if (held && Date.now() - held.at < TOKEN_TTL_MS) token = held.token
141 else {
142 token = await fetchToken(host, w)
143 if (token) cache.set(w.alias, { token, at: Date.now() })
144 }
145 }
146 return token ? { ...w, token } : w
147}
148hooks/tracker.ts 122 lines1import type { Timer } from 'claude-code'
2import type { RecentState, TaskState, TrackedTask } from '../types/index.d.ts'
3import { getTask } from './client.ts'
4import { describeOutcome, wakeDetail, wakeLine, type WakeItem } from './format.ts'
5import { isOpen, isRunning, noteState, readRecent } from './recent.ts'
6import { loadWorkers, noteFailure, own, targetOf, type Host } from './registry.ts'
7import { STATUS_MS, statusText } from './ui/status.ts'
8import { isLive } from './wire.ts'
9
10export const POLL_MS = 5000
11export const POLL_TIMEOUT_MS = 15000
12export const MAX_FAILURES = 6
13
14let ticker: Timer | undefined
15let statusTicker: Timer | undefined
16let busy = false
17
18export async function runningTasks(host: Host): Promise<TrackedTask[]> {
19 return (await host.readTasks()).value ?? []
20}
21
22// The line has a clock and a spinner in it, so it is redrawn each second while a task runs or a
23// "done" line is waiting out its five seconds. A waiting question is static and needs no ticker.
24export async function showStatus(host: Host): Promise<void> {
25 const rows = await readRecent(host)
26 const text = statusText(rows, await host.now(), host.anim)
27 host.status(text)
28 const needsTick = rows.some(t => isRunning(t.state)) || (text !== undefined && !rows.some(isOpen))
29 if (!needsTick) { statusTicker?.cancel(); statusTicker = undefined }
30 else if (!statusTicker) statusTicker = host.every(STATUS_MS, () => showStatus(host))
31}
32
33// Read-modify-write with ifVersion: a send can track a task while a tick is writing.
34async function update(host: Host, change: (tasks: TrackedTask[]) => TrackedTask[]): Promise<TrackedTask[]> {
35 for (;;) {
36 const held = await host.readTasks()
37 const next = change(held.value ?? [])
38 if (await host.writeTasks(next, held.version)) {
39 await showStatus(host)
40 if (!next.length) { ticker?.cancel(); ticker = undefined }
41 else if (!ticker) ticker = host.every(POLL_MS, () => void tick(host))
42 return next
43 }
44 }
45}
46
47export async function track(host: Host, t: Omit<TrackedTask, 'failures'>): Promise<void> {
48 await update(host, tasks => [...tasks.filter(x => x.taskId !== t.taskId), { ...t, failures: 0 }])
49}
50
51// A reload drops the module state, and with it both tickers, while tasks are still live. A reload
52// between `send` writing its recent row and tracking the task (or an error there that is not an
53// A2AError) leaves a running row nothing polls, which would keep the status line spinning.
54export async function resume(host: Host): Promise<void> {
55 const tracked = new Set((await runningTasks(host)).map(t => t.taskId))
56 const orphans = (await readRecent(host)).filter(t => isRunning(t.state) && !tracked.has(t.taskId))
57 if (orphans.length) {
58 const workers = await loadWorkers(host)
59 for (const t of orphans) {
60 if (!own(workers, t.worker)) { await noteState(host, t.worker, t.taskId, 'removed', 'worker removed'); continue }
61 await track(host, { worker: t.worker, taskId: t.taskId, contextId: t.contextId, state: t.state as TaskState, startedAt: t.startedAt })
62 tracked.add(t.taskId)
63 }
64 }
65 if (tracked.size) await update(host, tasks => tasks)
66 else await showStatus(host)
67}
68
69// $.http.fetch has no timeout of its own.
70export function timeoutOr<T>(host: Host, work: Promise<T>, ms = POLL_TIMEOUT_MS): Promise<T> {
71 return Promise.race([work, host.sleep(ms).then((): never => { throw new Error('timed out') })])
72}
73
74type Seen = { state: RecentState; text: string }
75type Result = { task: TrackedTask; live?: TrackedTask; note?: WakeItem; failed?: true; seen?: Seen }
76
77async function pollOne(host: Host, t: TrackedTask, workers: Awaited<ReturnType<typeof loadWorkers>>): Promise<Result> {
78 const w = own(workers, t.worker)
79 if (!w) return { task: t, note: { worker: t.worker, taskId: t.taskId, state: 'removed', body: `${t.worker} task ${t.taskId}: the worker was removed, so it is no longer tracked.` }, seen: { state: 'removed', text: 'worker removed' } }
80 try {
81 const out = await timeoutOr(host, getTask(host.fetch, await targetOf(host, w), t.taskId))
82 const seen = { state: out.state, text: out.text }
83 return isLive(out.state) ? { task: t, live: { ...t, state: out.state, failures: 0 }, seen } : { task: t, note: { worker: w.alias, taskId: t.taskId, state: out.state, body: describeOutcome(w.alias, out) }, seen }
84 } catch (err) {
85 noteFailure(w, err)
86 return { task: t, failed: true }
87 }
88}
89
90export async function tick(host: Host): Promise<void> {
91 if (busy) return
92 busy = true
93 const notes: WakeItem[] = []
94 try {
95 const workers = await loadWorkers(host)
96 const results = await Promise.all((await runningTasks(host)).map(t => pollOne(host, t, workers)))
97 const byId = new Map(results.map(r => [r.task.taskId, r]))
98 notes.push(...results.flatMap(r => (r.note ? [r.note] : [])))
99 // update may run its callback again after a version miss, so lost-contact notes are keyed, not pushed.
100 const lost = new Map<string, { task: TrackedTask; note: WakeItem }>()
101 await update(host, tasks => {
102 lost.clear()
103 return tasks.flatMap(t => {
104 const r = byId.get(t.taskId)
105 if (!r) return [t]
106 if (r.live) return [r.live]
107 if (!r.failed) return []
108 if (t.failures + 1 < MAX_FAILURES) return [{ ...t, failures: t.failures + 1 }]
109 lost.set(t.taskId, { task: t, note: { worker: t.worker, taskId: t.taskId, state: 'unknown', word: 'lost contact', body: `${t.worker} task ${t.taskId}: lost contact with the worker after ${MAX_FAILURES} failed checks. Use the task tool to try again.` } })
110 return []
111 })
112 })
113 notes.push(...[...lost.values()].map(l => l.note))
114 for (const r of results) if (r.seen) await noteState(host, r.task.worker, r.task.taskId, r.seen.state, r.seen.text)
115 for (const l of lost.values()) await noteState(host, l.task.worker, l.task.taskId, 'unknown', l.note.body)
116 await showStatus(host)
117 } finally {
118 busy = false
119 }
120 if (notes.length) await host.wake(wakeLine(notes), wakeDetail(notes))
121}
122hooks/ui/color.ts 18 lines1const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
2
3/** Two-stop RGB mix of `#rrggbb` colours; `t` is clamped to 0..1. */
4export function mix(a: string, b: string, t: number): string {
5 const A = rgb(a), B = rgb(b), k = Math.min(1, Math.max(0, t))
6 return `#${A.map((v, i) => Math.round(v + (B[i]! - v) * k).toString(16).padStart(2, '0')).join('')}`
7}
8
9/** The raw colours the gradient and shimmer use, for the theme the person picked. */
10export type Palette = { text: string; dim: string; from: string; to: string }
11
12export function palette(theme: unknown): Palette {
13 const light = typeof theme === 'string' && theme.includes('light')
14 const text = light ? '#1f2328' : '#e6edf3'
15 // Pulled a quarter toward the text colour, so the gradient reads on light and dark backgrounds.
16 return { text, dim: mix(text, light ? '#ffffff' : '#000000', 0.55), from: mix('#4fb3a9', text, 0.25), to: mix('#b392f0', text, 0.25) }
17}
18hooks/ui/band.tsx 64 lines1import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
2import type { RecentTask } from '../../types/index.d.ts'
3import { isOpen, isRunning, isWaiting, SHOW_MS } from '../recent.ts'
4import type { Palette } from './color.ts'
5import { lines, wireCells, type WireProps } from './draw.tsx'
6import { fit, type Direction } from './fx.ts'
7import { hasClient } from './pane.tsx'
8import { STATES } from './states.ts'
9
10export type BandEls = Pick<ElementTable<'mobile'>, 'Box' | 'Text'> & Partial<Pick<ElementTable<'terminal'>, 'Client'>>
11
12export type BandData = { surface: RenderSurface; width: number; now: number; anim: boolean; pal: Palette; rows: RecentTask[] }
13
14const NAMES_MAX = 4
15// How long the packet runs back after the last running task ends.
16export const BACK_MS = 2000
17
18/** Running and waiting rows, and rows that ended within the return packet's two seconds. */
19export function bandRows(list: readonly RecentTask[], now: number): RecentTask[] {
20 return list.filter(t => isOpen(t) || (t.endedAt !== undefined && now - t.endedAt < BACK_MS))
21}
22
23/** Out after the newest send, back after the newest result, idle once nothing runs and the return has played. */
24export function direction(rows: readonly RecentTask[], now: number): Direction {
25 const ended = Math.max(0, ...rows.map(t => t.endedAt ?? 0))
26 if (!rows.some(t => isRunning(t.state))) return ended && now - ended < BACK_MS ? 'back' : 'idle'
27 const sent = Math.max(...rows.map(t => t.startedAt))
28 return ended > sent ? 'back' : 'out'
29}
30
31/** Ms until the pane or band next changes with no state change to trigger it: a row ages out, or the return packet rests. */
32export function nextRedraw(rows: readonly RecentTask[], now: number, withReturn: boolean): number | undefined {
33 const due = rows.filter(t => !isOpen(t)).map(t => (t.endedAt ?? t.startedAt) + SHOW_MS)
34 const ended = Math.max(0, ...rows.map(t => t.endedAt ?? 0))
35 if (withReturn && ended && !rows.some(t => isRunning(t.state)) && now - ended < BACK_MS) due.push(ended + BACK_MS)
36 return due.length ? Math.max(0, Math.min(...due) - now) : undefined
37}
38
39/** Workers with running or waiting tasks first, then those that finished lately. */
40export function wireProps(d: BandData): WireProps {
41 const names = [...new Set([...d.rows.filter(isOpen), ...d.rows].map(t => t.worker))]
42 return { names: names.slice(0, NAMES_MAX), more: Math.max(0, names.length - NAMES_MAX), dir: direction(d.rows, d.now), anim: d.anim, pal: d.pal }
43}
44
45function chip(el: BandEls, t: RecentTask): RenderElement {
46 const { Box, Text } = el
47 const look = STATES[t.state]
48 const what = isWaiting(t.state) ? 'waiting on Claude' : fit(t.text, 24)
49 return <Box><Text color={look.color}>{isRunning(t.state) ? '●' : look.glyph}</Text><Text>{` ${t.worker} · ${what}`}</Text></Box>
50}
51
52/** The wire, one chip per running or waiting task, then whatever the hooks beneath drew. */
53export function bandTree(el: BandEls, d: BandData, theirs: RenderElement | undefined): RenderElement {
54 const { Box, Client } = el
55 const wire = wireProps(d)
56 return (
57 <Box flexDirection="column">
58 {Client && hasClient(d.surface) ? <Client key="wire" module="./wire.tsx" props={wire} /> : lines(el, [wireCells(wire, 0, 0)])}
59 <Box flexWrap="wrap" columnGap={3}>{d.rows.filter(isOpen).map(t => chip(el, t))}</Box>
60 {theirs ?? null}
61 </Box>
62 )
63}
64hooks/ui/cards.tsx 130 lines1import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
2import type { RecentState, Worker } from '../../types/index.d.ts'
3import { printable } from '../format.ts'
4import { isRunning, isWaiting } from '../recent.ts'
5import type { Palette } from './color.ts'
6import { cellLen, lines, rowLines, splitLine, stillTick, wrap, type RowProps } from './draw.tsx'
7import { fit, gradient, type Cell } from './fx.ts'
8import { hasClient } from './pane.tsx'
9import { STATES } from './states.ts'
10
11export type CardEls = Pick<ElementTable<'mobile'>, 'Box' | 'Text'> & Partial<Pick<ElementTable<'terminal'>, 'Client'>>
12
13/** A tool result as text, whatever shape the engine stored it in. */
14export function textOf(output: unknown): string {
15 return printable(rawText(output))
16}
17
18function rawText(output: unknown): string {
19 if (typeof output === 'string') return output
20 if (Array.isArray(output)) return output.map(rawText).filter(Boolean).join('\n')
21 if (output && typeof output === 'object') {
22 const o = output as Record<string, unknown>
23 if (typeof o.text === 'string') return o.text
24 if (typeof o.result === 'string') return o.result
25 if ('content' in o) return rawText(o.content)
26 }
27 return output === undefined || output === null ? '' : JSON.stringify(output)
28}
29
30/** The state as a glyph and a word, in the state table's theme colour. A running task is "tracked": it left for the background. */
31export function badge(state: RecentState | undefined, isErrored: boolean): { label: string; color: string } {
32 if (!state) return isErrored ? { label: '✕ failed', color: 'error' } : { label: '✓ done', color: 'inactive' }
33 if (isRunning(state)) return { label: '● tracked', color: 'warning' }
34 const look = STATES[state]
35 return { label: `${look.glyph} ${state === 'rejected' ? 'failed' : look.label}`, color: look.color }
36}
37
38// `width` is the card's outer width; the border and a column of padding on each side take four.
39const INSET = 4
40const MESSAGE_LINES = 4
41
42const frame = (width: number) => ({ marginLeft: 2, width, flexDirection: 'column', borderStyle: 'round', borderColor: 'inactive', paddingX: 1 }) as const
43
44export type UseCard = {
45 surface: RenderSurface
46 width: number
47 anim: boolean
48 pal: Palette
49 now: number
50 alias: string
51 worker: Worker | undefined
52 message: string
53 isRunning: boolean
54 startedAt: number
55}
56
57/** `⇢ alias` with the version and organization at the right, the message under a quote bar, and while it runs an animated row. */
58export function useCard(el: CardEls, d: UseCard): RenderElement {
59 const { Box, Text, Client } = el
60 const inner = Math.max(1, d.width - INSET)
61 const left: Cell[] = [{ text: '⇢ ', color: 'inactive' }, ...gradient(fit(d.alias, Math.min(24, Math.max(1, inner - 8))), 0, d.pal)]
62 const meta = [d.worker ? `A2A ${d.worker.version}` : '', d.worker?.organization ?? ''].filter(Boolean).join(' · ')
63 const right: Cell[] = [{ text: fit(meta, Math.max(0, inner - cellLen(left) - 1)), color: 'inactive' }]
64 const props: RowProps = { text: 'working…', state: 'working', startedAt: d.startedAt, now: d.now, progress: null, tail: null, width: inner, anim: d.anim, pal: d.pal }
65 return (
66 <Box {...frame(d.width)}>
67 {splitLine(el, left, right, inner)}
68 {wrap(d.message, inner - 2, MESSAGE_LINES).map(line => <Box><Text color="inactive">│ </Text><Text color="inactive">{line}</Text></Box>)}
69 {d.isRunning
70 ? Client && hasClient(d.surface)
71 ? <Client key="run" module="./row.tsx" props={props} width={inner} />
72 : lines(el, rowLines(props, stillTick(props)))
73 : null}
74 </Box>
75 )
76}
77
78export type ResultCard = {
79 surface: RenderSurface
80 width: number
81 anim: boolean
82 state: RecentState | undefined
83 isErrored: boolean
84 text: string
85 /** Typewriter key: the task id, or the call id for a reply that came back as a message. */
86 playId: string
87 played: boolean
88 /** The tracked task, named in the line shown to the person in place of the instruction to Claude. */
89 taskId?: string
90}
91
92function resultBody(el: CardEls, d: ResultCard, clientKey: string): RenderElement {
93 const { Text, Client } = el
94 const types = d.state === 'completed' && d.anim && !d.played && hasClient(d.surface)
95 return types && Client
96 ? <Client key={clientKey} module="./typewriter.tsx" props={{ id: d.playId, text: d.text, anim: true }} />
97 : <Text>{d.text}</Text>
98}
99
100/** A result as a person reads it: without the summary line ("fake task t is failed (contextId c).") Claude reads above it. */
101export function bodyOf(text: string): string {
102 const head = /^\S+ (?:task \S+ is [a-z-]+|replied)(?: \(contextId [^)]*\))?[.:](?:\n\n|$)/.exec(text)
103 return head ? text.slice(head[0].length) : text
104}
105
106/** A state badge, then the result indented; a completed result types itself in once, and a tracked task reads as a line for a person. */
107export function resultCard(el: CardEls, d: ResultCard): RenderElement {
108 const { Box, Text } = el
109 const b = badge(d.state, d.isErrored)
110 // The tool result Claude reads says "do not poll"; the person sees what it means for them.
111 const tracked = `tracked${d.taskId ? ` · task ${printable(d.taskId).slice(0, 8)}` : ''} · you'll be told when it lands`
112 // Likewise the summary line above the worker's words is for Claude; the person gets the words alone.
113 const body = bodyOf(d.text)
114 const asking = d.state !== undefined && isWaiting(d.state)
115 const pointer = `${d.taskId ? `task ${printable(d.taskId).slice(0, 8)} · ` : ''}reply in the a2a pane`
116 return (
117 <Box {...frame(d.width)}>
118 <Text color={b.color}>{fit(b.label, Math.max(1, d.width - INSET))}</Text>
119 <Box marginLeft={2} flexDirection="column">
120 {d.state && isRunning(d.state)
121 ? wrap(tracked, Math.max(1, d.width - INSET - 2), 4).map(line => <Text color="inactive">{line}</Text>)
122 : [
123 ...(body ? [resultBody(el, { ...d, text: body }, 'type')] : []),
124 ...(asking ? wrap(pointer, Math.max(1, d.width - INSET - 2), 2).map(line => <Text color="inactive">{line}</Text>) : []),
125 ]}
126 </Box>
127 </Box>
128 )
129}
130