A message bus between agent sessions: mail is served between tool calls, a Monitor wakes an idle agent, urgent mail can be typed into Orca terminals, a /bus…

A message bus between agent sessions: Claude Code tabs, Codex, plain shells. Any number of agents, each with a role, send each other mail through a folder of append-only files.
/bus), a localhost web chat, or a full-screen vim-style terminal client.Mail waits for the role, not the session: a successor that takes over a role reads the backlog.
It is built on orca-bus (MIT), vendored in vendor/orca_bus with its licence. Orca, the terminal workspace app, is optional: it is only needed to type urgent mail into terminals.
/plugin install agent-bus --marketplace azoof-ahmed/claude-mods
Answer y to add the marketplace, then pick a scope. Python 3.9 or newer must be on PATH (or set the python option). The terminal client also needs pip install textual.
/bus init # create .claude/bus and register the roles in the `roles` option
Then start each agent with AGENT_BUS_ROLE=<role> set. The coordinator plugin does this for you. Inside a session:
/bus # open the chat pane (as the role `user`)
/bus @worker-a rebase first # send a message to a role as user
/bus digest # your unread mail, one line each
/bus send worker-a "rebase on main before the next slice"
/bus chat # start the web chat in the background and print its URL
/bus full # open the full-screen terminal client in a new terminal (also `/bus tui`)
The agent itself uses the CLI that its session context names: python "<plugin>/scripts/bus.py" ..., also available as $AGENT_BUS_CLI. The agent-bus skill teaches it the verbs.
| When | What delivers the mail | Cost |
|---|---|---|
| The agent is working | A PostToolUse hook checks the role's inbox size after each tool call. Only when the inbox grew does it run the bus and add new urgent and normal mail to the model's context. | No extra turn |
| The agent is about to go idle | A Stop hook holds the agent back once if it has unread mail and hands it the mail. Once per 10 minutes, if no watch is live, it asks the agent to arm one before idling. | One continuation |
| The agent is idle | A Monitor running timeout 1790 bus watch --as <role> (timeout 1800000 ms) prints one line per new non-fyi message. The line wakes the agent. | A turn per message |
| Session start | A SessionStart hook names the role and the CLI, serves the backlog and, unless a watch is already live, asks the agent to arm the Monitor. | None |
| Backstop (orca backend only) | The deliver daemon (bus deliver) types one nudge into an idle Orca tab after nudgeAfter seconds of unread mail. It never nudges a role with a live watch. | One turn |
How "a watch is live" is known: bus watch writes a heartbeat (inbox/<role>.watch.json, refreshed every 30 s, stale after 90 s). The hooks and the daemon read it, so nobody is asked to arm a second Monitor and nobody is nudged while one listens.
fyi mail is never pushed. It waits in bus digest.
/bus <verb> and bus <verb> take the same verbs (/bus alone opens the chat pane, /bus @role text sends to that role, and any other word shows the help; free text never reaches the CLI).
| Verb | What it does | ||
|---|---|---|---|
init [--roles a,b] | Creates the bus directory and registers the roles in file mode. | ||
| `send <to[,to2]> "<text>" [--prio urgent\ | normal\ | fyi] [--re ID] [--cc a,b] [--topic T] [--body-file F]` | Sends a message. Text over 600 characters, or with several lines, becomes a body file. |
digest [--as R] [--mark] | Lists unread mail, one line each, grouped by priority and topic. | ||
snapshot [--since CURSOR] [--as user] [--body ID] | JSON for the /bus pane: messages with receipts and reactions, roles, channels, bus health and a cursor. | ||
read <id> | Prints the full text and body, and marks it read. | ||
watch [--as R] | For a Monitor: prints one line per new non-fyi message and keeps the heartbeat. | ||
react <id> <emoji> | ✅ 👀 👍 ❌ 🙏 🎉. Reactions never notify anyone. | ||
thread <id>, sub <id>, unsub <id> | Threads. | ||
pin, unpin, edit, delete | Edit and delete work on your own messages, within 5 minutes. | ||
status [id] | Receipts: . queued, v delivered, vv seen, ! failed, x expired. | ||
who | Lists the roles. | ||
register <role> --kind claude --mode file [--handle H] | Joins the bus. | ||
| `chat [--detach\ | --status\ | --stop] [--port N] [--bind A]` | The web chat. |
tui [--open] [--as R] [--theme T] | The terminal client. | ||
deliver | The typing daemon. It needs the orca backend and runs in its own terminal. | ||
whoami, watch-live | Diagnostics. |
Who is notified:
to and cc roles;@role in the text;--re;A post to a #channel notifies nobody unless it mentions someone. @all is for admins only (user, coordinator, scheduler).
/bus with no arguments opens a compact chat pane, about 56 columns wide, where you read and write as the role user (the same identity as the web chat; it is registered on first use). The full experience is the standalone client: /bus full (see the standalone full-screen client).
How it opens. Claude Code has no full-screen overlay for plugins, so the chat is a pane opened with the keyboard focus and "close on Escape". On the main screen it sits inline above the prompt, as tall as the layout spares; in the fullscreen layout it docks beside the transcript as a side bar. It stays open until you close it.
What it shows.
bus user ●3 @1 dlv✓ 12s: unread count, unread @mentions and replies to you, whether the deliver daemon's heartbeat is fresh, and the age of the last write to the log.● marks unread mail for you, @ an @mention of you or a reply to you, ↳ a reply, ¶ a message with a body. Your own messages show the receipt of their least advanced recipient: . queued, v delivered, vv seen, ! failed, x expired, # a channel post.@role names goes to those roles; any other line is posted to #all, and the bus routes it to the @mentioned roles and the coordinator. Typing @ offers matching role names below the field. While replying, the field is labelled with the author and the line goes to them with --re.Keys. A plugin pane can take keys in two ways, and the pane uses both:
| Key | Vim region (click it first) | Hotkey (pane focused) | Action |
|---|---|---|---|
j / k, ↓ / ↑ | yes | j / k | Move the selection |
gg / G, Home / End | yes | First / last message | |
Ctrl-d / Ctrl-u | yes | Down / up eight messages | |
Enter, o, l / h | yes | o | Expand or collapse the selected message (h collapses) |
r | yes | r | Reply to the selected message |
e, then 1-6 | yes | e, then 1-6 | React: ✅ 👀 👍 ❌ 🙏 🎉 |
i / a | yes | Focus the composer | |
x | yes | x | Cancel the reply or the emoji row |
q | yes | q | Close the pane |
Esc | yes | Close the pane (while the pane holds the keyboard) |
The vim region is the one-line NORMAL bar at the bottom of the pane: a raw-key area that receives every key once it has the focus (a click on it). The hotkeys work whenever the pane holds the keyboard (it opens focused; a click or ctrl+x tab gives it back); they are also drawn as a row of buttons you can click. Both run the same actions. Rows are buttons too: a click selects and expands one.
The band. While the pane is closed and you have unread mail, a one-line band above the prompt reads bus: 3 unread · 1 @you [ Open ]; it disappears when the count is zero. New @mentions of user and replies to user also raise a toast once each. Turn the band off with the band option.
Polling. The pane runs bus.py snapshot every 2 seconds while it is open and stops when it closes; the band looks every 15 seconds. snapshot --since <cursor> answers {"unchanged": true} cheaply when nothing on the bus changed. Every write goes through the regular verbs: send --from user, react <id> <emoji> --as user, read <id> --as user.
Themes. The theme option: tokyonight (blue accent, the default) or claude (orange accent, #D97757).
Limitations.
/bus, or press Open on the band.gg, G, Ctrl-d, Ctrl-u, Enter, arrows, h/l, i/a) reach the pane only after a click on the vim region, and Escape there returns the focus instead of reaching the pane. Button hotkeys are limited to one lowercase letter or digit, so G, gg, Ctrl-d/Ctrl-u and Enter have no hotkey (Enter does press a focused row)./bus send ... --body-file or the full client./bus chat starts the server in the background and prints its URL, by default http://127.0.0.1:8790/. The page shows:
tasksDb points at a task-tracker db.People post as the role user. Their posts reach the named roles, and always the coordinator.
The server binds to loopback only and checks the Host header. Stop it with /bus chat --stop.
The full experience: a Textual client for the bus with vim-style modal keys. It reads the bus incrementally (it polls about once a second) and writes only through the same store and chat functions the CLI and the web chat use.
/bus full # from Claude Code: opens it in a new terminal (Orca, tmux or Windows Terminal;
# else prints the command); /bus tui does the same
agent-bus/tui/bus-tui # directly in a terminal; on Windows: agent-bus\tui\bus-tui.cmd
bus-tui --as coordinator --theme claude --dir path/to/bus
--dir, else $AGENT_BUS_DIR, else .claude/bus under the nearest ancestor that holds .git or .claude.--as, else $AGENT_BUS_ROLE, else user (registered on first use, the same way the web chat does).pip install textual). Without it the launcher prints that hint and exits with status 2.All, @me (to you or from you), one tab per #channel, and every thread you open. Unread counts appear on each tab.● means the role's watch heartbeat is live; the number is its unread count), threads, and pinned messages. It hides below 100 columns. Space e toggles it.● when unread, the time, the sender, the recipients, #topic, the text, ▸body, 📌, reactions, and receipts on your own messages.deliver (age of the delivery daemon heartbeat, - when no daemon runs), watch (live watch heartbeats, * when yours is one of them), log (age of the last log write), then your position in the list.| Key | Action |
|---|---|
j / k, ↓ / ↑ | next / previous message |
gg / G | first / last message |
Ctrl-d / Ctrl-u | half page down / up (Ctrl-f / Ctrl-b: full page) |
Enter / o | message detail: full text, body, receipts per recipient, reactions |
i / a | insert mode (write in the composer) |
c | compose modal (to, topic, priority, multi-line text) |
r | reply modal for the selected message |
e / + | reaction palette: ✅ 👀 👍 ❌ 🙏 🎉 (1-6; picking one you already added removes it) |
p | pin / unpin |
s | follow / unfollow the thread (its replies then reach your inbox) |
t | open the selected message's thread in a tab |
x | close the thread tab |
H / L, gT / gt | previous / next tab |
Tab | move focus between the tree and the list (in the tree: j / k move, Enter opens) |
/ | search: jumps to the first match and highlights every match |
n / N | next / previous match |
: | command line |
y | copy the message id |
Esc | clear the search, then the filters |
? | help |
Space | leader (shows the which-key popup) |
Receipts use the bus CLI's symbols: . queued, v delivered, vv seen, ! failed, x expired.
| Key | Action |
|---|---|
Enter | send to the current tab. In a channel tab it posts to that channel, in a thread tab it replies to the selected message, and anywhere else it posts to #all. An @role in the text notifies that role. |
Esc | back to normal mode |
Space)| Key | Action |
|---|---|
f | fuzzy picker over everything: channels, roles, threads, messages |
r | roles (picking one filters by it; pick it again to clear) |
t | threads |
b | channels and tabs |
p | pinned messages |
e | toggle the tree |
n | compose |
u | toggle the unread filter |
@ | toggle the to-me filter |
m | mark every message in the view read |
T | theme picker |
h | help |
q | quit |
In the picker, typing filters the list (subsequence match; whole words rank first), ↑ / ↓ or Ctrl-p / Ctrl-n move, Enter picks, and Esc closes.
:)| Command | Action | |
|---|---|---|
:reply <text> | reply to the selected message (without text: opens the reply modal) | |
:react <emoji> [off] | toggle a reaction, or remove it with off (without arguments: opens the palette) | |
| `:send <to[,to2]\ | #chan> <text>` | send a message |
:thread [id] | open a thread tab | |
:pin / :unpin | pin or unpin the selected message | |
:sub / :unsub | follow or unfollow the selected message's thread | |
:edit <text> / :delete | edit or delete your own message (within 5 minutes) | |
:filter [@role] [#chan] [mentions] [unread] [words…] | narrow the list (:filter alone or :filter clear resets it) | |
:theme <name> | switch the theme | |
:tree | toggle the tree | |
:seen | mark the view read | |
:close | close the thread tab | |
:compose | open the compose modal | |
:help | show the keymap | |
:q | quit |
tokyonight (default), catppuccin (mocha), and claude (warm dark with an orange #D97757 accent). Pick one with --theme, :theme <name>, or Space T.
Set them in /config, or in settings under pluginConfigs["agent-bus"].options. They reach the CLI as environment variables. A project's .claude/claude-mods.json section "agent-bus" overrides the bus options (busDir, roles, orcaBusPath, backend, nudgeAfter, chatPort, chatBind, tasksDb; dir is still read as an old name for busDir); the session options (role, python, monitor, taskPrefix, band, theme) come from /config only. Other keys are reported and ignored. Only init creates the bus directory.
| Option | Default | What it does |
|---|---|---|
busDir | .claude/bus | The bus directory, relative to the project root. |
role | (empty) | The session's role when AGENT_BUS_ROLE and ORCA_BUS_NAME are unset and the terminal is not registered. |
roles | coordinator | The roles init registers. |
orcaBusPath | (empty) | A checkout of orca-bus to use instead of the vendored copy. |
python | python | The interpreter. |
backend | none | orca lets the deliver daemon type urgent mail and nudges into Orca tabs. none never types. |
nudgeAfter | 300 | Seconds of unread mail before the daemon's one nudge (orca backend). |
monitor | on | off stops the "arm a Monitor" requests. |
chatPort | 8790 | The web chat port. |
chatBind | 127.0.0.1 | 127.0.0.1, localhost or ::1. Loopback only. |
tasksDb | (empty) | A task-tracker db for the chat's lane list. |
taskPrefix | TASK | Task ids with this prefix (TASK-12) become task chips in the chat. |
band | on | off hides the unread band above the prompt. |
theme | tokyonight | The /bus pane's colors: tokyonight or claude. |
Roles: a session's role is resolved in this order:
AGENT_BUS_ROLE;ORCA_BUS_NAME;ORCA_TERMINAL_HANDLE;role option.The bus directory (default .claude/bus) holds:
| File | What it holds |
|---|---|
registry.json | The roles and their modes |
log.jsonl | Every message and delivery state |
chat.jsonl | Reactions, subscriptions, pins and edits |
inbox/<role>.jsonl | The role's mail |
inbox/<role>.seen | What the role has read |
inbox/<role>.hookpos | The hook's cursor |
inbox/<role>.watch.json | The watch heartbeat |
bodies/<id>.md | Long message bodies |
Commit the folder or ignore it, as you prefer. It is plain text.
From the repository root:
claude plugin validate ./agent-bus
claude plugin test ./agent-bus
python -m unittest discover -s agent-bus/scripts/tests
(cd agent-bus && python -m unittest discover -s tui/tests -t tui)
cd agent-bus/vendor && python -m unittest tests.test_bus tests.test_chat
MIT. vendor/orca_bus is orca-bus 0.5.0 © 2026 polygon-mv, MIT (see vendor/orca_bus/LICENSE). It is changed only to make the chat's task-id prefix configurable.
hooks/register.tsx 786 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { BusMessage, BusMode, BusSnapshot } from '../types'
5
6/** What `bus.py hook` prints. */
7type HookOut = {
8 bus: boolean
9 dir?: string
10 role?: string | null
11 text?: string | null
12 watch?: boolean
13 registered?: boolean
14 cli?: string
15}
16
17type $T = EngineInterface
18
19const REMIND_EVERY_MS = 10 * 60 * 1000
20const RECHECK_ROLE_EVERY = 25
21const PANE = 'bus'
22const ME = 'user'
23const PANE_POLL_MS = 2000
24const BAND_POLL_MS = 15000
25const EMOJI = ['✅', '👀', '👍', '❌', '🙏', '🎉'] as const
26
27const snap = atom({ plugin: 'agent-bus', key: 'snap' } as const, null)
28const error = atom({ plugin: 'agent-bus', key: 'error' } as const, null)
29const selected = atom({ plugin: 'agent-bus', key: 'selected' } as const, null)
30const expanded = atom({ plugin: 'agent-bus', key: 'expanded' } as const, null)
31const mode = atom({ plugin: 'agent-bus', key: 'mode' } as const, 'normal')
32const draft = atom({ plugin: 'agent-bus', key: 'draft' } as const, '')
33const replyTo = atom({ plugin: 'agent-bus', key: 'replyTo' } as const, null)
34const notice = atom({ plugin: 'agent-bus', key: 'notice' } as const, null)
35
36type Theme = { accent: string; me: string; dim: string; ok: string; warn: string; err: string; selBg: string }
37
38/** tokyonight-like by default; `claude` swaps the accent for Claude's orange. */
39const THEMES: Record<string, Theme> = {
40 tokyonight: { accent: '#7aa2f7', me: '#bb9af7', dim: '#565f89', ok: '#9ece6a', warn: '#e0af68', err: '#f7768e', selBg: '#283457' },
41 claude: { accent: '#D97757', me: '#D97757', dim: '#8a8580', ok: '#7fb069', warn: '#e5a54b', err: '#e06c75', selBg: '#3b2a23' },
42}
43
44/** This load's options, set once by register. */
45const cfg = { python: 'python', band: true, theme: THEMES.tokyonight as Theme }
46
47// Module state: a reload starts it over (timers are the engine's and end with the load).
48let paneTimer: { cancel: () => void } | undefined
49let paneOpen = false
50let lastKey = 0
51let gPending = false
52const toasted = new Set<string>()
53let primed = false
54let lastBandLook = 0
55/** Whether the last snapshot found a bus in this project (undefined before the first). */
56let busFound: boolean | undefined
57
58function str(v: unknown, fallback: string): string {
59 return typeof v === 'string' && v !== '' ? v : fallback
60}
61
62function short(s: string | null | undefined, n: number): string {
63 const t = (s ?? '').replace(/\s+/g, ' ')
64 return t.length <= n ? t : t.slice(0, Math.max(0, n - 1)) + '…'
65}
66
67/** Runs `bus.py <args>` from this plugin's folder. */
68async function runBus($: $T, python: string, args: string[], timeoutMs: number) {
69 return $.process.run([python, `${$.plugin.root}/scripts/bus.py`, ...args], { timeoutMs })
70}
71
72/** Asks bus.py for this session's mail and state at a hook event. */
73async function hook($: $T, python: string, event: string): Promise<HookOut> {
74 const ran = await runBus($, python, ['hook', '--event', event], 15000)
75 if (ran.exitCode !== 0) {
76 throw new Error(`bus.py hook exited ${ran.exitCode}: ${ran.stderr.trim().slice(0, 300)}`)
77 }
78 return JSON.parse(ran.stdout) as HookOut
79}
80
81/** What `/bus` says when the configured bus directory does not exist. */
82export function noBusText(dir: string | undefined): string {
83 return (
84 `agent-bus: no bus in this project yet (${dir ?? '.claude/bus'}). \`/bus init\` creates one there. ` +
85 'If this project\'s bus lives elsewhere, set "busDir" in the "agent-bus" section of .claude/claude-mods.json ' +
86 '(for example {"agent-bus": {"busDir": "tmp/bus"}}).'
87 )
88}
89
90/** The `/bus` subcommands: the pane's own, then the bus.py verbs. Nothing else ever reaches the CLI. */
91export const BUS_VERBS = [
92 'pane', 'full', 'tui', 'chat', 'init', 'send', 'digest', 'read', 'inbox', 'ack', 'watch', 'react', 'thread',
93 'sub', 'unsub', 'pin', 'unpin', 'edit', 'delete', 'status', 'who', 'register', 'unregister', 'deliver',
94 'snapshot', 'whoami', 'watch-live',
95] as const
96
97export const BUS_HELP = [
98 'Usage: /bus [subcommand] (no subcommand opens the chat pane)',
99 ' @role <text> send <text> to role as user',
100 ' full | tui the full-screen client in a new terminal',
101 ' chat [--stop|--status] the localhost web chat',
102 ' init [--roles a,b] create this project\'s bus',
103 ' send <to> "<text>" send a message',
104 ' digest | who | status [id] | read <id> | thread <id>',
105 ' react <id> <emoji> | sub | unsub | pin | unpin | edit | delete <id>',
106 ' register <role> ... | whoami | deliver',
107].join('\n')
108
109/**
110 * `/bus` arguments as bus.py verbs. Nothing (or `pane`) opens the chat pane (null); `full` and `tui` start
111 * the standalone full-screen client in a new terminal; `chat` starts the web chat in the background;
112 * `@role text` sends text to that role as `user`. Anything else is not a subcommand: the help text
113 * (a string) is returned, and nothing is run.
114 */
115export function busArgs(typed: string): string[] | string | null {
116 const trimmed = typed.trim()
117 const args = trimmed === '' ? [] : trimmed.split(/\s+/)
118 if (args.length === 0 || (args[0] === 'pane' && args.length === 1)) {
119 return null
120 }
121 const lead = /^((?:@[A-Za-z0-9][\w.-]*[\s,]*)+)([\s\S]*)$/.exec(trimmed)
122 if (lead) {
123 const to = [...(lead[1] ?? '').matchAll(/@([A-Za-z0-9][\w.-]*)/g)].map(x => (x[1] ?? '').replace(/[.-]+$/, ''))
124 const text = (lead[2] ?? '').trim()
125 return text === '' ? `${BUS_HELP}\n\n/bus @role needs a message after the role.` : ['send', '--from', ME, '--', to.join(','), text]
126 }
127 if (['help', '-h', '--help'].includes(args[0] ?? '')) {
128 return BUS_HELP
129 }
130 if (!(BUS_VERBS as readonly string[]).includes(args[0] ?? '') || args[0] === 'pane') {
131 return `/bus: "${args[0]}" is not a subcommand.\n${BUS_HELP}`
132 }
133 const flags = new Set(args.slice(1))
134 if (args[0] === 'chat' && !flags.has('--stop') && !flags.has('--status')) {
135 return [...args, '--detach']
136 }
137 if (args[0] === 'full') {
138 return ['tui', '--open', ...args.slice(1)]
139 }
140 if (args[0] === 'tui') {
141 return [...args, '--open']
142 }
143 return args
144}
145
146/** The Monitor command that keeps an idle agent listening. */
147export function watchCommand(out: HookOut): string {
148 return `timeout 1790 ${out.cli} watch --as ${out.role}`
149}
150
151export function armLine(out: HookOut): string {
152 return (
153 `agent-bus: no live watch for ${out.role}. Arm one now with the Monitor tool: command ` +
154 `\`${watchCommand(out)}\`, timeout 1800000 ms. It prints one line per new non-fyi message, ` +
155 'which wakes you when idle; re-arm it whenever it ends. Skip this if one is already running.'
156 )
157}
158
159/** The one tick a sender sees for a message: the least advanced receipt wins, a failure first. */
160export function tickOf(m: BusMessage): string {
161 const rs = m.receipts
162 if (rs.length === 0) {
163 return m.chan || m.to.length === 0 ? '#' : '.'
164 }
165 for (const bad of ['failed', 'expired']) {
166 const r = rs.find(x => x.r === bad)
167 if (r) return r.tick
168 }
169 for (const st of ['queued', 'delivered', 'seen', 'posted']) {
170 const r = rs.find(x => x.r === st)
171 if (r) return r.tick
172 }
173 return rs[0]?.tick ?? '.'
174}
175
176/** Where a composed line goes: a reply to the parent's author, the leading @roles, else #all. */
177export function sendArgs(text: string, parent: BusMessage | undefined, roles: string[]): string[] {
178 const args = ['send', '--from', ME]
179 let to = '#all'
180 if (parent) {
181 args.push('--re', parent.id)
182 to = parent.from !== ME ? parent.from : (parent.to[0] ?? (parent.chan ? `#${parent.chan}` : '#all'))
183 } else {
184 const lead = /^((?:@[A-Za-z0-9][\w.-]*[\s,]*)+)/.exec(text.trim())
185 const named = lead ? [...(lead[1] ?? '').matchAll(/@([A-Za-z0-9][\w.-]*)/g)].map(x => (x[1] ?? '').replace(/[.-]+$/, '')) : []
186 const known = named.filter(n => roles.includes(n) && n !== ME)
187 if (known.length > 0) to = known.join(',')
188 }
189 return [...args, '--', to, text]
190}
191
192/** Roles that complete the `@prefix` the draft ends with. */
193export function completions(text: string, roles: string[]): string[] {
194 const m = /(?:^|\s)@([\w.-]*)$/.exec(text)
195 if (!m) return []
196 const p = (m[1] ?? '').toLowerCase()
197 return roles.filter(r => r !== ME && r.toLowerCase().startsWith(p) && r.toLowerCase() !== p).slice(0, 4)
198}
199
200function messages(s: BusSnapshot | null): BusMessage[] {
201 return s?.messages ?? []
202}
203
204function selIndex(ms: BusMessage[], id: string | null): number {
205 const i = id === null ? -1 : ms.findIndex(m => m.id === id)
206 return i >= 0 ? i : ms.length - 1
207}
208
209// ------------------------------------------------------------------------------------------- data
210
211/** One `snapshot` call: incremental by cursor unless forced; toasts new @mentions and replies to the user. */
212async function refresh($: $T, force = false): Promise<void> {
213 const prev = await read($, snap)
214 const args = ['snapshot', '--as', ME]
215 if (!force && prev?.cursor) args.push('--since', prev.cursor)
216 let s: BusSnapshot
217 try {
218 const r = await runBus($, cfg.python, args, 20000)
219 if (r.exitCode !== 0) {
220 await update($, error, () => short(r.stderr.trim() || `bus.py snapshot exited ${r.exitCode}`, 300))
221 return
222 }
223 s = JSON.parse(r.stdout) as BusSnapshot
224 } catch (err) {
225 await update($, error, () => short(String(err), 300))
226 return
227 }
228 await update($, error, () => null)
229 busFound = s.bus
230 if (s.unchanged && prev) {
231 await update($, snap, () => ({ ...prev, health: s.health ?? prev.health, cursor: s.cursor ?? prev.cursor }))
232 return
233 }
234 await update($, snap, () => s)
235 for (const m of messages(s)) {
236 if (toasted.has(m.id) || m.from === ME || !m.unread || !(m.mention || m.reply_to_me)) continue
237 toasted.add(m.id)
238 if (primed) {
239 $.ui.toast(`${m.reply_to_me ? '↳' : '@'} ${m.from}: ${short(m.text, 160)}`)
240 }
241 }
242 primed = true
243}
244
245function ensurePolling($: $T): void {
246 paneOpen = true
247 if (!paneTimer) {
248 paneTimer = $.clock.every(PANE_POLL_MS, () => {
249 void refresh($)
250 })
251 }
252}
253
254function stopPolling(): void {
255 paneOpen = false
256 paneTimer?.cancel()
257 paneTimer = undefined
258}
259
260async function openPane($: $T): Promise<boolean> {
261 await refresh($, true)
262 await update($, mode, () => 'normal' as BusMode)
263 const opened = await $.ui.open({ id: PANE, title: 'Bus', focus: true, closeOnEscape: true, columns: 56 })
264 ensurePolling($)
265 return opened.isPlaced
266}
267
268async function closePane($: $T): Promise<void> {
269 stopPolling()
270 await $.ui.close({ id: PANE })
271}
272
273async function focusComposer($: $T): Promise<void> {
274 try {
275 await $.ui.focus({ requestId: PANE, key: 'composer' })
276 } catch {
277 // the site does not hold the keys (a click elsewhere): the person clicks the field
278 }
279}
280
281async function expand($: $T, m: BusMessage | undefined): Promise<void> {
282 if (!m) return
283 const cur = await read($, expanded)
284 if (cur?.id === m.id) {
285 await update($, expanded, () => null)
286 return
287 }
288 await update($, expanded, () => ({ id: m.id, body: null, loading: m.body }))
289 if (m.unread && m.rid) {
290 await runBus($, cfg.python, ['read', m.rid, '--as', ME], 15000)
291 }
292 if (m.body) {
293 const r = await runBus($, cfg.python, ['snapshot', '--body', m.id], 15000)
294 let body: string | null = null
295 try {
296 body = (JSON.parse(r.stdout) as { body: string | null }).body
297 } catch {
298 body = null
299 }
300 await update($, expanded, d => (d?.id === m.id ? { id: m.id, body: body ?? '(body unreadable)', loading: false } : d))
301 }
302 if (m.unread) await refresh($, true)
303}
304
305async function sendDraft($: $T, text: string): Promise<void> {
306 const t = text.trim()
307 if (t === '') return
308 const s = await read($, snap)
309 const re = await read($, replyTo)
310 const parent = messages(s).find(m => m.id === re)
311 const roles = (s?.roles ?? []).map(r => r.name)
312 const r = await runBus($, cfg.python, sendArgs(t, parent, roles), 20000)
313 if (r.exitCode !== 0) {
314 await update($, notice, () => `send failed: ${short(r.stderr.trim() || r.stdout.trim(), 200)}`)
315 return
316 }
317 await update($, draft, () => '')
318 await update($, replyTo, () => null)
319 await update($, notice, () => `sent ${short(r.stdout.trim().split('\n')[0] ?? '', 120)}`)
320 await refresh($, true)
321}
322
323async function react($: $T, emoji: string): Promise<void> {
324 const s = await read($, snap)
325 const ms = messages(s)
326 const m = ms[selIndex(ms, await read($, selected))]
327 await update($, mode, () => 'normal' as BusMode)
328 if (!m) return
329 const r = await runBus($, cfg.python, ['react', m.id, emoji, '--as', ME], 15000)
330 await update($, notice, () => (r.exitCode === 0 ? `${emoji} on ${m.id}` : `react failed: ${short(r.stderr.trim(), 200)}`))
331 await refresh($, true)
332}
333
334/** Every key and button lands here, so the vim keys and the hotkeys do the same thing. */
335async function act($: $T, action: string): Promise<void> {
336 const s = await read($, snap)
337 const ms = messages(s)
338 const i = selIndex(ms, await read($, selected))
339 const move = async (to: number) => {
340 const m = ms[Math.max(0, Math.min(ms.length - 1, to))]
341 if (m) await update($, selected, () => m.id)
342 }
343 switch (action) {
344 case 'down': return move(i + 1)
345 case 'up': return move(i - 1)
346 case 'top': return move(0)
347 case 'bottom': return move(ms.length - 1)
348 case 'pagedown': return move(i + 8)
349 case 'pageup': return move(i - 8)
350 case 'expand': return expand($, ms[i])
351 case 'collapse':
352 await update($, expanded, () => null)
353 return
354 case 'reply':
355 if (ms[i]) {
356 await update($, replyTo, () => ms[i]?.id ?? null)
357 await focusComposer($)
358 }
359 return
360 case 'react':
361 await update($, mode, m => (m === 'react' ? 'normal' : 'react') as BusMode)
362 return
363 case 'compose':
364 await focusComposer($)
365 return
366 case 'cancel':
367 await update($, mode, () => 'normal' as BusMode)
368 await update($, replyTo, () => null)
369 return
370 case 'close':
371 return closePane($)
372 default:
373 if (action.startsWith('emoji:')) {
374 const e = EMOJI[Number(action.slice(6)) - 1]
375 if (e) return react($, e)
376 }
377 }
378}
379
380/** A raw key from the Client region, vim style. */
381export function keyAction(k: string, ctrl: boolean, shift: boolean, reacting: boolean, g: boolean): { action?: string; g: boolean } {
382 if (ctrl) {
383 if (k === 'd') return { action: 'pagedown', g: false }
384 if (k === 'u') return { action: 'pageup', g: false }
385 return { g: false }
386 }
387 if (reacting && /^[1-6]$/.test(k)) return { action: `emoji:${k}`, g: false }
388 if (k === 'G' || (k === 'g' && shift)) return { action: 'bottom', g: false }
389 if (k === 'g') return g ? { action: 'top', g: false } : { g: true }
390 const map: Record<string, string> = {
391 j: 'down', down: 'down', k: 'up', up: 'up', return: 'expand', o: 'expand', l: 'expand', h: 'collapse',
392 r: 'reply', e: 'react', q: 'close', i: 'compose', a: 'compose', x: 'cancel', pagedown: 'pagedown', pageup: 'pageup',
393 home: 'top', end: 'bottom',
394 }
395 return { action: map[k], g: false }
396}
397
398// ------------------------------------------------------------------------------------------- register
399
400export const register: Register = (on, options) => {
401 const python = str(options.python, 'python')
402 const monitorOn = str(options.monitor, 'on') === 'on'
403 cfg.python = python
404 cfg.band = str(options.band, 'on') !== 'off'
405 cfg.theme = THEMES[str(options.theme, 'tokyonight')] ?? (THEMES.tokyonight as Theme)
406
407 // Module state: a reload starts it over, which only costs one extra look at the inbox.
408 let known: HookOut | undefined
409 let lastSize = -1
410 let callsSinceLook = 0
411 let lastReminder = 0
412
413 on('session.start', async ($, e, next) => {
414 // Every Bash child and every bus.py run inherits these, so the model's own commands
415 // and a Monitor's watch use the same configuration as the hooks.
416 await $.env.set('AGENT_BUS_DIR', str(options.busDir, '.claude/bus'))
417 await $.env.set('AGENT_BUS_BACKEND', str(options.backend, 'none'))
418 await $.env.set('AGENT_BUS_NUDGE_AFTER', String(options.nudgeAfter ?? 300))
419 await $.env.set('AGENT_BUS_CHAT_PORT', String(options.chatPort ?? 8790))
420 await $.env.set('AGENT_BUS_CHAT_BIND', str(options.chatBind, '127.0.0.1'))
421 await $.env.set('AGENT_BUS_ROLES', str(options.roles, 'coordinator'))
422 await $.env.set('AGENT_BUS_PYTHON', python)
423 // Lets other tools (the coordinator plugin, shell workers) find this CLI.
424 await $.env.set('AGENT_BUS_CLI', `${$.plugin.root}/scripts/bus.py`)
425 await $.env.set('AGENT_BUS_TASK_PREFIX', str(options.taskPrefix, 'TASK'))
426 if (str(options.orcaBusPath, '') !== '') {
427 await $.env.set('AGENT_BUS_ORCA_BUS_PATH', str(options.orcaBusPath, ''))
428 }
429 if (str(options.tasksDb, '') !== '') {
430 await $.env.set('AGENT_BUS_TASKS_DB', str(options.tasksDb, ''))
431 }
432 if (str(options.role, '') !== '' && (await $.env.get('AGENT_BUS_ROLE')) === undefined) {
433 await $.env.set('AGENT_BUS_ROLE', str(options.role, ''))
434 }
435 await $.command.register({
436 name: 'bus',
437 description: 'agent-bus: open the chat pane; `full` opens the full-screen client, `chat` the web chat; or any bus verb.',
438 argumentHint: '[@role text|full|chat|digest|who|init|send|status <id>|help]',
439 })
440 if (cfg.band) {
441 // the band's slow look; the pane polls faster while it is open
442 $.clock.every(BAND_POLL_MS, () => {
443 const now = Date.now()
444 // no bus in this project: look again only now and then (one may be created mid-session)
445 if (paneOpen || (busFound === false && now - lastBandLook < 8 * BAND_POLL_MS)) return
446 lastBandLook = now
447 void refresh($)
448 })
449 }
450 return next(e)
451 })
452
453 on('command.run', { command: 'bus' }, async ($, e) => {
454 const args = busArgs(e.args)
455 if (typeof args === 'string') {
456 return { text: args }
457 }
458 if (args === null) {
459 const placed = await openPane($)
460 const s = await read($, snap)
461 if (s && !s.bus) {
462 return { text: noBusText(s.dir) }
463 }
464 return {
465 text: placed
466 ? `bus: chat open as ${ME} (j/k move, Enter/o expand, r reply, e react, q or Esc close).`
467 : 'bus: the chat pane is waiting for room; widen the terminal.',
468 }
469 }
470 const ran = await runBus($, python, args, 30000)
471 const text = [ran.stdout.trim(), ran.stderr.trim()].filter(t => t !== '').join('\n')
472 return { text: text === '' ? `bus ${args.join(' ')}: done` : text }
473 })
474
475 on('ui.close', async ($, e, next) => {
476 if (e.id === PANE) stopPolling()
477 return next(e)
478 }).catch(($, e, next) => next(e))
479
480 on('ui.message', async ($, e, next) => {
481 if (e.requestId !== PANE || e.element !== 'keys') return next(e)
482 const d = e.data as { n?: unknown; keys?: unknown }
483 const n = typeof d?.n === 'number' ? d.n : 0
484 const keys = Array.isArray(d?.keys) ? (d.keys as { i: number; k: string; ctrl?: boolean; shift?: boolean }[]) : []
485 if (n < lastKey) lastKey = 0 // a new instance counts from one again
486 for (const k of keys) {
487 if (typeof k?.i !== 'number' || typeof k.k !== 'string' || k.i <= lastKey) continue
488 lastKey = k.i
489 const reacting = (await read($, mode)) === 'react'
490 const r = keyAction(k.k, k.ctrl === true, k.shift === true, reacting, gPending)
491 gPending = r.g
492 if (r.action) await act($, r.action)
493 }
494 return next(e)
495 })
496
497 // ----------------------------------------------------------------------------------------- the band
498
499 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
500 if (!cfg.band || paneOpen || e.props.hasSurvey) return next(e)
501 const s = await read($, snap)
502 const unread = s?.unread ?? 0
503 const at = s?.mentions ?? 0
504 if (!s?.bus || (unread === 0 && at === 0)) return next(e)
505 const { Box, Text, Button } = $.ui.resolve(e)
506 const t = cfg.theme
507 return (
508 <Box gap={1}>
509 <Text key="bus-band" bold color={t.accent}>bus:</Text>
510 <Text key="bus-unread">{unread} unread</Text>
511 {at > 0 && <Text key="bus-at" color={t.warn}>· {at} @you</Text>}
512 <Button key="bus-open" label="Open" onPress={async () => { await openPane($) }} />
513 </Box>
514 )
515 })
516
517 // ----------------------------------------------------------------------------------------- the pane
518
519 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
520 ensurePolling($)
521 const els = $.ui.resolve(e)
522 const { Box, Text, Button } = els
523 const Input = 'Input' in els ? els.Input : null
524 const Client = 'Client' in els ? els.Client : null
525 const t = cfg.theme
526 const s = await read($, snap)
527 const err = await read($, error)
528 const cols = Math.max(20, e.props.bodyColumns)
529 const bodyRows = Math.max(8, e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 24)
530
531 if (!s) {
532 return (
533 <Box flexDirection="column">
534 <Text color={err ? t.err : t.dim}>{err ?? 'loading the bus…'}</Text>
535 <Button key="retry" hotkey="g" label="retry" onPress={async () => { await refresh($, true) }} />
536 </Box>
537 )
538 }
539 if (!s.bus) {
540 return (
541 <Box flexDirection="column">
542 <Text>No bus in this project.</Text>
543 <Text dimColor>`/bus init` creates {s.dir ?? '.claude/bus'}</Text>
544 <Text dimColor>A bus elsewhere: set "busDir" under "agent-bus" in .claude/claude-mods.json</Text>
545 </Box>
546 )
547 }
548
549 const ms = messages(s)
550 const sel = selIndex(ms, await read($, selected))
551 const ex = await read($, expanded)
552 const md = await read($, mode)
553 const dr = await read($, draft)
554 const re = await read($, replyTo)
555 const note = await read($, notice)
556 const roles = (s.roles ?? []).map(r => r.name)
557 const health = s.health
558 const nameW = cols < 50 ? 8 : 11
559
560 // header: who, unread, @you, bus health
561 const header = (
562 <Box key="hdr" gap={1}>
563 <Text bold color={t.accent}>bus</Text>
564 <Text color={t.me}>{ME}</Text>
565 {(s.unread ?? 0) > 0 && <Text color={t.ok}>●{s.unread}</Text>}
566 {(s.mentions ?? 0) > 0 && <Text color={t.warn}>@{s.mentions}</Text>}
567 <Text color={health?.deliver ? t.ok : t.dim}>{health?.deliver ? 'dlv✓' : 'dlv·'}</Text>
568 {health?.last_write_age !== null && health?.last_write_age !== undefined && (
569 <Text dimColor>{age(health.last_write_age)}</Text>
570 )}
571 </Box>
572 )
573
574 // the expanded message's lines
575 const exMsg = ex ? ms.find(m => m.id === ex.id) : undefined
576 const detail: RenderChildren[] = []
577 if (exMsg && ex) {
578 detail.push(<Text key="d-text" wrap="wrap">{exMsg.deleted ? '(deleted)' : exMsg.text}</Text>)
579 const dest = [...exMsg.to, ...exMsg.cc.map(c => `cc ${c}`)].join(', ') || (exMsg.chan ? `#${exMsg.chan}` : '#all')
580 detail.push(<Text key="d-to" dimColor wrap="truncate-end">{`${exMsg.id} → ${dest}${exMsg.topic ? ` [${exMsg.topic}]` : ''}`}</Text>)
581 if (exMsg.receipts.length > 0) {
582 detail.push(
583 <Text key="d-rc" wrap="wrap" color={t.dim}>
584 {exMsg.receipts.map(r => `${r.to} ${r.tick} ${r.r}`).join(' · ')}
585 </Text>,
586 )
587 }
588 const rx = Object.entries(exMsg.reactions).filter(([, by]) => by.length > 0)
589 if (rx.length > 0) {
590 detail.push(<Text key="d-rx" wrap="wrap">{rx.map(([em, by]) => `${em} ${by.join(',')}`).join(' ')}</Text>)
591 }
592 if (exMsg.body) {
593 const lines = ex.loading ? ['(loading body…)'] : (ex.body ?? '').split('\n').slice(0, 60)
594 lines.forEach((l, n) => detail.push(<Text key={`d-b${n}`} wrap="wrap">{l === '' ? ' ' : l}</Text>))
595 }
596 }
597
598 // rows that fit: the bottom block is the composer, the key bar, the key region and one optional row
599 const extra = (md === 'react' ? 1 : 0) + (completions(dr, roles).length > 0 ? 1 : 0) + (note || err ? 1 : 0)
600 const fit = Math.max(1, bodyRows - 4 - extra - (exMsg ? Math.min(detail.length, bodyRows - 6) : 0))
601 const start = Math.max(0, Math.min(sel - Math.floor(fit / 2), ms.length - fit))
602 const shown = ms.slice(start, start + fit)
603
604 const rows = shown.map((m, n) => {
605 const idx = start + n
606 const isSel = idx === sel
607 const mine = m.from === ME
608 const who = mine ? `→${m.to[0] ?? (m.chan ? `#${m.chan}` : '#all')}` : m.from
609 const marks = `${m.unread ? '●' : ' '}${m.mention || m.reply_to_me ? '@' : ' '}`
610 const tick = mine ? ` ${tickOf(m)}` : ''
611 const rx = Object.entries(m.reactions).filter(([, by]) => by.length > 0).map(([em]) => em).join('')
612 return (
613 <Box key={`row-${m.id}`} flexDirection="column">
614 <Button key={`msg-${m.id}`} plain onPress={async () => { await update($, selected, () => m.id); await expand($, m) }}>
615 <Text color={isSel ? t.accent : undefined} bold={isSel}>{isSel ? '›' : ' '}</Text>
616 <Text color={m.mention || m.reply_to_me ? t.warn : t.ok}>{marks}</Text>
617 <Text dimColor>{m.time.slice(11, 16)} </Text>
618 <Text color={mine ? t.me : t.accent}>{short(who, nameW)}</Text>
619 <Text color={t.dim}>{tick} </Text>
620 <Text backgroundColor={isSel ? t.selBg : undefined} wrap="truncate-end">
621 {`${m.reply_to ? '↳' : ''}${m.deleted ? '(deleted)' : m.text}${m.body ? ' ¶' : ''}${rx ? ` ${rx}` : ''}`}
622 </Text>
623 </Button>
624 {ex && ex.id === m.id && (
625 <Box key={`det-${m.id}`} flexDirection="column" paddingLeft={2}>
626 {detail}
627 </Box>
628 )}
629 </Box>
630 )
631 })
632
633 const sugg = completions(dr, roles)
634 const parent = re ? ms.find(m => m.id === re) : undefined
635
636 return (
637 <Box flexDirection="column">
638 {header}
639 {ms.length === 0 ? <Text key="empty" dimColor>No messages yet.</Text> : <Box key="list" flexDirection="column">{rows}</Box>}
640 {(note || err) && <Text key="note" color={err ? t.err : t.dim} wrap="truncate-end">{err ?? note}</Text>}
641 {md === 'react' && (
642 <Box key="react-row" gap={1}>
643 {EMOJI.map((em, n) => (
644 <Button key={`rx-${n + 1}`} plain hotkey={String(n + 1)} label={em} onPress={async () => { await react($, em) }} />
645 ))}
646 </Box>
647 )}
648 {sugg.length > 0 && (
649 <Box key="ac-row" gap={1}>
650 {sugg.map(r => (
651 <Button
652 key={`ac-${r}`}
653 plain
654 label={`@${r}`}
655 onPress={async () => {
656 await update($, draft, d => d.replace(/@[\w.-]*$/, `@${r} `))
657 await focusComposer($)
658 }}
659 />
660 ))}
661 </Box>
662 )}
663 {Input ? (
664 <Input
665 key="composer"
666 label={parent ? `↳${short(parent.from, 10)} ` : '› '}
667 placeholder={cols < 50 ? '@role message' : '@role message · Enter sends'}
668 value={dr}
669 submitLabel="send"
670 onInput={async (v: string) => { await update($, draft, () => v) }}
671 onSubmit={async (v: string) => { await sendDraft($, v) }}
672 />
673 ) : (
674 <Text key="no-input" dimColor>this surface has no text field: compose with /bus send</Text>
675 )}
676 <Box key="keybar" gap={1}>
677 <Button key="k-down" plain hotkey="j" label="↓" onPress={async () => { await act($, 'down') }} />
678 <Button key="k-up" plain hotkey="k" label="↑" onPress={async () => { await act($, 'up') }} />
679 <Button key="k-open" plain hotkey="o" label="open" onPress={async () => { await act($, 'expand') }} />
680 <Button key="k-reply" plain hotkey="r" label="reply" onPress={async () => { await act($, 'reply') }} />
681 <Button key="k-react" plain hotkey="e" label="react" onPress={async () => { await act($, 'react') }} />
682 {(re || md === 'react') && <Button key="k-cancel" plain hotkey="x" label="cancel" onPress={async () => { await act($, 'cancel') }} />}
683 <Button key="k-close" plain hotkey="q" label="close" onPress={async () => { await act($, 'close') }} />
684 </Box>
685 {Client && (
686 <Client
687 key="keys"
688 module="./keys.tsx"
689 props={{
690 mode: md === 'react' ? 'react' : re ? 'reply' : 'normal',
691 hint: cols < 50 ? 'click: vim keys' : 'click here: j k gg G ^d ^u ↵ r e i q',
692 accent: t.accent,
693 }}
694 />
695 )}
696 </Box>
697 )
698 })
699
700 // ----------------------------------------------------------------------------------------- mail for agents
701
702 on('classic.SessionStart', async ($, e, next) => {
703 const result = await next(e)
704 const out = await hook($, python, 'SessionStart')
705 known = out
706 if (!out.bus) {
707 return result
708 }
709 const lines: string[] = []
710 if (!out.role) {
711 lines.push(
712 `agent-bus: a bus is set up here (${out.dir}) but this session has no role. To join: ` +
713 `\`${out.cli} register <role> --kind claude --mode file\` and set AGENT_BUS_ROLE=<role>.`,
714 )
715 } else {
716 lines.push(
717 `agent-bus: you are \`${out.role}\`. Bus CLI: \`${out.cli}\` (send <to> "<one line>" [--prio urgent|normal|fyi] ` +
718 '[--re <id>], digest, read <id>, react <id> <emoji>). New mail reaches you after tool calls; no acks.',
719 )
720 if (monitorOn && out.registered && !out.watch) {
721 lines.push(armLine(out))
722 }
723 if (out.text) {
724 lines.push(out.text)
725 }
726 }
727 return { ...result, additionalContext: [...(result.additionalContext ?? []), lines.join('\n')] }
728 }).catch(($, e, next) => next(e))
729
730 on('classic.PostToolUse', async ($, e, next) => {
731 const result = await next(e)
732 callsSinceLook += 1
733 if (known !== undefined && (!known.bus || !known.role)) {
734 // No bus or no role yet: look again now and then (a tab may register mid-session).
735 if (callsSinceLook < RECHECK_ROLE_EVERY) {
736 return result
737 }
738 } else if (known?.dir && known.role) {
739 // The cheap path: only run Python when the inbox file grew.
740 try {
741 const st = await $.fs.stat(`${known.dir}/inbox/${known.role}.jsonl`)
742 if (st.size === lastSize) {
743 return result
744 }
745 lastSize = st.size
746 } catch {
747 return result
748 }
749 }
750 callsSinceLook = 0
751 const out = await hook($, python, 'PostToolUse')
752 known = out
753 if (!out.text) {
754 return result
755 }
756 return { ...result, additionalContext: [...(result.additionalContext ?? []), out.text] }
757 }).catch(($, e, next) => next(e))
758
759 on('classic.Stop', async ($, e, next) => {
760 const result = await next(e)
761 if (e.stop_hook_active || result.block !== undefined) {
762 return result // already kept going once: let the agent stop
763 }
764 const out = await hook($, python, 'Stop')
765 known = out
766 if (!out.bus || !out.role) {
767 return result
768 }
769 if (out.text) {
770 return { ...result, block: out.text }
771 }
772 const now = Date.now()
773 if (monitorOn && out.registered && !out.watch && now - lastReminder > REMIND_EVERY_MS) {
774 lastReminder = now
775 return { ...result, block: `${armLine(out)} Then you may go idle.` }
776 }
777 return result
778 }).catch(($, e, next) => next(e))
779}
780
781function age(sec: number): string {
782 if (sec < 60) return `${Math.round(sec)}s`
783 if (sec < 3600) return `${Math.round(sec / 60)}m`
784 return `${Math.round(sec / 3600)}h`
785}
786hooks/keys.tsx 38 lines1import type { ClientKeyEvent, ClientModule, JsonValue } from 'claude-code'
2
3/** What the pane hands the key region: the mode shown and the accent it is drawn in. */
4type KeysProps = { mode: string; hint: string; accent: string }
5
6type Sent = { i: number; k: string; ctrl?: true; shift?: true }
7
8/**
9 * The pane's raw-key region: once it has the focus (a click on it), every key the person presses is
10 * posted to the hooks module, which owns all behaviour. Posts carry a running count and the last keys,
11 * so a key pressed in the same frame as another is not lost when one post replaces an undelivered one.
12 */
13const Keys: ClientModule<JsonValue, { ready: true }> = (props, surface) => {
14 const { Box, Text } = surface.elements
15 const p = (props ?? {}) as Partial<KeysProps>
16 if (surface.state === undefined) {
17 let n = 0
18 let recent: Sent[] = []
19 surface.onKey((ev: ClientKeyEvent) => {
20 n += 1
21 const s: Sent = { i: n, k: ev.key }
22 if (ev.ctrl) s.ctrl = true
23 if (ev.shift) s.shift = true
24 recent = [...recent.slice(-15), s]
25 surface.post({ n, keys: recent } as unknown as JsonValue)
26 })
27 surface.setState({ ready: true })
28 }
29 return (
30 <Box gap={1}>
31 <Text inverse bold color={p.accent ?? 'claude'}>{` ${(p.mode ?? 'normal').toUpperCase()} `}</Text>
32 <Text dimColor wrap="truncate-end">{p.hint ?? 'click here for vim keys'}</Text>
33 </Box>
34 )
35}
36
37export default Keys
38types/index.d.ts 73 lines1/** One recipient's receipt: queued, delivered, seen, failed, expired or posted (a channel post). */
2export type Receipt = { to: string; r: string; tick: string; why: string }
3
4/** One message as `bus.py snapshot` prints it. */
5export type BusMessage = {
6 id: string
7 from: string
8 time: string
9 text: string
10 body: boolean
11 prio: string
12 topic: string | null
13 chan: string | null
14 thread: string | null
15 reply_to: string | null
16 to: string[]
17 cc: string[]
18 reactions: Record<string, string[]>
19 pinned: boolean
20 edited: boolean
21 deleted: boolean
22 receipts: Receipt[]
23 /** The per-recipient id of the copy addressed to `me`, when there is one (what `read` marks seen). */
24 rid: string | null
25 unread: boolean
26 mention: boolean
27 reply_to_me: boolean
28}
29
30export type BusRole = { name: string; kind: string | null; mode: string | null; unread: number; watch: boolean }
31
32export type BusHealth = {
33 deliver: boolean
34 deliver_age: number | null
35 watch: boolean
36 last_write_age: number | null
37}
38
39export type BusSnapshot = {
40 bus: boolean
41 dir?: string
42 me?: string
43 cursor?: string
44 unchanged?: boolean
45 health?: BusHealth
46 messages?: BusMessage[]
47 roles?: BusRole[]
48 channels?: string[]
49 unread?: number
50 mentions?: number
51}
52
53/** normal: keys move and act; react: the emoji row is up. Typing goes to the composer Input. */
54export type BusMode = 'normal' | 'react'
55
56/** What an expanded message shows below its row: the body, once fetched. */
57export type BusDetail = { id: string; body: string | null; loading: boolean }
58
59declare module 'claude-code' {
60 interface PluginState {
61 'agent-bus': {
62 snap: BusSnapshot | null
63 error: string | null
64 selected: string | null
65 expanded: BusDetail | null
66 mode: BusMode
67 draft: string
68 replyTo: string | null
69 notice: string | null
70 }
71 }
72}
73