Writes this Claude Code session's state for the tmuxaxxing sidebars

tmux sidebars for running many Claude Code sessions at once. Inspired by tmux-agent-status.
slk and back.Works in any terminal that runs tmux (Ghostty, iTerm2 and others).
gh (logged in) for the GitHub sidebar, slk for Slackgit clone https://github.com/kolezka/tmuxaxxing.git
cd tmuxaxxing
./scripts/install.sh
The script installs dependencies and prints the lines to add to ~/.tmux.conf. It never edits your config. Sourcing tmux/tmuxaxxing.tmux.conf sets status-right, status-interval and the window-status formats, so put any of your own overrides after it. Sessions report their state through a Claude Code mod, so start Claude with claude --plugin-dir <repo>/mod, or add <repo>/mod to CLAUDE_CODE_PLUGIN_DIRS.
| Key | Does |
|---|---|
prefix a | Show or hide the agents sidebar |
prefix g | Show or hide the GitHub sidebar |
prefix R | Reload sidebars after editing layout.toml |
prefix s | Switch to Slack and back (replaces tmux's session picker on that key) |
In a sidebar: j/k move, Enter opens the session or item, r refreshes, Tab switches between plugins in one sidebar. In the agents sidebar: y approves once, x rejects, n jumps to the next INBOX item.
Approving from the sidebar presses the key in the real Claude Code dialog. It only ever sends "yes, once" or "no", and sends nothing if the question changed or the dialog is no longer on screen.
~/.config/tmuxaxxing/layout.toml decides which plugin sits where:
[left]
plugins = ["agents"]
width = 32
[right]
plugins = ["github-prs"] # several plugins become tabs
width = 36
A plugin is a folder in ~/.config/tmuxaxxing/plugins/<name>/ with a plugin.toml. A plugin with the same name as a built-in one replaces it.
Feed plugin: a command that prints JSON. The sidebar draws it and handles keys and refresh.
kind = "feed"
command = "./feed.sh"
refresh = "2m"
{"sections":[{"title":"TODO","items":[
{"id":"t1","icon":"●","color":"work","text":"Write README","meta":"today",
"action":"open https://example.com"}]}]}
Colors: work, ask, done, error, dim, info. Invalid output, a non-zero exit or a timeout shows as an error row, and the last good data stays on screen marked stale.
TUI plugin: your own program, which gets the whole pane. It must be the only plugin in its slot.
kind = "tui"
command = "my-tui"
It receives TMUXAXXING_SLOT, TMUXAXXING_WIDTH and TMUXAXXING_STATE_DIR. Agent state lives in $TMUXAXXING_STATE_DIR/agents/<pane-id>.json.
bun test
bunx tsc --noEmit -p .
Design and plans are in docs/superpowers/. Manual checks for the tmux behaviour are in docs/manual-checks.md.
hooks/register.ts 116 lines1import type { EngineInterface, Register } from "claude-code";
2
3import { nextRecord, summaryOf, type AgentRecord, type ModEvent } from "./transitions";
4
5// The session's state file and its last record; reset on reload, and session.start runs again then.
6type Target = { file: string; record: AgentRecord | null };
7
8let target: Target | null = null;
9let queue: Promise<void> = Promise.resolve();
10
11async function stateDir($: EngineInterface): Promise<string> {
12 const override = await $.env.get("TMUXAXXING_STATE_DIR");
13 if (override) return override;
14 const home = await $.env.get("HOME");
15 if (!home) throw new Error("HOME is not set");
16 return `${home}/.cache/tmuxaxxing`;
17}
18
19async function run($: EngineInterface, argv: string[]): Promise<string> {
20 const r = await $.process.run(argv);
21 if (r.exitCode !== 0) throw new Error(`${argv[0]} exited ${r.exitCode}: ${r.stderr.trim()}`);
22 return r.stdout;
23}
24
25async function writeRecord($: EngineInterface, file: string, record: AgentRecord): Promise<void> {
26 const tmp = `${file}.tmp`;
27 await $.fs.write(tmp, `${JSON.stringify(record)}\n`);
28 await run($, ["mv", tmp, file]);
29}
30
31// Logs and swallows, so a mod failure never reaches the session.
32async function guard($: EngineInterface, what: string, work: () => Promise<void>): Promise<void> {
33 try {
34 await work();
35 } catch (error) {
36 const message = error instanceof Error ? error.message : String(error);
37 $.ui.log(`tmuxaxxing: ${what} failed: ${message}`, { to: "debug" });
38 }
39}
40
41// Serialized so parallel tool calls apply in order.
42function apply($: EngineInterface, ev: ModEvent): Promise<void> {
43 const step = queue.then(() =>
44 guard($, ev.kind, async () => {
45 if (!target) return;
46 const next = nextRecord(target.record, ev);
47 if (next === null || next === target.record) return;
48 await writeRecord($, target.file, next);
49 target.record = next;
50 }),
51 );
52 queue = step;
53 return step;
54}
55
56async function startSession($: EngineInterface, cwd: string): Promise<void> {
57 target = null;
58 const pane = await $.env.get("TMUX_PANE");
59 if (!pane) return;
60 const out = await run($, ["tmux", "display", "-p", "-t", pane, "#{session_name}\t#{window_name}\t#{pane_pid}"]);
61 const [session = "", window = "", pidText = ""] = out.replace(/\n$/, "").split("\t");
62 const pid = Number(pidText);
63 if (!Number.isInteger(pid) || pid <= 0) throw new Error(`bad pane_pid from tmux: ${JSON.stringify(out)}`);
64 const dir = `${await stateDir($)}/agents`;
65 await run($, ["mkdir", "-p", dir]);
66 target = { file: `${dir}/${pane}.json`, record: null };
67 await apply($, { kind: "session.start", paneId: pane, session, window, cwd, pid, now: Date.now() });
68}
69
70async function endSession($: EngineInterface): Promise<void> {
71 await queue;
72 const file = target?.file;
73 target = null;
74 if (file) await run($, ["rm", "-f", file]);
75}
76
77export const register: Register = (on) => {
78 on("session.start", async ($, e, next) => {
79 const result = await next(e);
80 await guard($, "session.start", () => startSession($, e.cwd));
81 return result;
82 }).catch(($, e, next) => next(e));
83
84 // Written before next so a fast turn cannot land done before working.
85 on("turn.start", async ($, e, next) => {
86 if (!e.agentId) await apply($, { kind: "turn.start", text: e.text, now: Date.now() });
87 return next(e);
88 }).catch(($, e, next) => next(e));
89
90 on("tool.check", async ($, e, next) => {
91 const verdict = await next(e);
92 if (verdict.decision === "ask" && e.approver === "person" && !e.agentId && e.tool_use_id) {
93 const summary = summaryOf(e.tool, e.input);
94 await apply($, { kind: "ask", requestId: e.tool_use_id, tool: e.tool, summary, now: Date.now() });
95 }
96 return verdict;
97 }).catch(($, e, next) => next(e));
98
99 on("tool.call", async ($, e, next) => {
100 const result = await next(e);
101 if (!e.agentId && e.tool_use_id) await apply($, { kind: "tool.done", requestId: e.tool_use_id, now: Date.now() });
102 return result;
103 }).catch(($, e, next) => next(e));
104
105 on("turn.complete", async ($, e, next) => {
106 const result = await next(e);
107 if (!e.agentId) await apply($, { kind: "turn.complete", now: Date.now() });
108 return result;
109 }).catch(($, e, next) => next(e));
110
111 on("session.end", async ($, e, next) => {
112 await guard($, "session.end", () => endSession($));
113 return next(e);
114 }).catch(($, e, next) => next(e));
115};
116hooks/transitions.ts 80 lines1// Pure state machine for the agent record. No mod API imports, so bun test can load it.
2// The record shape mirrors AgentRecord in src/agents/state.ts; the tests parse every output with it.
3
4export type AgentState = "idle" | "working" | "ask" | "done";
5
6export type AgentQuestion = { requestId: string; tool: string; summary: string };
7
8export type AgentRecord = {
9 paneId: string;
10 session: string;
11 window: string;
12 cwd: string;
13 pid: number;
14 state: AgentState;
15 title?: string;
16 question?: AgentQuestion;
17 updatedAt: number;
18};
19
20export type ModEvent =
21 | { kind: "session.start"; paneId: string; session: string; window: string; cwd: string; pid: number; now: number }
22 | { kind: "turn.start"; text: string; now: number }
23 | { kind: "ask"; requestId: string; tool: string; summary: string; now: number }
24 | { kind: "tool.done"; requestId: string; now: number }
25 | { kind: "turn.complete"; now: number };
26
27const TITLE_MAX = 60;
28const SUMMARY_MAX = 120;
29
30function cut(text: string, max: number): string {
31 return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
32}
33
34export function titleOf(text: string): string {
35 const line = text.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
36 return cut(line, TITLE_MAX);
37}
38
39function stringField(input: unknown, key: string): string | undefined {
40 if (typeof input !== "object" || input === null) return undefined;
41 const value = (input as Record<string, unknown>)[key];
42 return typeof value === "string" && value.length > 0 ? value : undefined;
43}
44
45export function summaryOf(tool: string, input: unknown): string {
46 const text = (tool === "Bash" ? stringField(input, "command") : undefined) ?? stringField(input, "file_path") ?? tool;
47 return cut(text, SUMMARY_MAX);
48}
49
50// Drops the question so non-ask states never carry one.
51function withoutQuestion(record: AgentRecord): AgentRecord {
52 const { question: _dropped, ...rest } = record;
53 return rest;
54}
55
56export function nextRecord(prev: AgentRecord | null, ev: ModEvent): AgentRecord | null {
57 if (ev.kind === "session.start") {
58 const { paneId, session, window, cwd, pid, now } = ev;
59 return { paneId, session, window, cwd, pid, state: "idle", updatedAt: now };
60 }
61 if (prev === null) return null;
62
63 switch (ev.kind) {
64 case "turn.start":
65 return { ...withoutQuestion(prev), state: "working", title: titleOf(ev.text), updatedAt: ev.now };
66 case "ask":
67 return {
68 ...prev,
69 state: "ask",
70 question: { requestId: ev.requestId, tool: ev.tool, summary: ev.summary },
71 updatedAt: ev.now,
72 };
73 case "tool.done":
74 if (prev.state !== "ask" || prev.question?.requestId !== ev.requestId) return prev;
75 return { ...withoutQuestion(prev), state: "working", updatedAt: ev.now };
76 case "turn.complete":
77 return { ...withoutQuestion(prev), state: "done", updatedAt: ev.now };
78 }
79}
80