SLOPSHOPPER

switchboard

See and message your other Claude Code and Codex sessions: who is working on what, direct messages, broadcast, handoffs, an inbox that holds incoming messages…

newpaneguardcommandtoastmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · switchboard
│ ┃ Switchboard ✕ › fix the failing auth test and add an audit log call │ ┃ ● … app │ ┃ ⏺ Read(src/auth.ts) │ ┃ This repo · 0 ⎿ Read 6 lines │ ┃ No other session is working here. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ incoming: hold-other-repos Refresh ⏺ 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 │ │ › /switchboard │ ⎿ switchboard: Switchboard opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Switchboard
● … app This repo · 0 No other session is working here. incoming: hold-other-repos Refresh
README

switchboard

See and talk to your other Claude Code sessions from inside the one you're in.

/plugin install switchboard --marketplace bbarney7/claude-code-mods

Claude Code can already message other sessions on the same machine: its SendMessage tool reaches any session that ListAgents lists. Switchboard adds the parts for you as the human: a view of every session, messages you send yourself, an inbox that controls what reaches Claude, and warnings when two sessions edit the same file.

What it does

/switchboardA pane listing every running session: busy or idle, and for sessions that also run Switchboard, their repository, branch, Syntaur ticket and the file they last edited. Each one has a Message button.
/dm <session> <message>Message a session yourself. Its replies show up in the pane.
Codex sessionsThe pane also lists your recent Codex sessions (from Codex's local app-server daemon). Message fills /dm codex:<id> …, which sends through codex queue: an open Codex session answers right away, a closed one gets it the next time it's opened. Needs Codex 0.162 or later.
/broadcast <message>Message every session that runs Switchboard ("main is frozen for 10 minutes").
/handoff <session>Claude writes a handoff of this session (goal, done, in progress, decisions, open problems, next step) and sends it to another session.
InboxMessages from other sessions can wait in the pane until you choose Pass to Claude, Reply or Dismiss. A held message is never shown to Claude.
Collision warningsBefore Claude edits a file, Switchboard checks whether another session edited the same file recently: "⚠ src/auth/session.ts was edited by run-0f (2m ago)".

Settings (/config)

SettingValuesDefault
Messages from other sessionsdeliver (Claude Code's normal behavior), hold-other-repos (deliver from sessions in the same repository, hold the rest), hold (hold everything)hold-other-repos
When another session edited the same filewarn (warning, edit goes ahead), ask (ask before the edit; Cancel refuses it and tells Claude to coordinate), offwarn
How recent counts as a collisionminutes30

Why hold messages?

A message from another session arrives in Claude's conversation much like a prompt. A session that is confused, or working from a poisoned web page or file, could tell your other sessions what to do. Holding messages from sessions outside the current repository by default means a stranger session can't steer this one without you seeing it first.

How it works

  • The session list comes from Claude Code's own ListAgents tool, called by the mod.
  • Presence (repository, branch, ticket, busy or idle, recently edited files) is shared through the mod's store. Sessions running Switchboard see each other's; other sessions show just their name and state. No files are written outside Claude Code's own storage.
  • Messages go through Claude Code's cross-session messaging ($.session.send), the same channel SendMessage uses. Incoming ones are recognised by their <cross-session-message from-name="…"> envelope.

Limits

  • Codex: one way, and no live status. Messages go to Codex, but Codex's replies stay in that Codex session. A Codex terminal session doesn't tell the daemon it's open, so the pane shows when each one was last active rather than open or closed.
  • Edits only through Claude's editing tools count. Edits made through Edit, Write, MultiEdit and NotebookEdit are tracked. Edits made by shell commands (sed -i, scripts) are not.
  • Branch and ticket only appear for sessions running Switchboard. Other sessions show only what ListAgents reports.
  • Session names are Claude Code's. They're often generated from the folder (shared-56), and the pane shows them as they are.

Development

claude --plugin-dir plugins/switchboard
claude plugin validate plugins/switchboard
claude plugin test plugins/switchboard
Source 2 files
hooks/register.tsx 553 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import type { CodexThread, Note, PeerRow, Presence, Touch } from '../types'
5
6const PANE = 'switchboard'
7const NOTES_CAP = 60
8const TOUCH_KEEP_MS = 2 * 60 * 60 * 1000
9const STALE_MS = 6 * 60 * 60 * 1000
10
11const meAtom = atom({ plugin: 'switchboard', key: 'me' } as const, '')
12const peersAtom = atom({ plugin: 'switchboard', key: 'peers' } as const, [])
13const presenceAtom = atom({ plugin: 'switchboard', key: 'presence' } as const, [])
14const notesAtom = atom({ plugin: 'switchboard', key: 'notes' } as const, [])
15const codexAtom = atom({ plugin: 'switchboard', key: 'codex' } as const, [])
16
17// ---------- settings ----------
18
19export type Incoming = 'deliver' | 'hold-other-repos' | 'hold'
20export type Collisions = 'warn' | 'ask' | 'off'
21export type Config = { incoming: Incoming; collisions: Collisions; collisionMinutes: number }
22export const DEFAULT_CONFIG: Config = { incoming: 'hold-other-repos', collisions: 'warn', collisionMinutes: 30 }
23
24export function readConfig(options: Readonly<Record<string, unknown>>): Config {
25  const incoming = options.incoming as Incoming
26  const collisions = options.collisions as Collisions
27  const minutes = typeof options.collisionMinutes === 'number' && options.collisionMinutes > 0 ? options.collisionMinutes : DEFAULT_CONFIG.collisionMinutes
28  return {
29    incoming: ['deliver', 'hold-other-repos', 'hold'].includes(incoming) ? incoming : DEFAULT_CONFIG.incoming,
30    collisions: ['warn', 'ask', 'off'].includes(collisions) ? collisions : DEFAULT_CONFIG.collisions,
31    collisionMinutes: Math.min(24 * 60, Math.round(minutes)),
32  }
33}
34
35// ---------- pure helpers ----------
36
37// ListAgents answers with a listing whose first line names this session and structured rows for the rest.
38export function parseListing(result: unknown): { me: string; peers: PeerRow[] } {
39  const r = (result ?? {}) as { listing?: string; sections?: { kind?: string; rows?: PeerRow[] }[] }
40  const me = /This session is (\S+)/.exec(r.listing ?? '')?.[1] ?? ''
41  const rows = (r.sections ?? []).filter(s => s.kind === 'sessions').flatMap(s => s.rows ?? [])
42  return { me, peers: rows.filter(row => row && typeof row.name === 'string') }
43}
44
45// A message from another session arrives as <cross-session-message from-name="…">body</cross-session-message>.
46export function parseEnvelope(text: string): { from: string; body: string } | undefined {
47  const m = /<cross-session-message\b([^>]*)>\n?([\s\S]*?)\n?<\/cross-session-message>/.exec(text)
48  if (!m) return undefined
49  const from = /from-name="([^"]*)"/.exec(m[1] ?? '')?.[1] ?? ''
50  return from ? { from, body: (m[2] ?? '').trim() } : undefined
51}
52
53export function shouldHold(policy: Incoming, senderRoot: string | undefined, myRoot: string): boolean {
54  if (policy === 'deliver') return false
55  if (policy === 'hold') return true
56  return !senderRoot || senderRoot !== myRoot
57}
58
59// Other sessions' edits to this path within the window, newest first.
60export function collisionsFor(path: string, others: readonly Presence[], now: number, windowMs: number): { name: string; at: number }[] {
61  return others
62    .flatMap(p => p.touched.filter(t => t.path === path && now - t.at <= windowMs).map(t => ({ name: p.name, at: t.at })))
63    .sort((a, b) => b.at - a.at)
64}
65
66export function addTouch(touched: readonly Touch[], path: string, now: number): Touch[] {
67  return [{ path, at: now }, ...touched.filter(t => t.path !== path && now - t.at <= TOUCH_KEEP_MS)].slice(0, 50)
68}
69
70// "/dm run-c1 pause before you push" -> peer + text
71// /dm's target, read against the sessions listed: a full name (names can have spaces, so the
72// longest one that prefixes the args wins), a session's [ref], or a Codex `codex:` handle.
73// A name two sessions share is sent as `name [ref]`, the spelling SendMessage takes for that.
74export function resolveDm(args: string, peers: readonly PeerRow[]): { peer: string; text: string } | { error: string } {
75  const rest = args.trim().replace(/^@/, '')
76  if (rest.startsWith('codex:')) return parseDm(rest)
77  const target = (p: PeerRow) => (peers.filter(q => q.name === p.name).length > 1 ? `${p.name} [${p.ref}]` : p.name)
78  const byLength = [...peers].sort((a, b) => b.name.length - a.name.length)
79  const spellings = [...byLength.map(p => ({ spelled: `${p.name} [${p.ref}]`, peer: `${p.name} [${p.ref}]` })), ...byLength.map(p => ({ spelled: p.name, peer: target(p) }))]
80  for (const { spelled, peer } of spellings) {
81    if (rest === spelled) return { error: 'Usage: /dm <session> <message>' }
82    if (rest.startsWith(`${spelled} `)) return { peer, text: rest.slice(spelled.length).trim() }
83  }
84  const [first = '', ...words] = rest.split(/\s+/)
85  const byRef = peers.find(p => p.ref && (first === p.ref || first === `[${p.ref}]`))
86  if (byRef) return words.length ? { peer: target(byRef), text: words.join(' ') } : { error: 'Usage: /dm <session> <message>' }
87  return parseDm(rest)
88}
89
90export function parseDm(args: string): { peer: string; text: string } | { error: string } {
91  const [peer = '', ...rest] = args.trim().split(/\s+/)
92  const text = rest.join(' ').trim()
93  if (!peer || !text) return { error: 'Usage: /dm <session> <message>' }
94  return { peer: peer.replace(/^@/, ''), text }
95}
96
97// Codex sessions go by `codex:` and the end of their id in /dm, since their names have spaces.
98export function codexHandle(id: string): string {
99  return `codex:${id.slice(-8)}`
100}
101
102export function findCodex(peer: string, threads: readonly CodexThread[]): CodexThread | undefined {
103  if (!peer.startsWith('codex:')) return undefined
104  const tail = peer.slice('codex:'.length)
105  return tail ? threads.find(t => t.id.endsWith(tail)) : undefined
106}
107
108// Working ones first, then the most recently active. (A Codex terminal session doesn't report
109// itself to the daemon as open, so there is no reliable open/closed to show.)
110export function codexToShow(threads: readonly CodexThread[], max = 5): CodexThread[] {
111  const open = (t: CodexThread) => t.status !== 'notLoaded'
112  return [...threads].sort((a, b) => Number(open(b)) - Number(open(a)) || b.updatedAt - a.updatedAt).slice(0, max)
113}
114
115export function ago(ms: number): string {
116  const s = Math.max(0, Math.round(ms / 1000))
117  if (s < 60) return `${s}s ago`
118  if (s < 3600) return `${Math.round(s / 60)}m ago`
119  return `${Math.round(s / 3600)}h ago`
120}
121
122export function branchFromHead(head: string): string | undefined {
123  const m = /^ref: refs\/heads\/(.+)$/m.exec(head.trim())
124  if (m) return m[1]
125  return head.trim() ? head.trim().slice(0, 8) : undefined
126}
127
128// ---------- module state (starts over on a reload) ----------
129
130let config: Config = DEFAULT_CONFIG
131let me = ''
132let root = ''
133let mine: Presence | undefined
134let seq = 0
135let ticker: { cancel: () => void } | undefined
136const held = new Map<string, string>()
137
138// ---------- presence and the session list ----------
139
140async function refreshPeers($: EngineInterface) {
141  try {
142    const call = (await $.tool.call({ tool: 'ListAgents' } as never)) as { result?: unknown }
143    const { me: name, peers } = parseListing(call.result)
144    if (name) me = name
145    await update($, meAtom, () => me)
146    await update($, peersAtom, () => peers)
147  } catch (error) {
148    $.ui.log(`switchboard: could not list sessions: ${String(error)}`, { to: 'debug' })
149  }
150  await readPresence($)
151}
152
153let codexError: string | undefined
154
155// Codex's sessions come from its local daemon through a small helper; no Codex, no section.
156async function refreshCodex($: EngineInterface) {
157  try {
158    const run = await $.process.run(['node', `${$.plugin.root}/bin/codex-sessions.mjs`, '15'])
159    const out = JSON.parse(run.stdout.trim() || '{}') as { threads?: CodexThread[]; error?: string }
160    codexError = out.error
161    await update($, codexAtom, () => out.threads ?? [])
162  } catch (error) {
163    codexError = String(error)
164    await update($, codexAtom, () => [])
165  }
166}
167
168async function refreshAll($: EngineInterface) {
169  await refreshPeers($)
170  await refreshCodex($)
171}
172
173// Puts a /dm line in Claude's prompt box. The box can refuse (the app draws its own, or a dialog
174// holds the keys); then a toast says so and shows the line to type.
175async function fillDm($: EngineInterface, target: string) {
176  const line = `/dm ${target} `
177  const filled = (await $.prompt.fill({ text: line }).catch(() => ({ isFilled: false, refusal: undefined }))) as { isFilled: boolean; refusal?: string }
178  $.ui.log(`switchboard: prompt.fill ${filled.isFilled ? 'ok' : `refused (${filled.refusal ?? 'unknown'})`}`, { to: 'debug' })
179  if (!filled.isFilled) $.ui.toast(`Couldn't fill the prompt box${filled.refusal ? ` (${filled.refusal})` : ''}. Type: ${line}<message>`)
180}
181
182async function readPresence($: EngineInterface) {
183  const now = await $.clock.now()
184  const keys = (await $.store.keys()).filter(k => k.startsWith('presence:'))
185  const live = new Set((await read($, peersAtom)).map(p => p.name))
186  const found: Presence[] = []
187  for (const key of keys) {
188    const value = (await $.store.get(key)) as Presence | undefined
189    if (!value || value.name === me) continue
190    // Forget sessions that ended without cleaning up.
191    if (!live.has(value.name) && now - value.updatedAt > STALE_MS) {
192      await $.store.delete(key)
193      continue
194    }
195    if (live.has(value.name)) found.push(value)
196  }
197  await update($, presenceAtom, () => found)
198}
199
200async function locate($: EngineInterface): Promise<Pick<Presence, 'root' | 'branch' | 'ticket'>> {
201  root = await $.session.root()
202  let branch: string | undefined
203  try {
204    // A worktree's .git is a file pointing at its real git dir; a normal repo's is the directory itself.
205    let gitDir = `${root}/.git`
206    if (!(await $.fs.exists(`${gitDir}/HEAD`))) {
207      const pointer = /^gitdir: (.+)$/m.exec(await $.fs.read(gitDir))?.[1]
208      if (pointer) gitDir = pointer.startsWith('/') ? pointer : `${root}/${pointer}`
209    }
210    branch = branchFromHead(await $.fs.read(`${gitDir}/HEAD`))
211  } catch {}
212  let ticket: string | undefined
213  try {
214    const context = JSON.parse(await $.fs.read(`${root}/.syntaur/context.json`)) as Record<string, unknown>
215    const named = Object.entries(context).find(([key, value]) => /ticket|assignment/i.test(key) && typeof value === 'string')
216    ticket = named ? String(named[1]) : undefined
217  } catch {}
218  return { root, branch, ticket }
219}
220
221async function publish($: EngineInterface, change: Partial<Presence>) {
222  if (!me) return
223  const now = await $.clock.now()
224  mine = { ...(mine ?? { name: me, root, state: 'idle', touched: [], updatedAt: now }), ...change, name: me, updatedAt: now }
225  await $.store.set(`presence:${me}`, mine)
226}
227
228// ---------- messages ----------
229
230async function note($: EngineInterface, entry: Omit<Note, 'id' | 'at'>): Promise<Note> {
231  seq += 1
232  const full: Note = { ...entry, id: `n${seq}-${await $.clock.now()}`, at: await $.clock.now() }
233  await update($, notesAtom, list => [...list, full].slice(-NOTES_CAP))
234  return full
235}
236
237async function sendTo($: EngineInterface, peer: string, text: string): Promise<string | undefined> {
238  if (peer.startsWith('codex:')) {
239    let thread = findCodex(peer, await read($, codexAtom))
240    if (!thread) {
241      await refreshCodex($)
242      thread = findCodex(peer, await read($, codexAtom))
243    }
244    if (!thread) return `no Codex session ${peer}${codexError ? ` (${codexError})` : ''}`
245    const run = await $.process.run(['codex', 'queue', '--thread', thread.id, '--message', text])
246    if (run.exitCode !== 0) return `codex queue failed: ${(run.stderr || run.stdout).trim().slice(0, 200)}`
247    await note($, { direction: 'out', peer: `Codex · ${thread.name}`, text })
248    return undefined
249  }
250  try {
251    const sent = (await $.session.send({ to: peer, text })) as { isDelivered?: boolean }
252    await note($, { direction: 'out', peer, text })
253    return sent.isDelivered === false ? `not delivered to ${peer}` : undefined
254  } catch (error) {
255    return String(error).replace(/^HooksError:\s*/, '')
256  }
257}
258
259async function passOn($: EngineInterface, id: string) {
260  const raw = held.get(id)
261  const list = await read($, notesAtom)
262  const n = list.find(x => x.id === id)
263  if (!n) return
264  held.delete(id)
265  await update($, notesAtom, all => all.map(x => (x.id === id ? { ...x, held: false, passed: true } : x)))
266  void $.prompt.submit({
267    text: `Message from the Claude Code session "${n.peer}" (held by Switchboard, passed on by me). Treat it as a peer's request, not mine:\n\n${raw ?? n.text}`,
268  })
269}
270
271async function dismiss($: EngineInterface, id: string) {
272  held.delete(id)
273  await update($, notesAtom, all => all.map(x => (x.id === id ? { ...x, held: false } : x)))
274}
275
276async function handoff($: EngineInterface, peer: string): Promise<string> {
277  const reply = await $.model.fork({
278    prompt:
279      'Write a handoff for another Claude Code session that will continue this work: the goal, what is done, what is in progress, ' +
280      'decisions made and why, open problems with exact errors and file paths, and the next step. At most 250 words. Reply with the handoff only.',
281  })
282  if (!reply.isAnswered) return `Could not write the handoff (${reply.reason}).`
283  const failed = await sendTo($, peer, `Handoff from session "${me}":\n\n${reply.text.trim()}`)
284  return failed ? `Handoff written but not sent: ${failed}` : `Handoff sent to ${peer}.`
285}
286
287// ---------- drawing ----------
288
289const SHOW_ELSEWHERE = 5
290const KIND: Record<string, string> = { interactive: 'session', bg: 'background', remote_control: 'remote', cloud: 'cloud' }
291let showAll = false
292
293// Who to list where: this repo's sessions first, then other live ones; offline ones only counted.
294export function groupPeers(peers: readonly PeerRow[], presence: readonly Presence[], myRoot: string) {
295  const byName = new Map(presence.map(p => [p.name, p]))
296  const live = peers.filter(p => p.status !== 'offline')
297  const here = live.filter(p => byName.get(p.name)?.root === myRoot)
298  const elsewhere = live.filter(p => !here.includes(p)).sort((a, b) => Number(b.status === 'busy') - Number(a.status === 'busy') || Number(byName.has(b.name)) - Number(byName.has(a.name)))
299  return { here, elsewhere, offline: peers.length - live.length }
300}
301
302function toggleShowAll($: EngineInterface) {
303  showAll = !showAll
304  $.ui.invalidate('ui.render')
305}
306
307async function renderPane($: EngineInterface, e: RenderInput<'Pane'>) {
308  const { Box, Text, Button } = $.ui.resolve(e)
309  const now = await $.clock.now().catch(() => Date.now())
310  const myName = await read($, meAtom)
311  const peers = await read($, peersAtom)
312  const presence = await read($, presenceAtom)
313  const notes = await read($, notesAtom)
314  const byName = new Map(presence.map(p => [p.name, p]))
315  const inbox = notes.filter(n => n.held)
316  const recent = notes.filter(n => !n.held).slice(-5)
317  const { here, elsewhere, offline } = groupPeers(peers, presence, root)
318  const codex = codexToShow(await read($, codexAtom))
319  const shown = showAll ? elsewhere : elsewhere.slice(0, SHOW_ELSEWHERE)
320  const repoName = (path: string) => path.split('/').pop() ?? path
321
322  const row = (peer: PeerRow) => {
323    const p = byName.get(peer.name)
324    const touch = p?.touched[0]
325    const detail =
326      peer.status === 'busy'
327        ? 'working…'
328        : touch
329          ? `edited ${touch.path.replace(`${p?.root ?? ''}/`, '')} · ${ago(now - touch.at)}`
330          : p
331            ? [p.root === root ? undefined : repoName(p.root), p.branch, p.ticket].filter(Boolean).join(' · ') || 'idle'
332            : KIND[peer.type] ?? peer.type
333    return (
334      <Box key={peer.name} width="100%" justifyContent="space-between" alignItems="center" marginTop={1}>
335        <Box flexDirection="column" flexShrink={1}>
336          <Box gap={1}>
337            <Text color={peer.status === 'busy' ? 'green' : 'gray'}>●</Text>
338            <Text bold>{peer.name}</Text>
339          </Box>
340          <Text dimColor wrap="truncate-end">{`  ${detail}`}</Text>
341        </Box>
342        <Button key={`dm-${peer.ref || peer.name}`} label="Message" variant="secondary" dimColor onPress={() => fillDm($, peers.filter(q => q.name === peer.name).length > 1 ? `${peer.name} [${peer.ref}]` : peer.name)} />
343      </Box>
344    )
345  }
346
347  return (
348    <Box flexDirection="column" width="100%">
349      <Box gap={1}>
350        <Text color="green">●</Text>
351        <Text bold>{myName || '…'}</Text>
352        <Text dimColor>{[root ? repoName(root) : '', mine?.branch, mine?.ticket].filter(Boolean).join(' · ')}</Text>
353      </Box>
354
355      {inbox.length > 0 && (
356        <Text bold color="yellow" >{`Inbox · ${inbox.length}`}</Text>
357      )}
358      {inbox.map(n => (
359        <Box key={n.id} flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginTop={1}>
360          <Box gap={1}>
361            <Text bold>{n.peer}</Text>
362            <Text dimColor>{ago(now - n.at)}</Text>
363          </Box>
364          <Text>{n.text.slice(0, 600)}</Text>
365          <Box gap={1} marginTop={1}>
366            <Button key={`pass-${n.id}`} label="Pass to Claude" variant="primary" onPress={() => passOn($, n.id)} />
367            <Button key={`reply-${n.id}`} label="Reply" variant="secondary" onPress={() => fillDm($, n.peer)} />
368            <Button key={`dismiss-${n.id}`} label="Dismiss" variant="secondary" dimColor onPress={() => dismiss($, n.id)} />
369          </Box>
370        </Box>
371      ))}
372
373      <Box marginTop={1}>
374        <Text bold>{`This repo · ${here.length}`}</Text>
375      </Box>
376      {here.length === 0 && <Text dimColor>No other session is working here.</Text>}
377      {here.map(row)}
378
379      {elsewhere.length > 0 && (
380        <Box marginTop={1}>
381          <Text bold>{`Other sessions · ${elsewhere.length}`}</Text>
382        </Box>
383      )}
384      {shown.map(row)}
385      {elsewhere.length > SHOW_ELSEWHERE && (
386        <Button key="more" label={showAll ? 'Show fewer' : `Show ${elsewhere.length - SHOW_ELSEWHERE} more`} plain dimColor onPress={() => toggleShowAll($)} />
387      )}
388
389      {codex.length > 0 && (
390        <Box marginTop={1}>
391          <Text bold>{`Codex · ${codex.length}`}</Text>
392        </Box>
393      )}
394      {codex.map(t => (
395        <Box key={`codex-${t.id}`} width="100%" justifyContent="space-between" alignItems="center" marginTop={1}>
396          <Box flexDirection="column" flexShrink={1}>
397            <Box gap={1}>
398              <Text color={t.status === 'active' ? 'green' : 'gray'}>●</Text>
399              <Text bold wrap="truncate-end">{t.name}</Text>
400            </Box>
401            <Text dimColor wrap="truncate-end">
402              {`  ${[repoName(t.cwd), t.status === 'active' ? 'working…' : `active ${ago(now - t.updatedAt)}`].filter(Boolean).join(' · ')}`}
403            </Text>
404          </Box>
405          <Button key={`dm-${t.id}`} label="Message" variant="secondary" dimColor onPress={() => fillDm($, codexHandle(t.id))} />
406        </Box>
407      ))}
408
409      {recent.length > 0 && (
410        <Box marginTop={1}>
411          <Text bold>Messages</Text>
412        </Box>
413      )}
414      {recent.map(n => (
415        <Box key={n.id} gap={1}>
416          <Text dimColor>{n.direction === 'out' ? '→' : '←'}</Text>
417          <Text bold>{n.peer}</Text>
418          <Text dimColor wrap="truncate-end">{`${n.text.replace(/\s+/g, ' ').slice(0, 140)}${n.passed ? ' · passed on' : ''}`}</Text>
419        </Box>
420      ))}
421
422      <Box width="100%" justifyContent="space-between" alignItems="center" marginTop={1}>
423        <Text dimColor>{`${offline ? `${offline} offline hidden · ` : ''}incoming: ${config.incoming}`}</Text>
424        <Button key="refresh" label="Refresh" plain dimColor onPress={() => refreshAll($)} />
425      </Box>
426    </Box>
427  )
428}
429
430function startTicker($: EngineInterface) {
431  if (ticker) return
432  ticker = $.clock.every(20_000, () => void refreshAll($))
433}
434
435// ---------- registration ----------
436
437// Each command is also declared in commands/, so a host lists it as `/switchboard:<name>` before
438// any session exists (the desktop app's empty chat); the hooks answer both spellings.
439export const register: Register = (on, options) => {
440  config = readConfig(options)
441
442  on('session.start', async ($, e, next) => {
443    const started = await next(e)
444    await Promise.allSettled([
445      $.command.register({ name: 'switchboard', description: 'Open the switchboard: your other Claude Code sessions, messages and inbox' }),
446      $.command.register({ name: 'dm', description: 'Message another Claude Code session', argumentHint: '<session> <message>' }),
447      $.command.register({ name: 'broadcast', description: 'Message every session running Switchboard', argumentHint: '<message>' }),
448      $.command.register({ name: 'handoff', description: 'Send a handoff of this session to another session', argumentHint: '<session>' }),
449    ])
450    const place = await locate($)
451    await refreshPeers($)
452    await publish($, { ...place, state: 'idle', touched: [] })
453    return started
454  })
455
456  on('session.end', async ($, e, next) => {
457    if (me) await $.store.delete(`presence:${me}`).catch(() => undefined)
458    ticker?.cancel()
459    return next(e)
460  })
461
462  on('turn.start', async ($, e, next) => {
463    void publish($, { state: 'busy' })
464    return next(e)
465  })
466
467  on('turn.complete', async ($, e, next) => {
468    const done = await next(e)
469    void publish($, { state: 'idle' })
470    return done
471  })
472
473  // Before an edit: has another session touched this file recently? After it: publish the touch.
474  on('tool.call', async ($, e, next) => {
475    const input = e as unknown as { tool: string; file_path?: string; notebook_path?: string }
476    const path = input.file_path ?? input.notebook_path
477    if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(input.tool) || !path) return next(e)
478
479    if (config.collisions !== 'off') {
480      await readPresence($)
481      const now = await $.clock.now()
482      const hits = collisionsFor(path, await read($, presenceAtom), now, config.collisionMinutes * 60_000)
483      if (hits.length > 0) {
484        const who = hits.map(h => `${h.name} (${ago(now - h.at)})`).join(', ')
485        const short = path.replace(`${root}/`, '')
486        if (config.collisions === 'ask') {
487          const answer = await $.ui.ask(`Another session edited ${short} recently: ${who}. Edit it anyway?`, ['Edit anyway', 'Cancel']).catch(() => 'Edit anyway')
488          if (answer === 'Cancel') return { deny: `Switchboard: ${who} edited ${short} recently; the user cancelled this edit. Coordinate with that session first (SendMessage).` }
489        } else {
490          $.ui.toast(`⚠ ${short} was edited by ${who}`)
491        }
492      }
493    }
494    const result = await next(e)
495    const now = await $.clock.now()
496    void publish($, { touched: addTouch(mine?.touched ?? [], path, now) })
497    return result
498  }).catch(($, e, next) => next(e))
499
500  on('session.receive', async ($, e, next) => {
501    // The envelope, not the origin kind, is what marks another session's message (the kind reads 'peer' in practice).
502    const envelope = parseEnvelope(e.text)
503    if (!envelope) return next(e)
504    await readPresence($)
505    const sender = (await read($, presenceAtom)).find(p => p.name === envelope.from)
506    if (shouldHold(config.incoming, sender?.root, root)) {
507      const n = await note($, { direction: 'in', peer: envelope.from, text: envelope.body, held: true })
508      held.set(n.id, e.text)
509      $.ui.toast(`📨 ${envelope.from} sent a message (held in /switchboard)`)
510      return { consumed: 'held by Switchboard for approval' }
511    }
512    await note($, { direction: 'in', peer: envelope.from, text: envelope.body })
513    return next(e)
514  }).catch(($, e, next) => next(e))
515
516  on('command.run', { command: ['switchboard', 'switchboard:switchboard'] }, async $ => {
517    await refreshAll($)
518    startTicker($)
519    await $.ui.open({ id: PANE, title: 'Switchboard' })
520    return { text: 'Switchboard opened.' }
521  })
522
523  on('command.run', { command: ['dm', 'switchboard:dm'] }, async ($, e) => {
524    if ((await read($, peersAtom)).length === 0) await refreshPeers($)
525    const parsed = resolveDm(e.args, await read($, peersAtom))
526    if ('error' in parsed) return { text: parsed.error }
527    const failed = await sendTo($, parsed.peer, parsed.text)
528    return { text: failed ? `Not sent: ${failed}` : `Sent to ${parsed.peer}.` }
529  })
530
531  on('command.run', { command: ['broadcast', 'switchboard:broadcast'] }, async ($, e) => {
532    const text = e.args.trim()
533    if (!text) return { text: 'Usage: /broadcast <message>' }
534    await refreshPeers($)
535    const targets = (await read($, presenceAtom)).map(p => p.name)
536    if (targets.length === 0) return { text: 'No other session is running Switchboard.' }
537    const failures: string[] = []
538    for (const peer of targets) {
539      const failed = await sendTo($, peer, text)
540      if (failed) failures.push(`${peer}: ${failed}`)
541    }
542    return { text: `Sent to ${targets.length - failures.length} of ${targets.length} sessions.${failures.length ? `\n${failures.join('\n')}` : ''}` }
543  })
544
545  on('command.run', { command: ['handoff', 'switchboard:handoff'] }, async ($, e) => {
546    const peer = e.args.trim().replace(/^@/, '')
547    if (!peer) return { text: 'Usage: /handoff <session>' }
548    return { text: await handoff($, peer) }
549  })
550
551  on('ui.render', { component: 'Pane', requestId: 'switchboard' }, ($, e) => renderPane($, e))
552}
553
types/index.d.ts 31 lines
1export type PeerRow = { name: string; ref: string; type: string; status: string }
2export type Touch = { path: string; at: number }
3// What each session running Switchboard publishes in the shared store, under `presence:<name>`.
4export type Presence = {
5  name: string
6  root: string
7  branch?: string
8  ticket?: string
9  state: 'busy' | 'idle'
10  touched: Touch[]
11  updatedAt: number
12}
13export type Note = {
14  id: string
15  at: number
16  direction: 'in' | 'out'
17  peer: string
18  text: string
19  held?: boolean
20  passed?: boolean
21}
22
23// A Codex session, from Codex's local app-server daemon (bin/codex-sessions.mjs).
24export type CodexThread = { id: string; name: string; cwd: string; status: string; updatedAt: number }
25
26declare module 'claude-code' {
27  interface PluginState {
28    switchboard: { me: string; peers: PeerRow[]; presence: Presence[]; notes: Note[]; codex: CodexThread[] }
29  }
30}
31