Some QOL on claude-mux

This commit is contained in:
2026-07-26 17:18:50 +02:00
parent 55ae88c2d4
commit f7088d9f81
8 changed files with 524 additions and 11 deletions
+34 -3
View File
@@ -22,6 +22,7 @@ a session there are exactly two chords plus a detach, under the `C-x` prefix:
| Key | Action |
| ------- | ----------------------------------------------------------------- |
| `C-x l` | Float a picker of **every** Claude session for this project |
| `C-x r` | Float the remote-control (`claude rc`) toggle for this project |
| `C-x n` | Start a fresh Claude session in a new window (current keeps going) |
| `C-x d` | Detach — everything keeps running in the background |
| `C-x C-x` | Send a literal `C-x` through to Claude |
@@ -44,9 +45,9 @@ session it shows:
- a status glyph:
- **●** green — *running* (Claude is working),
- **?** amber — *waiting for you* (Claude asked something / needs attention),
- **○** blue — *open* (idle in a window, ready for input),
- **·** dim — *closed* (transcript only, not open in any window);
- **⬤** amber — *waiting for you* (Claude asked something / needs attention),
- **⬤** blue — *open* (idle in a window, ready for input),
- **◯** dim — *closed* (transcript only, not open in any window);
- the session title (Claude's AI-generated title, falling back to the last prompt),
- the message count and how long ago it was last active.
@@ -72,6 +73,35 @@ idle one resumes it (`claude --resume`) in a new window. With `ctrl+a` (or
`claude-mux list --all`) the picker shows sessions from every project, labelled by
project; resuming one there opens (or switches to) that project's session.
### Remote control (`C-x r`)
[`claude rc`](https://claude.com/claude-code) (remote-control) runs a persistent
server that lets you drive local sessions from claude.ai/code or the Claude mobile
app. `C-x r` floats a small popup to manage it **per project**:
| Key | Action |
| ----------- | ------------------------------------------------- |
| `t` / space | Toggle the rc server on/off for this project |
| `s` / enter | Switch the client to the running rc session |
| `q` / `esc` | Close the popup |
Toggling it on starts `claude rc` in its own dedicated, background tmux session
(so it never spawns a stray Claude window and never clutters the `C-x l` picker)
and records the project in an **rc-enabled list**. Toggling it off kills that
session and removes the project from the list.
`claude rc` is launched non-interactively so it never stalls the background
session on a prompt: it runs with `--spawn=same-dir` (skipping the spawn-mode
chooser), and the project is pre-trusted in Claude's `.claude.json`
(`hasTrustDialogAccepted`) so it does not block on the workspace trust dialog.
Enabling remote control for a project is itself the decision to trust it — there
is no global setting to skip that dialog, so claude-mux records it per project.
The enabled list is persisted (under the state dir, `rc-projects`). Whenever the
isolated tmux server first cold-starts, claude-mux automatically brings the rc
server back up for **every** rc-enabled project, so your remote-control endpoints
are always available without having to open each project by hand.
## How it works
- **Sessions** are read straight from Claude's transcript storage
@@ -98,6 +128,7 @@ claude-mux Start or attach the session for the current directory
claude-mux list Interactive session picker (used by the C-x l chord)
claude-mux list --all Picker across every project (also toggled with ctrl+a)
claude-mux list --dump Print the session listing as plain text (scripting/debug)
claude-mux rc Remote-control toggle popup (used by the C-x r chord)
claude-mux new Start a fresh Claude session in the current directory
claude-mux kill Kill the running sessions for the current directory
claude-mux kill --all Kill every running session across all projects
+86
View File
@@ -0,0 +1,86 @@
// Package claude touches Claude Code's own config file (.claude.json) for the
// narrow bits claude-mux needs to launch `claude rc` non-interactively.
package claude
import (
"encoding/json"
"os"
"path/filepath"
"claude-mux/internal/paths"
)
// configJSONPath is Claude Code's per-user state file.
func configJSONPath() string {
return filepath.Join(paths.ClaudeConfigDir(), ".claude.json")
}
// EnsureTrusted marks dir as workspace-trusted in Claude's .claude.json.
//
// Claude refuses to start `claude rc` in a directory whose trust dialog has not
// been accepted (it errors with "Workspace not trusted" and exits), and there is
// no global setting or flag to skip that dialog — per-project
// `hasTrustDialogAccepted` is the only mechanism. Enabling remote control for a
// project through claude-mux is an explicit decision to trust it, so we record
// that here.
//
// It is a no-op (no write at all) when the directory is already trusted, which
// keeps it out of the way of Claude's own frequent writes to this file in the
// common case; only the first enable of a never-trusted project writes.
func EnsureTrusted(dir string) error {
path := configJSONPath()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
data = []byte("{}")
} else if err != nil {
return err
}
// Preserve every top-level key (and every per-project key) verbatim by
// keeping them as raw JSON; only the one trust flag is touched.
var root map[string]json.RawMessage
if err := json.Unmarshal(data, &root); err != nil {
return err
}
if root == nil {
root = map[string]json.RawMessage{}
}
var projects map[string]map[string]json.RawMessage
if raw, ok := root["projects"]; ok {
if err := json.Unmarshal(raw, &projects); err != nil {
return err
}
}
if projects == nil {
projects = map[string]map[string]json.RawMessage{}
}
proj := projects[dir]
if proj == nil {
proj = map[string]json.RawMessage{}
}
if v, ok := proj["hasTrustDialogAccepted"]; ok && string(v) == "true" {
return nil // already trusted: nothing to write (and no race with Claude)
}
proj["hasTrustDialogAccepted"] = json.RawMessage("true")
projects[dir] = proj
projRaw, err := json.Marshal(projects)
if err != nil {
return err
}
root["projects"] = projRaw
out, err := json.MarshalIndent(root, "", " ")
if err != nil {
return err
}
// Write atomically so a concurrent Claude reader never sees a half-file.
tmp := path + ".claude-mux.tmp"
if err := os.WriteFile(tmp, out, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
+98
View File
@@ -0,0 +1,98 @@
// Package rc persists which project directories have a Claude remote-control
// (`claude rc`) server enabled. The list lets claude-mux bring every enabled
// project's rc server back up automatically when the isolated server first
// starts.
package rc
import (
"bufio"
"os"
"path/filepath"
"sort"
"strings"
"claude-mux/internal/paths"
)
// listPath is the file holding the newline-separated absolute project
// directories that have rc enabled.
func listPath() string {
return filepath.Join(paths.StateDir(), "rc-projects")
}
// List returns the project directories with rc enabled, in stable order.
func List() []string {
f, err := os.Open(listPath())
if err != nil {
return nil
}
defer f.Close()
var dirs []string
seen := make(map[string]bool)
sc := bufio.NewScanner(f)
for sc.Scan() {
dir := strings.TrimSpace(sc.Text())
if dir == "" || seen[dir] {
continue
}
seen[dir] = true
dirs = append(dirs, dir)
}
return dirs
}
// IsEnabled reports whether rc is enabled for dir.
func IsEnabled(dir string) bool {
for _, d := range List() {
if d == dir {
return true
}
}
return false
}
// Enable records dir as rc-enabled (no-op if already present).
func Enable(dir string) error {
dirs := List()
for _, d := range dirs {
if d == dir {
return nil
}
}
return write(append(dirs, dir))
}
// Disable removes dir from the rc-enabled list (no-op if absent).
func Disable(dir string) error {
dirs := List()
kept := dirs[:0]
for _, d := range dirs {
if d != dir {
kept = append(kept, d)
}
}
return write(kept)
}
// write persists the (deduplicated, sorted) directory list.
func write(dirs []string) error {
if err := os.MkdirAll(paths.StateDir(), 0o755); err != nil {
return err
}
uniq := make([]string, 0, len(dirs))
seen := make(map[string]bool)
for _, d := range dirs {
if d == "" || seen[d] {
continue
}
seen[d] = true
uniq = append(uniq, d)
}
sort.Strings(uniq)
data := strings.Join(uniq, "\n")
if data != "" {
data += "\n"
}
return os.WriteFile(listPath(), []byte(data), 0o644)
}
+88 -3
View File
@@ -13,6 +13,7 @@ import (
"strings"
"syscall"
"claude-mux/internal/claude"
"claude-mux/internal/paths"
)
@@ -103,7 +104,11 @@ func writeConfig(binPath, socket string) (string, error) {
set -g prefix C-x
set -g prefix2 None
set -g status off
set -g mouse off
# Claude Code probes these on startup (tmux show -Av mouse / -gv focus-events)
# and nags with a "tmux detected ..." hint whenever they are not "on": mouse on
# gives it wheel scrollback, focus-events on lets it see terminal focus changes.
set -g mouse on
set -g focus-events on
set -g escape-time 10
set -g base-index 1
setw -g pane-base-index 1
@@ -137,11 +142,18 @@ bind -T prefix C-x send-prefix
# the shell-command verbatim without expanding #{...}. -d sets the popup's start
# directory (and does expand) as a belt-and-suspenders.
bind -T prefix l display-popup -d "#{@claude_project_dir}" -w 90%% -h 85%% -E "'%s' list --socket '%s'"
# C-x r : floating remote-control (claude rc) toggle for this project.
bind -T prefix r display-popup -d "#{@claude_project_dir}" -w 60%% -h 40%% -E "'%s' rc --socket '%s'"
# C-x n : start a fresh Claude session in a new window (current keeps running).
bind -T prefix n new-window -c "#{@claude_project_dir}" -n claude "exec '%s' run --dir '#{@claude_project_dir}'"
# Like the l/r bindings, the project is resolved from the session environment
# ($CLAUDE_MUX_PROJECT_DIR, which the new window inherits) rather than a #{...}
# format inside the shell-command: tmux passes the shell-command verbatim and
# does not expand formats there, so an embedded --dir '#{@claude_project_dir}'
# would reach run as a literal path, fail to chdir, and drop the whole session.
bind -T prefix n new-window -c "#{@claude_project_dir}" -n claude "exec '%s' run"
# C-x d : detach and leave everything running in the background.
bind -T prefix d detach-client
`, binPath, socket, binPath)
`, binPath, socket, binPath, socket, binPath)
path := configPath()
if err := os.WriteFile(path, []byte(conf), 0o644); err != nil {
@@ -312,6 +324,79 @@ func (s *Server) NewWindowFresh(dir string) error {
return err
}
// rcSuffix is appended to a project's slug to name its dedicated tmux session
// hosting the Claude remote-control (`claude rc`) server. Keeping rc in its own
// session (rather than a window in the project session) means an rc-enabled
// project can run its server in the background without a stray Claude window,
// and it never shows up in the session picker.
const rcSuffix = "-rc"
// rcSlug returns the tmux session name hosting rc for dir.
func (s *Server) rcSlug(dir string) string { return Slug(dir) + rcSuffix }
// IsRunning reports whether the isolated tmux server is currently up.
func (s *Server) IsRunning() bool {
_, err := s.run("list-sessions")
return err == nil
}
// HasRC reports whether the remote-control server is running for dir.
func (s *Server) HasRC(dir string) bool {
return s.hasSession(s.rcSlug(dir))
}
// StartRC brings up `claude rc` for dir in its own detached tmux session. It is
// a no-op when one is already running.
//
// `claude rc` is launched fully non-interactively: --spawn=same-dir skips the
// spawn-mode chooser prompt, and the directory is pre-trusted (see
// claude.EnsureTrusted) so it does not stall on the workspace trust dialog.
// Either prompt would otherwise leave the server blocked, so the rc session
// would look "on" while doing nothing.
func (s *Server) StartRC(dir string) error {
slug := s.rcSlug(dir)
if s.hasSession(slug) {
return nil
}
if err := claude.EnsureTrusted(dir); err != nil {
return err
}
conf, err := writeConfig(s.BinPath, s.Socket)
if err != nil {
return err
}
if _, err := s.run("-f", conf, "new-session", "-d", "-s", slug, "-c", dir, "exec claude rc --spawn=same-dir"); err != nil {
return err
}
s.tagProject(slug, dir)
_, _ = s.run("source-file", conf)
return nil
}
// StopRC tears down the remote-control session for dir. The window-unlinked
// hook (auto-detach on window close) is suppressed around the kill so stopping
// rc never drops the current client. It is a no-op when rc is not running.
func (s *Server) StopRC(dir string) error {
slug := s.rcSlug(dir)
if !s.hasSession(slug) {
return nil
}
_, _ = s.run("set-hook", "-gu", "window-unlinked")
_, err := s.run("kill-session", "-t", slug)
_, _ = s.run("set-hook", "-g", "window-unlinked", "detach-client")
return err
}
// FocusRC switches the current client to dir's remote-control session.
func (s *Server) FocusRC(dir string) error {
slug := s.rcSlug(dir)
if !s.hasSession(slug) {
return fmt.Errorf("remote control is not running for this project")
}
_, err := s.run("switch-client", "-t", slug)
return err
}
// InsideOurServer reports whether the current process is running inside a client
// of this server (i.e. $TMUX points at our socket), which tells the picker
// whether it can drive tmux or must fall back to launching Claude directly.
+3 -3
View File
@@ -57,11 +57,11 @@ func statusDot(s session.Status) string {
case session.StatusRunning:
return runningStyle.Render("●")
case session.StatusQuestions:
return questionStyle.Render("?")
return questionStyle.Render("⬤")
case session.StatusIdle:
return openStyle.Render("○")
return openStyle.Render("⬤")
default: // closed
return closedStyle.Render("·")
return closedStyle.Render("◯")
}
}
+153
View File
@@ -0,0 +1,153 @@
// This file renders the small floating popup shown by "claude-mux rc" (the C-x r
// chord): it displays whether a Claude remote-control (`claude rc`) server is
// running for the project, lets you toggle it on/off, and can jump to it.
package ui
import (
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
"claude-mux/internal/rc"
"claude-mux/internal/tmux"
)
// RCAction is what the user chose to do when the rc popup closed.
type RCAction int
const (
// RCActionNone means nothing further to do (closed, or toggled in place).
RCActionNone RCAction = iota
// RCActionSwitch means switch the client to the rc session.
RCActionSwitch
)
// RCResult is the outcome of running the rc popup.
type RCResult struct {
Action RCAction
}
type rcModel struct {
dir string
srv *tmux.Server
running bool // rc session currently up
enabled bool // persisted "enabled" flag
err error
result RCResult
width int
height int
}
// RunRCPopup shows the remote-control toggle popup for dir and returns the
// chosen action. Toggling is applied in place (it starts/stops the rc server and
// updates the persisted list); switching is deferred to the caller so it happens
// after the popup closes.
func RunRCPopup(dir string, srv *tmux.Server) (RCResult, error) {
m := &rcModel{dir: dir, srv: srv}
m.refresh()
p := tea.NewProgram(m, tea.WithAltScreen())
final, err := p.Run()
if err != nil {
return RCResult{}, err
}
return final.(*rcModel).result, nil
}
// refresh re-reads the live and persisted rc state for the project.
func (m *rcModel) refresh() {
m.running = m.srv.HasRC(m.dir)
m.enabled = rc.IsEnabled(m.dir)
}
// toggle flips rc on or off: starting/stopping the server and syncing the
// persisted enabled flag so it comes back automatically on the next cold start.
func (m *rcModel) toggle() {
if m.running {
m.err = m.srv.StopRC(m.dir)
if m.err == nil {
m.err = rc.Disable(m.dir)
}
} else {
m.err = m.srv.StartRC(m.dir)
if m.err == nil {
m.err = rc.Enable(m.dir)
}
}
m.refresh()
}
func (m *rcModel) Init() tea.Cmd { return nil }
func (m *rcModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "t", " ":
m.toggle()
case "s", "enter":
if m.running {
m.result = RCResult{Action: RCActionSwitch}
return m, tea.Quit
}
case "r":
m.refresh()
}
}
return m, nil
}
func (m *rcModel) dims() (width int) {
width = m.width
if width <= 0 {
width = 60
}
return
}
func (m *rcModel) View() string {
width := m.dims()
var b strings.Builder
title := titleStyle.Render("Remote control") + dimStyle.Render(" · ") +
dirStyle.Render(truncate(filepath.Base(m.dir), width-16))
b.WriteString(title)
b.WriteString("\n\n")
// Status line: a dot + word describing whether rc is live.
if m.running {
b.WriteString(runningStyle.Render("● running"))
} else {
b.WriteString(closedStyle.Render("· stopped"))
}
// Note when the persisted flag disagrees with the live state (e.g. enabled
// but not yet started, or running without being persisted).
switch {
case m.enabled && !m.running:
b.WriteString(dimStyle.Render(" (enabled — will auto-start)"))
case !m.enabled && m.running:
b.WriteString(dimStyle.Render(" (not persisted)"))
case m.enabled:
b.WriteString(dimStyle.Render(" (auto-starts on launch)"))
}
b.WriteString("\n")
if m.err != nil {
b.WriteString("\n")
b.WriteString(emptyStyle.Width(width).Render("error: " + m.err.Error()))
b.WriteString("\n")
}
b.WriteString("\n")
toggle := "t turn on"
if m.running {
toggle = "t turn off"
}
help := toggle + " · s switch to it · q close"
b.WriteString(helpStyle.Width(width).Render(help))
return b.String()
}
+60 -2
View File
@@ -3,6 +3,7 @@
// tmux) whose only purpose is to host `claude` processes, with two chords:
//
// C-x l float a picker of every Claude session for the project
// C-x r toggle a persistent `claude rc` (remote-control) server for the project
// C-x n start a fresh Claude session in a new window
//
// See README.md for the full picture.
@@ -17,9 +18,11 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"claude-mux/internal/manager"
"claude-mux/internal/rc"
"claude-mux/internal/state"
"claude-mux/internal/tmux"
"claude-mux/internal/ui"
@@ -37,6 +40,8 @@ func run(args []string) error {
switch args[0] {
case "list":
return cmdList(args[1:])
case "rc":
return cmdRC(args[1:])
case "new":
return cmdNew(args[1:])
case "run":
@@ -65,10 +70,12 @@ Usage:
Inside a session:
C-x l list every Claude session for this project
C-x r toggle a persistent remote-control (claude rc) server for this project
C-x n new Claude session in a background window
C-x d detach (everything keeps running)
In the picker: enter open · n new · x kill selected · ctrl+a all · q cancel
In the rc popup: t toggle · s switch to it · q close
Environment:
CLAUDE_MUX_SOCKET tmux socket name (default "claude-mux")
@@ -86,10 +93,18 @@ func cmdAttach() error {
if srv.InsideOurServer() {
return fmt.Errorf("already inside a claude-mux session (use C-x n for a new session, C-x d to detach)")
}
// A cold start (the server was not already running) is when we bring every
// rc-enabled project's remote-control server back up in the background.
coldStart := !srv.IsRunning()
slug, err := srv.EnsureSession(dir)
if err != nil {
return err
}
if coldStart {
for _, p := range rc.List() {
_ = srv.StartRC(p)
}
}
return srv.Attach(slug)
}
@@ -162,6 +177,7 @@ func cmdHook(args []string) error {
}
var payload struct {
SessionID string `json:"session_id"`
Message string `json:"message"`
}
if data, err := io.ReadAll(os.Stdin); err == nil && len(data) > 0 {
_ = json.Unmarshal(data, &payload)
@@ -169,10 +185,27 @@ func cmdHook(args []string) error {
if payload.SessionID == "" {
return nil // nothing we can key on; do not fail the hook
}
if *status == "closed" {
st := *status
// Claude's Notification hook fires both for genuine prompts (permission /
// questions) and for the plain "you've been idle" timeout. The latter must
// not masquerade as "waiting for you", so treat an idle-waiting notification
// as idle rather than questions.
if st == "questions" && isIdleNotification(payload.Message) {
st = "idle"
}
if st == "closed" {
return state.Clear(payload.SessionID)
}
return state.Set(payload.SessionID, *status)
return state.Set(payload.SessionID, st)
}
// isIdleNotification reports whether a Notification hook message is the idle
// timeout ("Claude is waiting for your input …") rather than a real prompt for
// permission or an answer. Only the idle case must not surface as attention.
func isIdleNotification(msg string) bool {
return strings.Contains(strings.ToLower(msg), "waiting for your input")
}
// cmdNew starts a fresh Claude session: a new window when inside the server,
@@ -239,6 +272,31 @@ func cmdList(args []string) error {
return nil // cancelled
}
// cmdRC runs the remote-control toggle popup (the C-x r chord) and, when the
// user asked for it, switches the client to the rc session afterwards.
func cmdRC(args []string) error {
fs := flag.NewFlagSet("rc", flag.ContinueOnError)
dirFlag := fs.String("dir", "", "project directory (defaults to cwd)")
sockFlag := fs.String("socket", "", "tmux socket name")
if err := fs.Parse(args); err != nil {
return err
}
if *sockFlag != "" {
os.Setenv("CLAUDE_MUX_SOCKET", *sockFlag)
}
dir := resolveDir(*dirFlag)
srv := tmux.New()
res, err := ui.RunRCPopup(dir, srv)
if err != nil {
return err
}
if res.Action == ui.RCActionSwitch {
return srv.FocusRC(dir)
}
return nil
}
// dumpList prints the enriched session listing as plain text (non-interactive).
func dumpList(dir string, all bool, srv *tmux.Server) error {
entries, err := manager.Load(dir, all, srv)
+2
View File
@@ -77,6 +77,8 @@
".local/state/nvim"
# claude-code
".config/claude"
# claude-mux (persisted rc-enabled project list + session status)
".local/state/claude-mux"
# opencode
".config/opencode"
".cache/opencode"