mirror of
https://github.com/zoriya/flake.git
synced 2026-08-15 18:43:42 +00:00
Some QOL on claude-mux
This commit is contained in:
+34
-3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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("◯")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user