SLOPSHOPPER

bus-band

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

newbandcommandtoasttimer
v0.1.0MITupdated 2026-10-03mariomosca/agent-team-os/mods/bus-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bus-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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-band ⎿ bus-band: Banda del bus nascosta. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

bus-band

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
  • UI text is in Italian (the agents' working language). Shows only when something is pending; disappears when the inbox is empty.
  • Same scoping as the shell lib (v1.4): agent from 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.
  • Priority is spelled out (URGENTE, ALTA, a !! marker), never colour alone.
  • A toast when a new 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-only: it never moves, archives or writes a message. /read, /inbox and the MCP tools stay the way to act on them.
  • Honours AB_HOME like the shell lib (default ~/.agent-team-os).

Install

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.

Security

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.

Develop

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.

Source 3 files
hooks/register.tsx 131 lines
1import { 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}
131
hooks/bus.ts 69 lines
1import 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}
69
types/index.d.ts 24 lines
1export 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