Skip to content

max-listov/ccmux

Repository files navigation

ccmux

Persistent, self-healing Claude Code sessions in tmux — across a fleet of machines.

A single daemon per machine keeps a fleet of long-running agent sessions alive in tmux: it heals crashed ones, brings them back on reboot, and resumes the same conversation by a pinned uuid. Sessions are full interactive claude processes (your subscription, Remote Control, slash-commands, statusline) — ccmux supervises them, it does not reimplement them.

┌─ daemon (launchd/systemd) ─ heals every 30s, self-updates ─┐
│   tmux: cc-api   cc-web   cc-infra   …   (each = `ccmux _run` → claude, auto-restart)
└────────────────────────────────────────────────────────────┘
        ▲ ccmux list / new / attach / send / restart …          ▲ interactive TUI (bare `ccmux`)

Install

One command — installs bun if missing, downloads the latest verified bundle, drops a ccmux shim on your PATH, and starts a self-updating daemon:

curl -fsSL https://github.com/max-listov/ccmux/releases/latest/download/install.sh | bash

Set the boot label / RC prefix with CCMUX_RC_PREFIX=prod (default local). Re-running is safe — it just refreshes to the latest release. Requires macOS (launchd) or Linux (systemd) and tmux.

Use

ccmux                      # interactive fleet TUI (add -f for fullscreen)
ccmux list                 # managed sessions + live status/uptime
ccmux new cc-api ~/code/api   # create + start a session (pins a fresh uuid)
ccmux send cc-api '/compact'  # type into a session (text or a /slash command)
ccmux restart cc-api       # bounce it (survives killing the caller)
ccmux mode cc-api auto     # per-session permission-mode override (see Permissions)
ccmux stop|start|rm cc-api # lifecycle (rm keeps the jsonl history)
ccmux transcript cc-api --json --tail 50   # conversation history as JSON
ccmux doctor               # health check: bins, config, daemon
ccmux help                 # full command list

Attach to a session like any tmux pane: tmux attach -t cc-api (detach with Ctrl-b d), or press Enter on it in the TUI.

Adopt an external session

A claude you started by hand (outside ccmux) shows up in the TUI under external. Adopt it to let the daemon manage it:

ccmux adopt <uuid> --fork       # safe copy under a new uuid (original untouched)
ccmux adopt <uuid> --takeover   # take over the original (kills the live writer)

