Agent Switchboard's line: records this Claude Code session and connects it to the other sessions on the switchboard

swb keeps track of every coding-agent session I start, in pi, Claude Code, or Codex, from its first turn until I archive it, across quits, crashes, and reboots, and gets me back into any of them in one keystroke. The sessions can message each other, across harnesses and projects.
Sessions run headless in a dedicated tmux server. swb opens a Deck: a roster of those sessions, showing which ones need me, beside the selected session's real, interactive pi.
swb needs tmux 3.7 or newer and pi 1.2.0 or newer.
swb binary, for linux-x64 or darwin-arm64, from the latest release, or with mise: mise use -g github:thurstonsand/agent-switchboard
pi branch where an npm mirror lags. With pi-sessions, it needs pi-sessions 0.19.0 or later: pi install npm:@thurstonsand/pi-agent-switchboard
pi install git:github.com/thurstonsand/agent-switchboard@pi
swb open a Deck where the last one in this Project left off
swb -c, --continue open a Deck in pi on this Project's most recent session, or a new one
swb new [--cwd DIR] [--harness pi|claude|codex] open a Deck on a new session
swb open ID open a Deck on a session
swb adopt ID|TRANSCRIPT track a pi, Claude Code, or Codex session run outside swb, then open it; quit it first
swb ls [--json] list sessions with derived state
swb dirs [--json] list the directories n offers for a new session
swb archive ID archive a session
swb unarchive ID reopen an archived session
swb wake ID start a dormant session in the background
swb launch --harness H [--cwd DIR] [--from ID] [--title T] start a new session in the background; its goal on stdin
swb send --from ID --to ID [--request-response] [--cancel] message a session, waking it if dormant; the body on stdin
swb runs Claude Code (2.1.300 or later) and Codex CLI (0.149 or later) the way it runs pi: the real TUI, in swb's tmux server, on the roster. Each plugs in through a line that swb ships and loads itself, so there is nothing else to install: Claude Code gets a plugin with a mod, and Codex runs on a private app-server that swb starts with it. n's picker starts pi by default; Tab in it cycles to Claude Code and Codex, and harness in the settings changes the default.
Every managed session's agent gets the same tools, whatever its harness:
session_reachable: every other open session, in any harness or project, live or dormantsession_send_message: a message to one of them, arriving mid-turn when it's working, or starting a turn when it's idle; a dormant one is woken firstsession_cancel: interrupts another session's turnsession_launch: starts a new session in any harness, whose first turn is the goal it's givenswb_update_session: archives or unarchives a sessionMessages wait in swb's db until their session takes them, so one sent to a session that's starting, or whose sender has quit, still arrives. Claude Code takes a message at its next tool call, or as a new turn when idle; pi and Codex steer the running turn. Codex's own /archive archives the session in swb too.
An idle session nobody is viewing stops after reap_after, and an upgrade stops idle sessions; pi and Claude Code get their unsent prompt back on the next wake. Codex has no way for swb to read its prompt box, so a Codex session loses an unsent prompt when it stops.
Inside a managed pi, swb owns coordination between sessions, so pi-sessions keeps only its subagent tools (subagent_launch, subagent_send, and the rest). Subagents still run in their parent's tmux and never become sessions. A pi launched by another session records its parent where pi-sessions sees it, so the lineage survives the session leaving swb.
In the roster, j/k move, Enter focuses the session (waking it if it's dormant), w wakes it in the background, x stops its pi and keeps it open, n picks a directory for a new session, starting from the highlighted one, N adds a directory and starts one there, a archives or unarchives, m picks the session's Group from a list or names a new one (or renames one from its header), / filters, and q quits. M-a is the Deck's prefix from anywhere: M-a h/M-a l move between the roster and the Stage, Tab returns to the pane you left, M-a e swaps pi and the editor and focuses it, M-a v splits them, M-a s opens the directory's shell under pi (ctrl-d closes it), M-a z hides the roster, M-a n/M-a N/M-a j/M-a k and the other roster keys work after it too, M-a q closes the Deck, M-a ? lists the rest. ctrl-h/j/k/l move between panes; with nvim as the editor they walk its windows first and step off its edge into the Deck. Dragging a session onto a Group, or anywhere beneath one, moves it there. Hovering a session, or moving onto it with j/k, opens its Peek beside it: its full title, state, directory and branch, its last turn, and its keys as buttons, which act on that session wherever the cursor is. The Peek goes when the pointer leaves the roster and the Peek, or a key is typed outside the roster. A narrow Deck, with no Stage beside the roster, draws the Peek at the roster's right edge instead. Clicking a session focuses it, and the tabs under the roster (shell among them) and its legend are clickable; any control lights up under the pointer. While the Stage fills the Deck, ◂ at the top brings the roster back.
n's picker lists each repository with its worktrees beneath it, alongside plain directories: those under [dirs], every directory a session is in, and ones added with N. Typing filters it, best match first. M-a d, or the ✕ on the highlighted row, removes a directory from the list, and a repository's worktrees go with it; adding it again with N brings it back. A directory that no longer exists drops off on its own.
The Operators section sits atop the roster: sessions that coordinate the others rather than work in a Project. n on it opens the picker on ~/.config/agent-switchboard/operator, where Tab picks pi, Claude Code, or Codex; that folder's AGENTS.md, which swb keeps current, makes it the dispatcher. An Operator reads only that AGENTS.md, not the user-level one or any above it, and reaches the other sessions through swb's tools. Codex is the exception: it always reads ~/.codex/AGENTS.md. Its new sessions are always session_launches, in whichever harness suits the work, and its subagents are off, since the Deck can't see them. To promote an existing pi session, /mv it into that folder.
${XDG_CONFIG_HOME:-~/.config}/agent-switchboard/config.toml. Unknown keys are an error.
wake_on_highlight = false # true starts a dormant session after the cursor rests 500 ms on its row
harness = "pi" # what n starts: "pi", "claude", or "codex"; Tab in the picker cycles it
editor = "nvim" # defaults to $EDITOR
inactive_after = "72h" # open sessions idle this long move to Inactive
reap_after = "30m" # an idle live session nobody is viewing is stopped after this long
clipboard = "wl-copy" # defaults to wl-copy, else xclip, else pbcopy
roster_width = 42 # columns, or a share of the Deck like "25%"; never more than half
group_by = "project" # "worktree" buckets each Project's ungrouped sessions by worktree branch
[dirs] # what n offers, beside every directory a session is in and ones added with N
scan = ["~/code"] # git checkouts and their worktrees beneath these; none by default
depth = 2 # how far beneath each to look, 1 to 10; a checkout is never entered
include = ["~/notes"] # always offered, git or not
exclude = ["dotfiles"] # like .gitignore: a bare name anywhere, a pattern with a slash under each scan root, or one ~/ path
sort = "name" # "recent" puts directories with the latest session activity first
[keys]
prefix = "M-a"
See DEV.md. The design lives in docs/designs/01-agent-switchboard.md.
MIT
hooks/register.js 338 lines1// Agent Switchboard's Claude Code line, written by swb into its state directory and loaded with --plugin-dir.
2// It records this session's lifecycle, gives Claude the switchboard's coordination tools, and delivers messages from
3// other sessions: mid-turn as context on the next tool result, or as a new turn when idle.
4//
5// claude plugin validate only accepts `$` passed to top-level functions, so every call goes through one.
6import { TOOLS } from "./tools.js";
7
8const line = {
9 managed: false,
10 started: false,
11 bin: "",
12 host: "",
13 parent: "",
14 session: "",
15 cwd: "",
16 transcript: "",
17 /** The session whose start swb has recorded, so a late environment records it once. */
18 recorded: "",
19 busy: false,
20 turnId: "",
21 blocked: false,
22 draft: "",
23 pending: [],
24};
25
26async function env($, name) {
27 if (name === "SWB_BIN") return (await $.env.get("SWB_BIN")) ?? "";
28 if (name === "SWB_TMUX_SESSION") return (await $.env.get("SWB_TMUX_SESSION")) ?? "";
29 if (name === "SWB_PARENT") return (await $.env.get("SWB_PARENT")) ?? "";
30 return (await $.env.get("SWB_DB")) ?? "";
31}
32
33/** Runs swb; a failure carries its stderr, which is what the model or the person should read. */
34async function swb($, args, stdin) {
35 const result = await $.process.run([line.bin, ...args], { ...(stdin === undefined ? {} : { stdin }), timeoutMs: 90000 });
36 if (result.exitCode !== 0) throw new Error(result.stderr.trim().replace(/^swb: /, "") || `swb ${args[0]} exited ${result.exitCode}`);
37 return result.stdout;
38}
39
40/** Recording never fails a hook: it says so where the person looks, and Claude carries on. */
41async function record($, args) {
42 try {
43 return await swb($, ["__record", ...args, "--host", line.host]);
44 } catch (error) {
45 $.ui.toast(`swb: not recording: ${error.message}`);
46 $.ui.log(`swb: not recording: ${error.message}`, { to: "debug" });
47 return "";
48 }
49}
50
51async function ack($, ids) {
52 if (ids.length > 0) await swb($, ["__ack", ...ids]);
53}
54
55function receipt($, m, how) {
56 $.ui.log(`↘ ${m.title} (${how}): ${m.body.length > 200 ? `${m.body.slice(0, 200)}…` : m.body}`);
57}
58
59/** The held messages for the session this process shows now; any others wait for theirs. */
60function take() {
61 const batch = line.pending.filter((m) => m.target === line.session);
62 line.pending = line.pending.filter((m) => m.target !== line.session);
63 return batch;
64}
65
66/**
67 * Starts a turn with what arrived while idle. submit resolves once that turn starts, which is when the batch is
68 * delivered; awaited inside a hook that runs while Claude works, it never would, so callers don't await this.
69 */
70async function submitPending($) {
71 if (line.busy || !line.session) return;
72 const batch = take();
73 if (batch.length === 0) return;
74 try {
75 await $.prompt.submit({ text: batch.map((m) => m.text).join("\n\n---\n\n") });
76 } catch (error) {
77 line.pending.unshift(...batch);
78 $.ui.log(`swb: couldn't start a turn for ${batch.length} message(s): ${error.message}`);
79 return;
80 }
81 for (const m of batch) receipt($, m, "new turn");
82 await ack(
83 $,
84 batch.map((m) => m.id),
85 );
86}
87
88/** Keeps swb's inbox for this session running for the session's life, and says so when it fails. */
89async function pump($) {
90 for (;;) {
91 try {
92 const ended = await drain($);
93 // A signal means the session is ending, and the line with it; anything else is worth saying.
94 if (ended?.signal) return;
95 $.ui.log(`swb: inbox stopped (exit ${ended?.code}); restarting`);
96 } catch (error) {
97 $.ui.log(`swb: inbox failed: ${error.message}; restarting`);
98 }
99 await $.clock.sleep(1000);
100 }
101}
102
103async function drain($) {
104 const inbox = $.process.spawn({ argv: [line.bin, "__inbox", "--host", line.host] });
105 let buffer = "";
106 const iterator = inbox[Symbol.asyncIterator]();
107 for (;;) {
108 const { done, value } = await iterator.next();
109 if (done) return value;
110 const { stream, text } = value;
111 if (stream !== "stdout") {
112 $.ui.log(`swb inbox: ${text}`);
113 continue;
114 }
115 buffer += text;
116 for (let nl = buffer.indexOf("\n"); nl >= 0; nl = buffer.indexOf("\n")) {
117 const raw = buffer.slice(0, nl);
118 buffer = buffer.slice(nl + 1);
119 if (!raw.trim()) continue;
120 const m = JSON.parse(raw);
121 if (m.kind === "cancel") {
122 if (line.busy && line.turnId) await $.turn.abort({ turnId: line.turnId });
123 await ack($, [m.id]);
124 } else {
125 line.pending.push(m);
126 void submitPending($);
127 }
128 }
129 }
130}
131
132async function useTool($, name, e) {
133 // The caller is whoever Claude Code says this session is now, never a value cached from an event that can be missed.
134 const self = await $.session.id();
135 const from = ["--from", self];
136 switch (name) {
137 case "session_reachable":
138 return swb($, ["reachable", ...from]);
139 case "session_send_message":
140 return swb(
141 $,
142 ["send", ...from, "--to", e.session, "--request-id", e.tool_use_id, ...(e.requestResponse ? ["--request-response"] : [])],
143 e.message,
144 );
145 case "session_cancel":
146 return swb($, ["send", ...from, "--to", e.session, "--cancel"]);
147 case "session_launch":
148 return swb(
149 $,
150 [
151 "launch",
152 ...from,
153 "--harness",
154 e.harness,
155 "--cwd",
156 e.cwd ?? line.cwd,
157 "--title",
158 e.title,
159 ...(e.requestResponse ? ["--request-response"] : []),
160 ],
161 e.goal,
162 );
163 default:
164 return swb($, [e.archived ? "archive" : "unarchive", e.session ?? self, ...from]);
165 }
166}
167
168/**
169 * Reads swb's launch environment and the session this process shows, and records that session's start once. Every
170 * hook asks, since none is sure to arrive: another mod can answer classic.SessionStart without passing it on.
171 */
172async function init($, start) {
173 if (!line.managed) {
174 line.bin = await env($, "SWB_BIN");
175 line.host = await env($, "SWB_TMUX_SESSION");
176 line.parent = await env($, "SWB_PARENT");
177 line.managed = Boolean(line.bin && line.host);
178 if (line.managed && !line.started) {
179 line.started = true;
180 void pump($);
181 }
182 }
183 if (!line.managed) return;
184 await follow($, start);
185 await begin($);
186}
187
188/** Moves to the session Claude Code shows now; /clear and /resume change it. classic.SessionStart also names its transcript. */
189async function follow($, start) {
190 const id = start?.session_id ?? (await $.session.id());
191 if (id !== line.session) {
192 // Messages held for a session this process left stay undelivered, for when that session runs again.
193 line.pending = line.pending.filter((m) => m.target === id);
194 line.session = id;
195 line.cwd = start?.cwd ?? (await $.session.cwd());
196 line.transcript = "";
197 line.busy = false;
198 line.blocked = false;
199 }
200 if (start?.transcript_path) line.transcript = start.transcript_path;
201}
202
203/** Records the start of the session this process shows, once. */
204async function begin($) {
205 if (line.recorded === line.session) return;
206 line.recorded = line.session;
207 await record($, ["start", "--session", line.session, "--cwd", line.cwd, "--transcript", line.transcript]);
208 await restoreDraft($);
209 void submitPending($);
210}
211
212/** swb is the only thing that loads this plugin, so its tools are always offered; a call outside swb says so. */
213async function registerTools($) {
214 for (const tool of TOOLS) {
215 try {
216 await $.tool.register(tool);
217 } catch (error) {
218 $.ui.log(`swb: tool ${tool.name} not registered: ${error.message}`);
219 }
220 }
221}
222
223async function draftPath($, id) {
224 const db = await env($, "SWB_DB");
225 return `${db.slice(0, db.lastIndexOf("/"))}/drafts/${id}`;
226}
227
228async function restoreDraft($) {
229 const path = await draftPath($, line.session);
230 if (!(await $.fs.exists(path))) return;
231 const draft = await $.fs.read(path);
232 await $.fs.write(path, "");
233 if (draft) await $.prompt.fill({ text: draft });
234}
235
236async function trackDraft(_$, e, next) {
237 const result = await next(e);
238 line.draft = result.text;
239 return result;
240}
241
242async function saveDraft($) {
243 if (line.draft.trim()) await $.fs.write(await draftPath($, line.session), line.draft);
244}
245
246export function register(on) {
247 on("session.start", async ($, e, next) => {
248 const result = await next(e);
249 await registerTools($);
250 await init($);
251 return result;
252 });
253
254 // Fires at startup and again after /clear and /resume, which move this process to another session.
255 on("classic.SessionStart", async ($, e, next) => {
256 await init($, e);
257 return next(e);
258 });
259
260 on("turn.start", async ($, e, next) => {
261 await init($);
262 if (line.managed && !e.agentId) {
263 line.busy = true;
264 line.turnId = e.turnId;
265 // The launcher is recorded with the first turn, which makes the session's row.
266 const parent = line.parent ? ["--parent", line.parent] : [];
267 line.parent = "";
268 await record($, ["turn", "--session", line.session, "--cwd", line.cwd, "--transcript", line.transcript, ...parent]);
269 }
270 return next(e);
271 });
272
273 on("classic.PermissionRequest", async ($, e, next) => {
274 if (line.managed && line.busy && !line.blocked) {
275 line.blocked = true;
276 await record($, ["phase", "blocked", "--session", line.session]);
277 }
278 return next(e);
279 });
280
281 // A model request means the permission prompt was answered.
282 on("turn.step", async function* ($, e, next) {
283 if (line.managed && !e.agentId && line.blocked) {
284 line.blocked = false;
285 await record($, ["phase", "working", "--session", line.session]);
286 }
287 return yield* next(e);
288 });
289
290 on("turn.complete", async ($, e, next) => {
291 const result = await next(e);
292 if (line.managed && !e.agentId) {
293 line.busy = false;
294 line.blocked = false;
295 // swb stops this session after recording, when it was archived during the turn.
296 await record($, ["settled", "--session", line.session, "--cwd", line.cwd, "--transcript", line.transcript]);
297 void submitPending($);
298 }
299 return result;
300 });
301
302 on("tool.call", async ($, e, next) => {
303 const tool = TOOLS.find((t) => e.tool === `mcp__swb__${t.name}`);
304 if (tool) {
305 await init($);
306 if (!line.managed) return { deny: "this Claude Code session isn't on the switchboard: swb didn't start it" };
307 try {
308 return { result: JSON.parse(await useTool($, tool.name, e)).text };
309 } catch (error) {
310 return { deny: error.message };
311 }
312 }
313 const result = await next(e);
314 // Messages that arrived mid-turn ride on the next main-loop tool result, which Claude reads before going on.
315 if (!line.managed || e.agentId || next.origin.plugin !== "engine" || !("result" in result)) return result;
316 const batch = take();
317 if (batch.length === 0) return result;
318 for (const m of batch) receipt($, m, "mid-turn");
319 await ack(
320 $,
321 batch.map((m) => m.id),
322 );
323 return { ...result, context: [...(result.context ?? []), ...batch.map((m) => m.text)] };
324 });
325
326 // A typed edit raises prompt.edit; a submit, a clear, a recall, or a fill raises prompt.change.
327 on("prompt.edit", trackDraft);
328 on("prompt.change", trackDraft);
329
330 on("session.end", async ($, e, next) => {
331 if (line.managed && e.reason !== "clear" && e.reason !== "resume") {
332 await saveDraft($);
333 await record($, ["end"]);
334 }
335 return next(e);
336 });
337}
338