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`)
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 | bashSet 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.
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 listAttach 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.
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)- One daemon per machine (launchd
com.<prefix>.ccmux/ systemdccmux.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 launchesclaudeand 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-idfirst,--resumeafter) → 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 perresumePickerin 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
-ntitle 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.
Two levels — a machine default plus an optional per-session override:
- Machine default —
permissionModein the machine config (~/.config/ccmux/config/machine.json). Applies to every session the daemon launches. A personal box typically runsbypassPermissions; a shared/server box staysauto. - Per-session override —
ccmux mode <name> <mode|default>pins one session to a different mode than the box (e.g. box isbypassPermissions, but a client-prod session staysauto).defaultclears the override → the session inherits the machine default again. The mode is a launch-time flag, soccmux restart <name>applies it (a running session keeps whatever it started with — you can't switch intobypassPermissionsat runtime). - Root guard (servers): under a root daemon, escalated modes (
bypassPermissions/dontAsk) are downgraded toautoat 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.
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) orowner(you — Telegram-only, no pane);--task Xpins a pointer. The sender is automatic — a session sends as itself, a shell ascli; 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--afterwatchdog or a queued--defer). Re-arming a conditional message with the same--taskalso 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 (--peekdoesn'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).
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 nextrestart).
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.
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.
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 --noEmitThe 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.
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 publishesPublishing 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.
MIT © ccmux contributors