diff --git a/claude-mux/README.md b/claude-mux/README.md index 645197d..b15fcdf 100644 --- a/claude-mux/README.md +++ b/claude-mux/README.md @@ -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 diff --git a/claude-mux/internal/manager/manager.go b/claude-mux/internal/manager/manager.go index b40c7d2..1589445 100644 --- a/claude-mux/internal/manager/manager.go +++ b/claude-mux/internal/manager/manager.go @@ -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) diff --git a/claude-mux/internal/state/state.go b/claude-mux/internal/state/state.go index f720347..2dd8ca2 100644 --- a/claude-mux/internal/state/state.go +++ b/claude-mux/internal/state/state.go @@ -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") } diff --git a/claude-mux/internal/tmux/tmux.go b/claude-mux/internal/tmux/tmux.go index 2d134c7..8a81b99 100644 --- a/claude-mux/internal/tmux/tmux.go +++ b/claude-mux/internal/tmux/tmux.go @@ -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) diff --git a/claude-mux/internal/ui/list.go b/claude-mux/internal/ui/list.go index 30df7af..dc0c24e 100644 --- a/claude-mux/internal/ui/list.go +++ b/claude-mux/internal/ui/list.go @@ -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)) } diff --git a/claude-mux/main.go b/claude-mux/main.go index fb36de9..5a0c85b 100644 --- a/claude-mux/main.go +++ b/claude-mux/main.go @@ -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) } diff --git a/hosts/fuhen/hardware-configuration.nix b/hosts/fuhen/hardware-configuration.nix index 6f7abcf..0523bf3 100644 --- a/hosts/fuhen/hardware-configuration.nix +++ b/hosts/fuhen/hardware-configuration.nix @@ -38,7 +38,7 @@ fileSystems."/" = { device = "none"; fsType = "tmpfs"; - options = ["size=8G" "mode=755"]; + options = ["size=16G" "mode=755"]; }; fileSystems."/tmp" = {