mirror of
https://github.com/zoriya/flake.git
synced 2026-08-15 18:43:42 +00:00
Fix claude-mux with rc sessions
This commit is contained in:
+10
-3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
fileSystems."/" = {
|
||||
device = "none";
|
||||
fsType = "tmpfs";
|
||||
options = ["size=8G" "mode=755"];
|
||||
options = ["size=16G" "mode=755"];
|
||||
};
|
||||
|
||||
fileSystems."/tmp" = {
|
||||
|
||||
Reference in New Issue
Block a user