SLOPSHOPPER

swb

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

newguardprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · swb
› 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 ⏺ Read(/work/app/src/auth.ts) ⎿ Denied by swb: this Claude Code session isn't on the switchboard: swb didn't start it ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Agent 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.

Install

swb needs tmux 3.7 or newer and pi 1.2.0 or newer.

  1. The swb binary, for linux-x64 or darwin-arm64, from the latest release, or with mise:
   mise use -g github:thurstonsand/agent-switchboard
  1. The pi line, swb's pi extension, from npm, or from this repo's generated 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

Use

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

Harnesses

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 dormant
  • session_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 first
  • session_cancel: interrupts another session's turn
  • session_launch: starts a new session in any harness, whose first turn is the goal it's given
  • swb_update_session: archives or unarchives a session

Messages 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.

With pi-sessions

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.

Operators

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.

Settings

${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"

Development

See DEV.md. The design lives in docs/designs/01-agent-switchboard.md.

License

MIT

Source 1 files
hooks/register.js 338 lines
1// 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