Fix claude-mux with rc sessions

This commit is contained in:
2026-08-15 19:53:33 +02:00
parent 1365fa8f26
commit b7fad6de84
7 changed files with 147 additions and 34 deletions
+10 -3
View File
@@ -117,9 +117,16 @@ app. `C-x r` floats a small popup to manage it **per project**:
| `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.
(so it never spawns a stray Claude window) and records the project in an
**rc-enabled list**. Toggling it off kills that session and removes the project
from the list.
Sessions you create from the phone or the browser are spawned *inside* that one
`claude rc` process, so they never get a tmux window of their own. The picker
finds them by their reported status instead of by a window tag, and tags their
row `· remote`; opening one switches to the rc window that hosts it. `x` on a
remote session only archives it — it does not kill the window, which would take
the whole remote-control endpoint down with it.
`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
+26 -8
View File
@@ -22,6 +22,10 @@ type Entry struct {
// Archived is true when the user has archived this session; archived sessions
// are grouped at the bottom of the listing under their own header.
Archived bool
// Remote is true when the session is hosted by the project's remote-control
// server, i.e. it was created from the mobile app or claude.ai/code rather
// than in a window of its own.
Remote bool
}
// Load lists sessions and marks which are currently running by consulting the
@@ -42,24 +46,38 @@ func Load(dir string, all bool, srv *tmux.Server) ([]Entry, error) {
return nil, err
}
running := srv.RunningSessions() // id -> target ("slug:window")
archived := state.ArchivedSet() // id -> archived
live := srv.Live() // what the isolated server is hosting
status := state.LiveSet() // id -> status word, for every live session
archived := state.ArchivedSet() // id -> archived
entries := make([]Entry, 0, len(sessions))
for _, s := range sessions {
e := Entry{Session: s, ProjectDir: projectDirOf(s, dir), Archived: archived[s.ID]}
if target, ok := running[s.ID]; ok {
// Open in a window; the substate (running/questions/idle) comes from
// what the session last reported through its hooks.
e.Status = session.ParseStatus(state.Get(s.ID))
w, hosted := live.Windows[s.ID]
st, reported := status[s.ID]
switch {
// Open in a window; the substate (running/questions/idle) comes from what
// the session last reported through its hooks. A remote-control window is
// only trusted while its session is still reporting: nothing untags it when
// the session ends, so a stale tag would otherwise read as open forever.
case hosted && (!w.RC || reported):
e.Status = session.ParseStatus(st)
// No hook fires when a response is interrupted with Esc, so the hook
// status can be left stuck at "running". If the transcript's last
// entry is an interrupt marker, the session is actually idle.
if s.Interrupted && e.Status != session.StatusIdle {
e.Status = session.StatusIdle
}
e.Target = target
} else {
e.Target, e.Remote = w.Target, w.RC
// Sessions created from the mobile app or claude.ai/code are spawned inside
// the project's `claude rc` process, so they never get a window of their own
// and used to read as closed even while actively working. Their hooks do
// report status, so a live status plus a running rc server for the project
// is what marks them open; opening one jumps to the rc window hosting it.
case reported && live.RC[e.ProjectDir] != "":
e.Status = session.ParseStatus(st)
e.Target, e.Remote = live.RC[e.ProjectDir], true
default:
e.Status = session.StatusClosed
}
entries = append(entries, e)
+20
View File
@@ -47,6 +47,26 @@ func Get(id string) string {
return strings.TrimSpace(string(b))
}
// LiveSet returns the recorded status word of every session that currently has
// one, keyed by session id. A session only has a status file between its
// SessionStart and SessionEnd hooks, so membership is itself the signal that a
// session is alive — which is the only thing the picker has to go on for the
// sessions `claude rc` spawns, since they run inside the rc process and so have
// no tmux window of their own.
func LiveSet() map[string]string {
entries, err := os.ReadDir(dir())
if err != nil {
return nil
}
set := make(map[string]string, len(entries))
for _, e := range entries {
if !e.IsDir() {
set[e.Name()] = Get(e.Name())
}
}
return set
}
// archiveDir is where the archived-session markers live: one empty file per
// archived session id. Membership is what matters, not the contents.
func archiveDir() string { return filepath.Join(paths.StateDir(), "archived") }
+61 -12
View File
@@ -329,22 +329,45 @@ func (s *Server) Attach(slug string) error {
return nil
}
// RunningSessions returns a map of Claude session id -> tmux target
// ("session:window") for every window currently hosting a session. This is how
// the picker knows what is live and where to jump.
func (s *Server) RunningSessions() map[string]string {
// Window is where a live Claude session is hosted in the isolated server.
type Window struct {
// Target is the tmux "session:window" to jump to.
Target string
// Dir is the project directory of the hosting tmux session.
Dir string
// RC is true when the host is a project's remote-control session, i.e. the
// window belongs to `claude rc` rather than to a Claude of its own.
RC bool
}
// Live is everything the isolated server knows about currently hosted Claude
// sessions.
type Live struct {
// Windows maps a Claude session id to the window hosting it. This is how the
// picker knows what is live and where to jump.
Windows map[string]Window
// RC maps a project directory to the target of its remote-control window.
// Sessions `claude rc` spawns (from the mobile app or claude.ai/code) run
// inside that one process and so never get a window of their own; this is
// where the picker sends you when you open one.
RC map[string]string
}
// Live lists the windows currently hosting Claude sessions, along with the
// remote-control window of every project running one.
func (s *Server) Live() Live {
live := Live{Windows: map[string]Window{}, RC: map[string]string{}}
out, err := s.run("list-windows", "-a", "-F",
"#{session_name}\t#{session_name}:#{window_index}\t#{"+sessionIDOption+"}")
"#{session_name}\t#{session_name}:#{window_index}\t#{"+sessionIDOption+"}\t#{@claude_project_dir}")
if err != nil {
return nil
return live
}
result := make(map[string]string)
for _, ln := range strings.Split(out, "\n") {
if ln == "" {
continue
}
f := strings.SplitN(ln, "\t", 3)
if len(f) != 3 || f[2] == "" {
f := strings.SplitN(ln, "\t", 4)
if len(f) != 4 {
continue
}
// Warm-pool windows are tagged too but are not real user sessions; keep
@@ -353,9 +376,16 @@ func (s *Server) RunningSessions() map[string]string {
if strings.HasSuffix(f[0], warmSuffix) {
continue
}
result[f[2]] = f[1]
isRC := strings.HasSuffix(f[0], rcSuffix)
if isRC && f[3] != "" {
live.RC[f[3]] = f[1]
}
if f[2] == "" {
continue
}
live.Windows[f[2]] = Window{Target: f[1], Dir: f[3], RC: isRC}
}
return result
return live
}
// CurrentSessionID returns the Claude session id tagged on the window the
@@ -389,6 +419,17 @@ func (s *Server) WindowSessionID(paneTarget string) string {
return strings.TrimSpace(out)
}
// IsRCPane reports whether paneTarget lives in a project's remote-control
// session. Such a window hosts `claude rc` plus every session it spawns, so it
// belongs to no single Claude and must not be retagged by them.
func (s *Server) IsRCPane(paneTarget string) bool {
out, err := s.run("display-message", "-p", "-t", paneTarget, "#{session_name}")
if err != nil {
return false
}
return strings.HasSuffix(strings.TrimSpace(out), rcSuffix)
}
// PanePID returns the pid of the command tmux started in paneTarget, or "" when
// it cannot be resolved. Windows are launched with an `exec`, so for a Claude
// window this is the pid of Claude itself.
@@ -734,7 +775,15 @@ func (s *Server) StartRC(dir string) error {
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 {
// Respawn rather than exec: `claude rc` exits on its own often enough (a
// dropped connection it cannot recover, an update, a crash) and taking the
// window with it ends the whole tmux session, so the endpoint stays down until
// the next cold start. Sessions created from the phone then have nothing local
// to spawn into and never show up. The loop puts it straight back; the sleep
// keeps a persistently failing rc from spinning, and StopRC kills the session
// so the loop dies with it.
if _, err := s.run("-f", conf, "new-session", "-d", "-s", slug, "-c", dir,
"while true; do claude rc --spawn=same-dir; sleep 2; done"); err != nil {
return err
}
s.tagProject(slug, dir)
+10 -1
View File
@@ -344,7 +344,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if len(m.entries) > 0 {
e := m.entries[m.cursor]
if !e.Archived {
if e.Target != "" {
// A remote session shares the project's rc window with the
// server itself, so killing it would take the whole
// remote-control endpoint down with it: only file it away.
if e.Target != "" && !e.Remote {
_ = m.srv.KillWindow(e.Target)
}
_ = state.Archive(e.ID)
@@ -630,6 +633,12 @@ func (m *model) renderRow(i int, e manager.Entry, width int) string {
indent = selBar.Render("▌ ") + " "
}
parts := []string{statusWord(e.Status)}
// Remote sessions live in the project's rc window rather than one of their
// own, so flag them: opening one lands on the remote-control server, not on a
// pane showing the conversation.
if e.Remote {
parts = append(parts, "remote")
}
if m.all {
parts = append(parts, filepath.Base(e.ProjectDir))
}
+19 -9
View File
@@ -103,17 +103,17 @@ 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)
}
// Bring every rc-enabled project's remote-control server back up. This runs on
// every attach, not just a cold start: an rc server that died mid-session used
// to stay down for the life of the tmux server while the popup still promised
// it would auto-start, so sessions created from the phone had nothing local to
// spawn into. StartRC is a no-op when the session is already up.
for _, p := range rc.List() {
_ = srv.StartRC(p)
}
// Pin this client's home project (the dir it was launched from) keyed by its
// tty, so C-x n always creates a session here even after the client is later
@@ -213,11 +213,17 @@ func cmdReload(args []string) error {
if *sockFlag != "" {
os.Setenv("CLAUDE_MUX_SOCKET", *sockFlag)
}
reloaded, err := tmux.New().Reload()
srv := tmux.New()
reloaded, err := srv.Reload()
if err != nil {
return err
}
if reloaded {
// Activation is the other natural point to heal a remote-control server
// that died since the tmux server came up (see cmdAttach).
for _, p := range rc.List() {
_ = srv.StartRC(p)
}
fmt.Println("reloaded claude-mux bindings")
}
return nil
@@ -274,9 +280,13 @@ func cmdHook(args []string) error {
// next hook fires. Claude exports CLAUDE_PID to its children, so the hook can
// tell the two apart: for a window claude-mux launched the pane command execs
// Claude, so the pane's pid is the owning Claude's pid.
// A remote-control window is exempt: it hosts `claude rc` and every session
// the mobile app spawns through it, so the sessions there would take turns
// stealing the tag and clearing each other's status, leaving all but the
// latest reading "closed". The picker finds them by their status instead.
if pane := os.Getenv("TMUX_PANE"); pane != "" {
srv := tmux.New()
if srv.InsideOurServer() && os.Getenv("CLAUDE_PID") == srv.PanePID(pane) {
if srv.InsideOurServer() && os.Getenv("CLAUDE_PID") == srv.PanePID(pane) && !srv.IsRCPane(pane) {
if old := srv.WindowSessionID(pane); old != "" && old != payload.SessionID {
_ = state.Clear(old)
}
+1 -1
View File
@@ -38,7 +38,7 @@
fileSystems."/" = {
device = "none";
fsType = "tmpfs";
options = ["size=8G" "mode=755"];
options = ["size=16G" "mode=755"];
};
fileSystems."/tmp" = {