Band above the Claude Code prompt with this session's pending Agent Team OS messages

A Claude Code mod (Claude Code 2.1.287+) that draws a band above the prompt with the messages waiting for this session on the Agent Team OS bus.
Bus alita/work-hub: 2 in attesa, 1 urgenti o alta (+1 di altri workspace) [Nascondi]
!! [ALTA] da kai: crewmoor-stato-piano-v1
[normale] da vera: content-brief
AGENT_MAP.json by cwd prefix, slug = workspace basename, shared root inboxes/<agent>/ plus inboxes/<agent>/@<slug>/, messages whose context.project_path belongs to another workspace counted apart.URGENTE, ALTA, a !! marker), never colour alone.urgent/high message arrives. Polls every 20 s and at the end of every turn./bus-band toggles it; the Nascondi button hides it until the next new message./read, /inbox and the MCP tools stay the way to act on them.AB_HOME like the shell lib (default ~/.agent-team-os).The mod is its own plugin, separate from the main agent-team-os plugin, so a manual install (hooks registered in settings.json) never gets its hooks registered twice.
With install.sh (manual install): the installer copies it to ~/.claude/mods/bus-band/. Load it in every session by adding the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/bus-band" } }
One session only: claude --plugin-dir /path/to/agent-team-os/mods/bus-band.
Mods are not sandboxed: they run with Claude Code's own access. This one reads ~/.agent-team-os/ and $HOME/AB_HOME, nothing else, and makes no network call. Check with claude plugin validate mods/bus-band, which lists every engine call and env variable the module touches.
claude plugin validate mods/bus-band # manifest, hooks, calls, state contract
claude plugin test mods/bus-band # hooks/bus.test.ts: cwd→agent, slug, scoping, sort
The pure bus logic lives in hooks/bus.ts (mirrors scripts/agent-team-os-lib.sh); hooks/register.tsx holds the hooks and the drawing. Known gap: the shell lib also matches the realpath of the cwd; the mod matches the cwd Claude Code reports, which is already resolved in practice.
hooks/register.tsx 131 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BusMessage, BusSnapshot } from '../types'
5import {
6 PRIORITY_LABEL,
7 agentForCwd,
8 isPressing,
9 parseMessage,
10 slugForCwd,
11 sortMessages,
12 workspaceMatch,
13} from './bus'
14
15const snapshot = atom({ plugin: 'bus-band', key: 'snapshot' } as const, null)
16const isHidden = atom({ plugin: 'bus-band', key: 'isHidden' } as const, false)
17
18const POLL_MS = 20_000
19const SHOWN = 3
20
21const scan = async ($: EngineInterface): Promise<BusSnapshot | null> => {
22 // Same bus home as the shell lib: AB_HOME, else ~/.agent-team-os.
23 const busHome = await $.env.get('AB_HOME')
24 const home = await $.env.get('HOME')
25 if (busHome === undefined && home === undefined) return null
26 const root = busHome ?? `${home}/.agent-team-os`
27 const cwd = await $.session.cwd()
28
29 const map = JSON.parse(await $.fs.read(`${root}/AGENT_MAP.json`))
30 const agent = agentForCwd(map.rules ?? [], cwd)
31 if (agent === undefined) return null
32 const slug = slugForCwd(cwd)
33
34 // Shared root (messages to the bare agent name) plus this session's own dir.
35 const dirs = [`${root}/inboxes/${agent}`, `${root}/inboxes/${agent}/@${slug}`]
36 const messages: BusMessage[] = []
37 let elsewhere = 0
38
39 for (const dir of dirs) {
40 if (!(await $.fs.exists(dir))) continue
41 for (const entry of await $.fs.list(dir)) {
42 if (entry.kind !== 'file' || !/^msg-.*\.json$/.test(entry.name)) continue
43 const parsed = parseMessage(await $.fs.read(`${dir}/${entry.name}`), entry.name)
44 if (parsed === null) continue
45 if (workspaceMatch(parsed.projectPath, cwd) === 'elsewhere') {
46 elsewhere += 1
47 } else {
48 messages.push(parsed.message)
49 }
50 }
51 }
52
53 return { agent, slug, messages: sortMessages(messages), elsewhere }
54}
55
56// Ids already seen, so only a message that arrives later raises a toast.
57let seen: Set<string> | null = null
58
59async function refresh($: EngineInterface) {
60 const next = await scan($)
61 const ids = new Set(next?.messages.map(m => m.id) ?? [])
62 const fresh = seen === null ? [] : (next?.messages ?? []).filter(m => !seen!.has(m.id))
63 seen = ids
64
65 await update($, snapshot, () => next)
66 if (fresh.length > 0) await update($, isHidden, () => false)
67
68 const m = fresh.find(isPressing)
69 if (m !== undefined) {
70 $.ui.toast(`Bus: ${PRIORITY_LABEL[m.priority]} da ${m.from} (${m.intent})`)
71 }
72}
73
74export const register: Register = on => {
75 on('session.start', async ($, e, next) => {
76 await $.command.register({
77 name: 'bus-band',
78 description: 'Mostra o nasconde la banda dei messaggi del bus',
79 })
80 await refresh($).catch(() => {})
81 $.clock.every(POLL_MS, () => refresh($).catch(() => {}))
82
83 return next(e)
84 })
85
86 on('turn.complete', async ($, e, next) => {
87 await refresh($).catch(() => {})
88
89 return next(e)
90 })
91
92 on('command.run', { command: 'bus-band' }, async $ => {
93 const hidden = await read($, isHidden)
94 await update($, isHidden, () => !hidden)
95 await refresh($).catch(() => {})
96
97 return { text: hidden ? 'Banda del bus visibile.' : 'Banda del bus nascosta.' }
98 })
99
100 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
101 const snap = await read($, snapshot)
102 const isQuiet =
103 e.props.hasSurvey || snap === null || snap.messages.length === 0 || (await read($, isHidden))
104
105 if (isQuiet) return next(e)
106
107 const { Box, Button, Text } = $.ui.resolve(e)
108 const pressing = snap.messages.filter(isPressing).length
109 const rest = snap.messages.length - SHOWN
110
111 return (
112 <Box flexDirection="column">
113 <Box>
114 <Text bold>
115 Bus {snap.agent}/{snap.slug}: {snap.messages.length} in attesa
116 {pressing > 0 ? `, ${pressing} urgenti o alta` : ''}
117 {snap.elsewhere > 0 ? ` (+${snap.elsewhere} di altri workspace)` : ''}{' '}
118 </Text>
119 <Button key="hide" label="Nascondi" onPress={() => update($, isHidden, () => true)} />
120 </Box>
121 {snap.messages.slice(0, SHOWN).map(m => (
122 <Text key={m.id} dimColor={!isPressing(m)} wrap="truncate-end">
123 {isPressing(m) ? '!! ' : ' '}[{PRIORITY_LABEL[m.priority]}] da {m.from}: {m.intent}
124 </Text>
125 ))}
126 {rest > 0 ? <Text dimColor> ...altri {rest}, /inbox per la lista</Text> : null}
127 </Box>
128 )
129 })
130}
131hooks/bus.ts 69 lines1import type { BusMessage, Priority } from '../types'
2
3// Pure logic of the Agent Team OS bus, mirrored from
4// ~/.claude/scripts/agent-team-os-lib.sh (v1.4): agent from cwd, session slug,
5// workspace scoping. No `$` here, so the tests run it as is.
6
7export type AgentRule = { pattern: string; match?: string; agent: string }
8
9const PRIORITIES: readonly Priority[] = ['urgent', 'high', 'normal', 'low']
10
11export const agentForCwd = (rules: readonly AgentRule[], cwd: string) =>
12 rules.find(rule => cwd.startsWith(rule.pattern))?.agent
13
14export const slugForCwd = (cwd: string) => {
15 const base = cwd.replace(/\/+$/, '').split('/').pop() || 'root'
16
17 return base.toLowerCase().replace(/[^a-z0-9._-]/g, '-')
18}
19
20export const workspaceMatch = (projectPath: string, cwd: string) => {
21 if (projectPath === '') return 'global'
22 const p = projectPath.replace(/\/+$/, '')
23 const c = cwd.replace(/\/+$/, '')
24 const isHere = p === c || c.startsWith(`${p}/`) || p.startsWith(`${c}/`)
25
26 return isHere ? 'here' : 'elsewhere'
27}
28
29export type Parsed = { message: BusMessage; projectPath: string }
30
31export const parseMessage = (text: string, fallbackId: string): Parsed | null => {
32 try {
33 const raw = JSON.parse(text)
34 const priority = PRIORITIES.includes(raw.priority) ? raw.priority : 'normal'
35
36 return {
37 message: {
38 id: String(raw.id ?? fallbackId),
39 from: String(raw.from ?? '?'),
40 intent: String(raw.intent ?? raw.type ?? ''),
41 priority,
42 type: String(raw.type ?? ''),
43 ts: String(raw.ts ?? ''),
44 },
45 projectPath: typeof raw.context?.project_path === 'string' ? raw.context.project_path : '',
46 }
47 } catch {
48 return null
49 }
50}
51
52// Most pressing first, newest first within a priority.
53export const sortMessages = (messages: readonly BusMessage[]) =>
54 [...messages].sort(
55 (a, b) =>
56 PRIORITIES.indexOf(a.priority) - PRIORITIES.indexOf(b.priority) ||
57 b.ts.localeCompare(a.ts),
58 )
59
60export const isPressing = (m: BusMessage) => m.priority === 'urgent' || m.priority === 'high'
61
62// Text labels, never colour alone: the person reading this is colour-blind.
63export const PRIORITY_LABEL: Record<Priority, string> = {
64 urgent: 'URGENTE',
65 high: 'ALTA',
66 normal: 'normale',
67 low: 'bassa',
68}
69types/index.d.ts 24 lines1export type Priority = 'urgent' | 'high' | 'normal' | 'low'
2
3export type BusMessage = {
4 id: string
5 from: string
6 intent: string
7 priority: Priority
8 type: string
9 ts: string
10}
11
12export type BusSnapshot = {
13 agent: string
14 slug: string
15 messages: BusMessage[]
16 elsewhere: number
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'bus-band': { snapshot: BusSnapshot | null; isHidden: boolean }
22 }
23}
24