SLOPSHOPPER

a2a-mod

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

newpanebandrowsguardcommand
★ 1v0.3.1MITupdated 2026-10-04NovusEdge/a2a-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · a2a-mod
│ ┃ A2A workers ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Claude ┄┄ · ⏺ Read(src/auth.ts) │ ┃ No workers yet ⎿ Read 6 lines │ ┃ /a2a add <url> ⏺ 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-mod: Usage: │ ┃ ⎿ a2a-mod: /a2a add <url> [alias] [--token-setting | --token-cmd │ ┃ ⎿ a2a-mod: (re-run to refresh; with no token flag the token │ ┃ ⎿ a2a-mod: /a2a list │ ┃ ⎿ a2a-mod: /a2a remove <alias> │ ┃ ⎿ a2a-mod: │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · A2A workers
Claude ┄┄ · No workers yet /a2a add <url>
README

a2a-mod

Hand work to other AI agents from Claude Code. Keep going. Get the result back.

CI Release License: MIT A2A 1.0 | 0.3 Docs

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

Why

  • Claude never waits on a slow agent. After a few seconds the task moves to the background and Claude carries on. When the agent finishes, Claude gets the result.
  • Works with any A2A agent. ADK, AG2, LangGraph, @a2a-js/sdk, or anything else that speaks A2A 1.0 or 0.3.
  • Your tokens stay out of the chat. Keys come from a setting, a command or a file. You never paste one into Claude.

Install

In Claude Code 2.1.287 or later:

/plugin marketplace add NovusEdge/a2a-mod
/plugin install a2a-mod@a2a-mod

Try it in a minute

You don't need a real agent. The repo ships a fake worker (needs Node 24 and pnpm).

  1. Start it: ``sh git clone https://github.com/NovusEdge/a2a-mod && cd a2a-mod pnpm i && pnpm worker ``
  2. In Claude Code, add it: `` /a2a add http://127.0.0.1:41241 fake ``
  3. Ask Claude: `` 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.

What you'll see

  • A status line with the live task: a2a-mod: ⠋ fake slow 20 build 0:12. It stays visible next to /diff.
  • A band above your prompt while a task runs, showing the hand-off from Claude to the worker.
  • A slim ⇄ 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.
  • Boxed cards in the transcript for each 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.

Use your own agents

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

Learn more

How it works · Write a worker · Troubleshooting · Security · Protocol support

Contributing and license

Contributions are welcome: see CONTRIBUTING.md. To report a vulnerability, see SECURITY.md. MIT licensed: see LICENSE.

Source 24 files
hooks/register.ts 430 lines
1import { 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}
430
types/index.d.ts 106 lines
1export 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}
106
hooks/client.ts 67 lines
1import 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)
67
hooks/command.ts 128 lines
1import 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}
128
hooks/format.ts 61 lines
1import 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}
61
hooks/manage.ts 102 lines
1import { 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}
102
hooks/recent.ts 115 lines
1import 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
115
hooks/registry.ts 148 lines
1import 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}
148
hooks/tracker.ts 122 lines
1import 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}
122
hooks/ui/color.ts 18 lines
1const 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}
18
hooks/ui/band.tsx 64 lines
1import 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}
64
hooks/ui/cards.tsx 130 lines
1import 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