SLOPSHOPPER

agent-bus

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…

newpanebandcommandtoastprocess
v0.1.2MITupdated 2026-10-09azoof-ahmed/claude-mods/agent-bus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-bus
│ ┃ Bus ✕ › fix the failing auth test and add an audit log call │ ┃ SyntaxError: JSON Parse error: Unexpected │ ┃ identifier "dev" ⏺ Read(src/auth.ts) │ ┃ [ retry ] ⎿ Read 6 lines │ ⏺ 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 │ │ › /bus │ ⎿ agent-bus: bus: chat open as user (j/k move, Enter/o expand, r r │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Bus
SyntaxError: JSON Parse error: Unexpected identifier "dev" [ retry ]
README

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

  • A busy agent gets its new mail after each tool call.
  • An idle agent is woken by a Monitor.
  • People follow along in a chat pane inside Claude Code (/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.

Install

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

Quick start

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

How mail reaches an agent: hooks plus a Monitor

WhenWhat delivers the mailCost
The agent is workingA 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 idleA 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 idleA 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 startA 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.

The CLI

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

VerbWhat 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, deleteEdit and delete work on your own messages, within 5 minutes.
status [id]Receipts: . queued, v delivered, vv seen, ! failed, x expired.
whoLists 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.
deliverThe typing daemon. It needs the orca backend and runs in its own terminal.
whoami, watch-liveDiagnostics.

Who is notified:

  • the to and cc roles;
  • every registered @role in the text;
  • the author of the message you --re;
  • the thread's subscribers.

A post to a #channel notifies nobody unless it mentions someone. @all is for admins only (user, coordinator, scheduler).

Chat inside Claude Code (/bus)

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

  • A header, for example 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.
  • The recent messages, oldest at the top. ● 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.
  • An expanded message: its full text, recipients and topic, each recipient's receipt, the reactions, and the body. Expanding a message addressed to you marks it read.
  • A one-line composer. A line that starts with @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:

KeyVim region (click it first)Hotkey (pane focused)Action
j / k, ↓ / ↑yesj / kMove the selection
gg / G, Home / EndyesFirst / last message
Ctrl-d / Ctrl-uyesDown / up eight messages
Enter, o, l / hyesoExpand or collapse the selected message (h collapses)
ryesrReply to the selected message
e, then 1-6yese, then 1-6React: ✅ 👀 👍 ❌ 🙏 🎉
i / ayesFocus the composer
xyesxCancel the reply or the emoji row
qyesqClose the pane
EscyesClose 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.

  • No true overlay: a plugin can draw only a pane (inline or docked) and the band above the prompt.
  • No global keybinding opens it: type /bus, or press Open on the band.
  • Raw keys (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).
  • While the composer has the focus, letters type into it rather than acting as keys.
  • The composer is one line: multi-line messages and bodies need /bus send ... --body-file or the full client.
  • No search, filters, pins, edits, deletes or thread view in the pane: use the full client or the web chat.
  • VS Code and mobile have no raw-key region; mobile has no text field, so the pane is read-only there.

Web chat

/bus chat starts the server in the background and prints its URL, by default http://127.0.0.1:8790/. The page shows:

  • threads, mentions, receipts and reactions;
  • pins and edits;
  • a per-role lane list, with in-progress tasks when 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 standalone full-screen client (bus-tui)

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.

Launch

/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
  • Bus directory: --dir, else $AGENT_BUS_DIR, else .claude/bus under the nearest ancestor that holds .git or .claude.
  • Role: --as, else $AGENT_BUS_ROLE, else user (registered on first use, the same way the web chat does).
  • Needs Textual (pip install textual). Without it the launcher prints that hint and exits with status 2.

Layout

  • Tabs (top): All, @me (to you or from you), one tab per #channel, and every thread you open. Unread counts appear on each tab.
  • Tree (left): channels, roles (● 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.
  • Messages (right): threads ordered by last activity, with replies indented under their root. Each row shows ● when unread, the time, the sender, the recipients, #topic, the text, ▸body, 📌, reactions, and receipts on your own messages.
  • Composer (above the statusline): used in insert mode.
  • Statusline:
  • left: the mode, your role, the tab, active filters and search;
  • right: your unread count, then bus health: 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.
  • Toasts: shown when a new message @mentions your role or replies to you.

Normal mode

KeyAction
j / k, ↓ / ↑next / previous message
gg / Gfirst / last message
Ctrl-d / Ctrl-uhalf page down / up (Ctrl-f / Ctrl-b: full page)
Enter / omessage detail: full text, body, receipts per recipient, reactions
i / ainsert mode (write in the composer)
ccompose modal (to, topic, priority, multi-line text)
rreply modal for the selected message
e / +reaction palette: ✅ 👀 👍 ❌ 🙏 🎉 (1-6; picking one you already added removes it)
ppin / unpin
sfollow / unfollow the thread (its replies then reach your inbox)
topen the selected message's thread in a tab
xclose the thread tab
H / L, gT / gtprevious / next tab
Tabmove 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 / Nnext / previous match
:command line
ycopy the message id
Escclear the search, then the filters
?help
Spaceleader (shows the which-key popup)

Receipts use the bus CLI's symbols: . queued, v delivered, vv seen, ! failed, x expired.

Insert mode

KeyAction
Entersend 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.
Escback to normal mode

Leader (Space)

KeyAction
ffuzzy picker over everything: channels, roles, threads, messages
rroles (picking one filters by it; pick it again to clear)
tthreads
bchannels and tabs
ppinned messages
etoggle the tree
ncompose
utoggle the unread filter
@toggle the to-me filter
mmark every message in the view read
Ttheme picker
hhelp
qquit

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

CommandAction
: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 / :unpinpin or unpin the selected message
:sub / :unsubfollow or unfollow the selected message's thread
:edit <text> / :deleteedit 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
:treetoggle the tree
:seenmark the view read
:closeclose the thread tab
:composeopen the compose modal
:helpshow the keymap
:qquit

Themes

tokyonight (default), catppuccin (mocha), and claude (warm dark with an orange #D97757 accent). Pick one with --theme, :theme <name>, or Space T.

Options

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.

OptionDefaultWhat it does
busDir.claude/busThe 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.
rolescoordinatorThe roles init registers.
orcaBusPath(empty)A checkout of orca-bus to use instead of the vendored copy.
pythonpythonThe interpreter.
backendnoneorca lets the deliver daemon type urgent mail and nudges into Orca tabs. none never types.
nudgeAfter300Seconds of unread mail before the daemon's one nudge (orca backend).
monitoronoff stops the "arm a Monitor" requests.
chatPort8790The web chat port.
chatBind127.0.0.1127.0.0.1, localhost or ::1. Loopback only.
tasksDb(empty)A task-tracker db for the chat's lane list.
taskPrefixTASKTask ids with this prefix (TASK-12) become task chips in the chat.
bandonoff hides the unread band above the prompt.
themetokyonightThe /bus pane's colors: tokyonight or claude.

Roles: a session's role is resolved in this order:

  1. AGENT_BUS_ROLE;
  2. ORCA_BUS_NAME;
  3. the bus registry entry for ORCA_TERMINAL_HANDLE;
  4. the role option.

Files

The bus directory (default .claude/bus) holds:

FileWhat it holds
registry.jsonThe roles and their modes
log.jsonlEvery message and delivery state
chat.jsonlReactions, subscriptions, pins and edits
inbox/<role>.jsonlThe role's mail
inbox/<role>.seenWhat the role has read
inbox/<role>.hookposThe hook's cursor
inbox/<role>.watch.jsonThe watch heartbeat
bodies/<id>.mdLong message bodies

Commit the folder or ignore it, as you prefer. It is plain text.

Develop

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

License

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.

Source 3 files
hooks/register.tsx 786 lines
1import { 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}
786
hooks/keys.tsx 38 lines
1import 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
38
types/index.d.ts 73 lines
1/** 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