How it works

  • One daemon per machine (launchd com.<prefix>.ccmux / systemd ccmux.service) heals the fleet every 30s and starts it on boot. It runs the prod bundle, not your source.
  • Each session is a tmux session whose foreground process is ccmux _run <name> — a tiny supervisor loop that launches claude and relaunches it on crash (exponential backoff). So an agent crash just comes back; killing a session is the only way to stop it.
  • Deterministic resume: every session pins a fixed uuid (--session-id first, --resume after) → no session-selection picker, no accidental second conversation. Claude 2.1.x adds a separate blocking "Resume from summary?" prompt for large/old sessions that would strand an unattended (daemon-healed) resume at a menu — input then lands on the menu, not the conversation. The supervisor auto-answers it per resumePicker in the machine config: full = resume full, keep ALL context (default) · summary = resume compacted · off = leave it for a human.
  • Follow the fork: Claude itself does NOT keep a uuid forever — running out of context forks the conversation to a new session id (new jsonl, old tail copied). Each heal pass detects that the conversation moved (the fork inherits the session's -n title in its first lines) and re-pins the registry, so previews, transcripts and the next restart follow the live conversation instead of a dead file.
  • jsonl is the source of truth for the conversation (transcript, tokens, "where it stopped"); the pane is scraped only for live status.

Permissions

Two levels — a machine default plus an optional per-session override:

  • Machine defaultpermissionMode in the machine config (~/.config/ccmux/config / machine.json). Applies to every session the daemon launches. A personal box typically runs bypassPermissions; a shared/server box stays auto.
  • Per-session overrideccmux mode <name> <mode|default> pins one session to a different mode than the box (e.g. box is bypassPermissions, but a client-prod session stays auto). default clears the override → the session inherits the machine default again. The mode is a launch-time flag, so ccmux restart <name> applies it (a running session keeps whatever it started with — you can't switch into bypassPermissions at runtime).
  • Root guard (servers): under a root daemon, escalated modes (bypassPermissions/dontAsk) are downgraded to auto at launch — whether they came from the machine default or a session override — so a config edit can't hand a server session host-wide power.

Modes match claude --permission-mode: auto, plan, acceptEdits, manual, dontAsk, bypassPermissions.

Inter-agent chat

Opt-in messaging between managed sessions, so one agent can hand off to another without you relaying.

  • ccmux chat on <name> — enable chat for a session (default OFF; nothing sends or receives until it's on, for both ends).
  • ccmux msg <to|owner> "<text>" — message another session (delivered to its pane) or owner (you — Telegram-only, no pane); --task X pins a pointer. The sender is automatic — a session sends as itself, a shell as cli; there is no --from. The body may also come from stdin (echo "…" | ccmux msg <to>). Flags: --defer (hold until the recipient voluntarily finishes its turn — never mid-work), --after <sec> (deliver no sooner than N seconds from now — a timer; a self-watchdog should use bare --after, not --after --defer), --on-behalf-of <who> (relay someone's authority honestly, without spoofing the sender; router/cli only).
  • ccmux msg cancel <task> — drop your still-undelivered mail for a task (an armed --after watchdog or a queued --defer). Re-arming a conditional message with the same --task also replaces the prior undelivered one automatically, so watchdogs don't pile up.
  • ccmux inbox [name] — read a session's still-undelivered messages and mark them read (--peek doesn't). It's the fallback for held/offline mail, not an archive — a message already pushed into a pane isn't here.
  • ccmux chat log [-n N] — the append-only ledger (the full debug log).

Delivery. The daemon push-delivers each message into the recipient's pane as its next turn, tagged [chat from <name>] so the agent treats it as a peer, not you. It never injects while the recipient is at a selection menu (that would pick an option it didn't choose) or while a human is attached; a busy recipient just gets it queued at its next turn boundary. Delivery is two-track: immediate mail flows in order, while deferred (--defer) and time-delayed (--after) mail is delivered by id when its condition holds — a Claude Stop hook fires a deferred message the instant the turn ends, or the daemon delivers it once the target is stably idle — so a pending conditional message never blocks an immediate reply behind it. Loop/rate guards cap a runaway ping-pong. Source of truth: ~/.ccmux-chat.jsonl (+ ~/.ccmux-chat-cursors.json, ~/.ccmux-chat-ack.jsonl).

Router sessions

A router is a session with a built-in manager protocol — it takes a follow-up you dictate, routes it to the right session, waits, validates the result, re-asks on a gap, and escalates to you only when genuinely stuck (never nagging "continue?").

  • ccmux new <name> <dir> --router — create a router (enables chat + carries the protocol).
  • ccmux router on|off <name> — promote/demote an existing session (applies on next restart).

It delivers every follow-up with --defer (so targets are never interrupted), carries your authority with --on-behalf-of owner, and arms a --after watchdog per dispatch — so a target that finishes but forgets to report back doesn't strand it: the timer wakes the router, it checks the target's transcript, and closes or escalates on its own. The protocol lives in code (promptModules), resolved fresh at each launch, so an update reaches every router on its next restart.

Telegram mirror (one-way). Add to machine.json to forward every message to a bot — a group, a DM, or a forum topic:

"telegram": { "botToken": "<@BotFather token>", "chatId": "<group/DM id>", "topicId": 42 }

topicId is optional. Absent → no mirroring (fail-soft). It's outbound only — ccmux sends to Telegram, never reads from it.

Updates

Releases are born only from tags, in CI. bun run release X.Y.Z "notes" is the one release entrypoint: it refuses a dirty tree, runs the full check, bumps the version, rolls the [Unreleased] CHANGELOG section, commits, tags vX.Y.Z and pushes. The CI workflow then re-runs the gate (typecheck + tests + a smoke run of the BUILT bundle), verifies the tag matches package.json, builds the assets and publishes the GitHub Release atomically — so the tag always points at exactly the code the fleet receives, and a red check means the release physically cannot happen. There is no local publish path.

Fleet-side, both update paths share one safe core: download → sha256-verify against the manifest → preflight (bun <candidate> version must load and report the right version) → atomic swap of the prod bundle (.bak kept) → bounce the daemon. Sessions survive the bounce (tmux is independent of the daemon); each picks up the new code on its next restart. A boot-guard reverts to .bak if a bad bundle crash-loops the daemon.

ccmux update             # update now to the latest published release
ccmux update --check     # is there a newer version?
ccmux update --rollback  # revert to the previous bundle (.bak)

With autoUpdate on (wired at install via --release-url), the daemon checks every updateCheckInterval seconds (default 300) and applies a newer release on its own — hands-off across the whole fleet.

Develop

ccmux is a Bun + TypeScript app; the TUI is Ink (React → terminal).

bun install
bun run dev            # run the CLI/TUI from source (this is `ccmux-dev`)
bun run smoke          # headless TUI e2e in a throwaway tmux pane
bun test               # tests
bun run typecheck      # tsc --noEmit

The dev source and the prod daemon are decoupled — editing source never touches the running prod bundle. See docs/architecture/ for the TUI, IO/perf model, and dev flow.

Build & release

The release tooling lives in the source checkout only — clients ship a single bundle, no repo:

bun run stage                   # build → ~/.ccmux/staged/ccmux.js, then `ccmux update` to test locally
bun run release X.Y.Z "notes"   # the ONE release entrypoint: guards → check → bump + CHANGELOG
                                # → commit → tag vX.Y.Z → push; CI builds, gates and publishes

Publishing happens only in CI (.github/workflows/ci.yml), off the tag: gate (typecheck + tests + a smoke run of the built bundle) → tag==version guard → assets → atomic GitHub Release. A release is a tag vX.Y.Z with three assets: the ccmux.js bundle, a release.json manifest (version + sha256 + versioned bundle url), and install.sh. Tags are immutable, and the tag always points at exactly the commit the assets were built from. The fleet tracks releases/latest/download/release.json.

License

MIT © ccmux contributors

About

Persistent, self-healing Claude Code sessions in tmux — deterministic resume, fleet self-update.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages