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…

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.
/switchboard | A 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 sessions | The 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. |
| Inbox | Messages 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 warnings | Before 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)". |
/config)| Setting | Values | Default |
|---|---|---|
| Messages from other sessions | deliver (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 file | warn (warning, edit goes ahead), ask (ask before the edit; Cancel refuses it and tells Claude to coordinate), off | warn |
| How recent counts as a collision | minutes | 30 |
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.
ListAgents tool, called by the mod.$.session.send), the same channel SendMessage uses. Incoming ones are recognised by their <cross-session-message from-name="…"> envelope.sed -i, scripts) are not.ListAgents reports.shared-56), and the pane shows them as they are.claude --plugin-dir plugins/switchboard
claude plugin validate plugins/switchboard
claude plugin test plugins/switchboardhooks/register.tsx 553 lines1import { 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}
553types/index.d.ts 31 lines1export 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