diff --git a/claude-mux/README.md b/claude-mux/README.md index 806c3fc..e12c9af 100644 --- a/claude-mux/README.md +++ b/claude-mux/README.md @@ -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 diff --git a/claude-mux/internal/claude/claude.go b/claude-mux/internal/claude/claude.go new file mode 100644 index 0000000..ff5385b --- /dev/null +++ b/claude-mux/internal/claude/claude.go @@ -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) +} diff --git a/claude-mux/internal/rc/rc.go b/claude-mux/internal/rc/rc.go new file mode 100644 index 0000000..66409fa --- /dev/null +++ b/claude-mux/internal/rc/rc.go @@ -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) +} diff --git a/claude-mux/internal/tmux/tmux.go b/claude-mux/internal/tmux/tmux.go index 859fa69..f1b0efe 100644 --- a/claude-mux/internal/tmux/tmux.go +++ b/claude-mux/internal/tmux/tmux.go @@ -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. diff --git a/claude-mux/internal/ui/list.go b/claude-mux/internal/ui/list.go index 57480a8..1a7e33c 100644 --- a/claude-mux/internal/ui/list.go +++ b/claude-mux/internal/ui/list.go @@ -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("◯") } } diff --git a/claude-mux/internal/ui/rc.go b/claude-mux/internal/ui/rc.go new file mode 100644 index 0000000..acf0f84 --- /dev/null +++ b/claude-mux/internal/ui/rc.go @@ -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() +} diff --git a/claude-mux/main.go b/claude-mux/main.go index 0bf1c15..15ea8f9 100644 --- a/claude-mux/main.go +++ b/claude-mux/main.go @@ -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) diff --git a/modules/cli/nix/impermanence.nix b/modules/cli/nix/impermanence.nix index db74345..b9c587b 100644 --- a/modules/cli/nix/impermanence.nix +++ b/modules/cli/nix/impermanence.nix @@ -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"