SLOPSHOPPER

tmuxaxxing

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

newguardprocess
v0.1.0no licenseupdated 2026-10-26kolezka/tmuxaxxing/mod
A shopper browsing a rack in a slop shop
README

tmuxaxxing

tmux sidebars for running many Claude Code sessions at once. Inspired by tmux-agent-status.

  • Agents sidebar (left): every Claude Code session with its state, and an INBOX of sessions that are asking for permission or have finished since you last looked. Approve or reject a permission prompt from the sidebar without switching to the session.
  • GitHub sidebar (right): review requests, your open PRs with CI state, and PRs assigned to you.
  • Slack toggle: one key switches to an always-running slk and back.
  • Plugins: any sidebar slot can hold your own feed script or TUI.

Works in any terminal that runs tmux (Ghostty, iTerm2 and others).

Requirements

  • tmux 3.8 (older versions are untested), Bun 1.4, Claude Code 2.1.310 or newer
  • Optional: gh (logged in) for the GitHub sidebar, slk for Slack

Install

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

Keys

KeyDoes
prefix aShow or hide the agents sidebar
prefix gShow or hide the GitHub sidebar
prefix RReload sidebars after editing layout.toml
prefix sSwitch 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.

Layout and plugins

~/.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.

Development

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.

Source 2 files
hooks/register.ts 116 lines
1import 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};
116
hooks/transitions.ts 80 lines
1// 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