Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 160 additions & 7 deletions backend/muse/autopilot/sessions.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
"""Discover active Claude Code sessions and match them to tmux panes.
"""Discover live provider sessions and match them to tmux panes.

Source of truth is ~/.claude/sessions/{pid}.json (written by Claude Code),
which gives pid -> sessionId/cwd/status. We keep only live pids, dedupe by
sessionId, and map each to its tmux pane via process ancestry.
Claude exposes pid->session sidecars under ~/.claude/sessions/*.json, which is
the primary source of truth for live Claude panes. Codex does not write an
equivalent sidecar; instead, its live process logs thread_ids into
~/.codex/logs_*.sqlite and stores thread metadata in ~/.codex/state_*.sqlite.
We resolve a tmux pane to its descendant Codex process, then map that pid to
the active thread id through those SQLite logs.
"""

from __future__ import annotations

import glob
import json
import os
import sqlite3
from datetime import datetime, timezone
from typing import Optional

Expand Down Expand Up @@ -43,13 +47,117 @@ def _pane_for(pid: int, pane_pids: dict[int, str]) -> Optional[str]:
return None


def discover() -> list[LiveSession]:
def _proc_snapshot() -> dict[int, dict[str, object]]:
out: dict[int, dict[str, object]] = {}
for name in os.listdir("/proc"):
if not name.isdigit():
continue
pid = int(name)
try:
with open(f"/proc/{pid}/stat", encoding="utf-8") as f:
data = f.read()
start = data.find("(")
end = data.rfind(")")
comm = data[start + 1 : end]
after = data[end + 2 :].split()
ppid = int(after[1])
with open(f"/proc/{pid}/cmdline", "rb") as f:
raw = f.read().replace(b"\0", b" ").decode("utf-8", errors="replace").strip()
except (OSError, ValueError, IndexError):
continue
out[pid] = {"ppid": ppid, "comm": comm, "cmdline": raw}
return out


def _children(procs: dict[int, dict[str, object]]) -> dict[int, list[int]]:
out: dict[int, list[int]] = {}
for pid, info in procs.items():
ppid = int(info["ppid"])
out.setdefault(ppid, []).append(pid)
return out


def _is_codex_proc(info: dict[str, object]) -> bool:
comm = os.path.basename(str(info.get("comm") or "")).lower()
if comm == "codex":
return True
cmd = str(info.get("cmdline") or "").lower()
argv0 = os.path.basename(cmd.split(" ", 1)[0]) if cmd else ""
return argv0 == "codex"


def _find_descendant(
root_pid: int,
procs: dict[int, dict[str, object]],
kids: dict[int, list[int]],
pred,
) -> Optional[int]:
if root_pid in procs and pred(procs[root_pid]):
return root_pid
queue = list(kids.get(root_pid, ()))
seen = set(queue)
while queue:
pid = queue.pop(0)
info = procs.get(pid)
if info and pred(info):
return pid
for child in kids.get(pid, ()):
if child not in seen:
seen.add(child)
queue.append(child)
return None


def _codex_dbs() -> tuple[Optional[str], Optional[str]]:
root = get_settings().codex_dir
state = sorted(root.glob("state_*.sqlite"))
logs = sorted(root.glob("logs_*.sqlite"))
return (str(state[-1]) if state else None, str(logs[-1]) if logs else None)


def _codex_meta_for_pid(pid: int, state_db: str, logs_db: str) -> Optional[dict[str, object]]:
try:
lconn = sqlite3.connect(f"file:{logs_db}?mode=ro", uri=True)
try:
row = lconn.execute(
"SELECT thread_id FROM logs "
"WHERE process_uuid LIKE ? AND thread_id IS NOT NULL "
"ORDER BY id DESC LIMIT 1",
(f"pid:{pid}:%",),
).fetchone()
finally:
lconn.close()
except sqlite3.Error:
return None
if row is None or not row[0]:
return None
thread_id = str(row[0])
try:
sconn = sqlite3.connect(f"file:{state_db}?mode=ro", uri=True)
try:
srow = sconn.execute(
"SELECT id, cwd, cli_version, updated_at_ms FROM threads WHERE id=?",
(thread_id,),
).fetchone()
finally:
sconn.close()
except sqlite3.Error:
return None
if srow is None:
return {"thread_id": thread_id}
return {
"thread_id": str(srow[0]),
"cwd": srow[1],
"version": srow[2],
"updated_at_ms": srow[3],
}


def _discover_claude(pane_pids: dict[int, str]) -> list[LiveSession]:
sessions_dir = get_settings().claude_dir / "sessions"
if not sessions_dir.is_dir():
return []

pane_pids = {p["pane_pid"]: p["pane_id"] for p in tmux.list_panes()}

# Latest record per sessionId among live pids.
best: dict[str, dict] = {}
for path in glob.glob(str(sessions_dir / "*.json")):
Expand Down Expand Up @@ -83,5 +191,50 @@ def discover() -> list[LiveSession]:
),
)
)
return out


def _discover_codex(panes: list[dict]) -> list[LiveSession]:
state_db, logs_db = _codex_dbs()
if not state_db or not logs_db:
return []
procs = _proc_snapshot()
kids = _children(procs)
best: dict[str, LiveSession] = {}
for pane in panes:
runtime_pid = _find_descendant(int(pane["pane_pid"]), procs, kids, _is_codex_proc)
if runtime_pid is None:
continue
meta = _codex_meta_for_pid(runtime_pid, state_db, logs_db)
if not meta or not meta.get("thread_id"):
continue
sid = f"codex:{meta['thread_id']}"
updated_ms = meta.get("updated_at_ms")
live = LiveSession(
session_id=sid,
pid=runtime_pid,
cwd=(meta.get("cwd") or pane.get("cwd")),
# Non-Claude live mapping gives us identity, not turn-state.
status="shell",
pane_id=str(pane["pane_id"]),
version=meta.get("version"),
updated_at=(
datetime.fromtimestamp(float(updated_ms) / 1000, tz=timezone.utc)
if updated_ms
else None
),
)
prev = best.get(sid)
if prev is None or (live.updated_at or datetime.min.replace(tzinfo=timezone.utc)) > (
prev.updated_at or datetime.min.replace(tzinfo=timezone.utc)
):
best[sid] = live
return list(best.values())


def discover() -> list[LiveSession]:
panes = tmux.list_panes()
pane_pids = {p["pane_pid"]: p["pane_id"] for p in panes}
out = _discover_claude(pane_pids) + _discover_codex(panes)
out.sort(key=lambda s: s.updated_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
return out
20 changes: 14 additions & 6 deletions backend/muse/autopilot/tmux.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import subprocess
import time


def _run(args: list[str], timeout: float = 5.0, input: str | None = None) -> tuple[int, str, str]:
Expand Down Expand Up @@ -58,6 +59,7 @@ def list_layout() -> list[dict]:
"#{pane_index}",
"#{pane_active}",
"#{pane_current_command}",
"#{pane_start_command}",
"#{pane_current_path}",
"#{pane_title}",
"#{session_attached}",
Expand All @@ -71,7 +73,7 @@ def list_layout() -> list[dict]:
panes: list[dict] = []
for line in out.splitlines():
parts = line.split("\t")
if len(parts) != 13:
if len(parts) != 14:
continue
try:
panes.append(
Expand All @@ -84,14 +86,15 @@ def list_layout() -> list[dict]:
"pane_index": int(parts[5]),
"pane_active": parts[6] == "1",
"command": parts[7],
"cwd": parts[8],
"title": parts[9],
"session_attached": parts[10] != "0",
"start_command": parts[8],
"cwd": parts[9],
"title": parts[10],
"session_attached": parts[11] != "0",
# Epoch secs of last activity in this window — for most-recent sort.
"last_activity": int(parts[11]) if parts[11].isdigit() else 0,
"last_activity": int(parts[12]) if parts[12].isdigit() else 0,
# Stable window handle (@<n>) — survives index shifts, so it's the
# safe target for move-window.
"window_id": parts[12],
"window_id": parts[13],
}
)
except ValueError:
Expand Down Expand Up @@ -217,6 +220,10 @@ def send_text(pane_id: str, text: str, submit: bool = True) -> tuple[bool, str]:
if code != 0:
return False, err or "send-keys failed"
if submit:
# Some TUIs (notably Codex) can drop an immediate synthetic Enter sent in
# the same instant as a literal paste. Give the app one frame to ingest
# the text before submitting it.
time.sleep(0.05)
code, _, err = _run(["send-keys", "-t", pane_id, "Enter"])
if code != 0:
return False, err or "enter failed"
Expand All @@ -237,6 +244,7 @@ def paste_text(pane_id: str, text: str, submit: bool = True) -> tuple[bool, str]
if code != 0:
return False, err or "paste-buffer failed"
if submit:
time.sleep(0.05)
code, _, err = _run(["send-keys", "-t", pane_id, "Enter"])
if code != 0:
return False, err or "enter failed"
Expand Down
3 changes: 3 additions & 0 deletions backend/muse/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,7 @@ class PendingOptions(BaseModel):
source: Literal["permission", "tool_question", "none"] = "none"
available: bool = False # False when nothing is pending / actionable
prompt: str = ""
detail: str = "" # long-form context to review before answering (e.g. a plan body)
options: list[PendingOption] = Field(default_factory=list)
current_index: Optional[int] = None # highlighted row in the live buffer
fingerprint: str = "" # client echoes this back on select for stale-protection
Expand All @@ -639,6 +640,7 @@ class SuggestReply(BaseModel):
class TmuxPane(BaseModel):
"""One pane in the live tmux topology, for the swipeable mobile panes view."""

provider: Optional[str] = None # claude | gemini | codex | opencode | None
pane_id: str
session_name: str
window_index: int
Expand All @@ -661,6 +663,7 @@ class TmuxPane(BaseModel):
attention: str = "" # short reason, e.g. "permission prompt", "working"
# Claude Code's permission mode (Shift+Tab cycles it); None for non-Claude panes.
mode: Optional[Literal["default", "acceptEdits", "plan", "bypass"]] = None
capabilities: dict[str, bool] = Field(default_factory=dict)
# Full screen text is heavy (hundreds of KB across a fleet) — it ships only
# when the client asks (?previews=1); the one-line tail always ships for
# task-list subtitles. The deck fetches live screens per-pane instead.
Expand Down
17 changes: 13 additions & 4 deletions backend/muse/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,25 @@ class ParsedMenu:
options: list[MenuOption] = field(default_factory=list)
current_index: Optional[int] = None # highlighted row, 0-based among options
remaining_questions: int = 0 # AskUserQuestion with >1 question still pending
# Long-form context to review before answering — the full ExitPlanMode plan
# (markdown), so it can be read where it's answered instead of only in the
# terminal. Empty for permission dialogs and plain questions (prompt suffices).
detail: str = ""


def fingerprint(prompt: str, options: list[MenuOption]) -> str:
def fingerprint(prompt: str, options: list[MenuOption], detail: str = "") -> str:
"""Stable hash of what the user is being shown. The client echoes it back on
select so the server can refuse (409) if the buffer changed underneath."""
select so the server can refuse (409) if the buffer changed underneath. The
detail (e.g. a plan's body) is folded in so a re-issued plan invalidates a
stale selection even when its one-line prompt is unchanged."""
h = hashlib.sha256()
h.update(prompt.strip().encode("utf-8", "replace"))
for opt in options:
h.update(b"\x00")
h.update(opt.label.strip().encode("utf-8", "replace"))
if detail:
h.update(b"\x01")
h.update(detail.strip().encode("utf-8", "replace"))
return h.hexdigest()[:16]


Expand Down Expand Up @@ -215,13 +224,13 @@ def _ask_user_question_menu(tool_input: dict) -> ParsedMenu:

def _exit_plan_menu(tool_input: dict) -> ParsedMenu:
plan = (tool_input.get("plan") or "").strip()
first = plan.splitlines()[0].strip() if plan else ""
prompt = "Ready to code? " + (f"Plan: {first}" if first else "(plan presented)")
prompt = "Review the plan, then choose:" if plan else "Ready to code?"
return ParsedMenu(
source="tool_question",
prompt=prompt,
options=[
MenuOption(id="1", label="Yes, proceed", kind="menu"),
MenuOption(id="2", label="No, keep planning", kind="menu"),
],
detail=plan, # full plan markdown — reviewable at the point of answering
)
27 changes: 19 additions & 8 deletions backend/muse/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class ProfileParam(BaseModel):
class Profile(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
provider: str = "claude" # claude | gemini | codex | opencode | shell-ish custom
cwd: str = "~"
command: str = "claude"
params: list[ProfileParam] = []
Expand All @@ -52,9 +53,14 @@ class Profile(BaseModel):
builtin: bool = False


# Always available so "New window" works with no config file. A file profile named
# "Claude" (case-insensitive) overrides this.
DEFAULT_PROFILE = Profile(name="Claude", cwd="~", command="claude", builtin=True)
# Always available so "New window" works with no config file. A file profile with the
# same name (case-insensitive) overrides the matching built-in.
BUILTIN_PROFILES = [
Profile(name="Claude", provider="claude", cwd="~", command="claude", builtin=True),
Profile(name="Gemini", provider="gemini", cwd="~", command="antigravity", builtin=True),
Profile(name="Codex", provider="codex", cwd="~", command="codex", builtin=True),
Profile(name="OpenCode", provider="opencode", cwd="~", command="opencode", builtin=True),
]


def profiles_path() -> Path:
Expand All @@ -75,7 +81,7 @@ def load_profiles() -> list[Profile]:
an actionable message instead of silently doing nothing."""
path = profiles_path()
if not path.exists():
return [DEFAULT_PROFILE]
return BUILTIN_PROFILES[:]

try:
data = tomllib.loads(path.read_text())
Expand All @@ -101,11 +107,16 @@ def load_profiles() -> list[Profile]:
seen.add(low)
file_profiles.append(p)

# File wins: only prepend the built-in default when the file doesn't define "Claude".
out: list[Profile] = []
if DEFAULT_PROFILE.name.lower() not in seen:
out.append(DEFAULT_PROFILE)
out.extend(file_profiles)
by_name = {p.name.lower(): p for p in file_profiles}
consumed: set[str] = set()
for builtin in BUILTIN_PROFILES:
low = builtin.name.lower()
chosen = by_name.get(low, builtin)
out.append(chosen)
if low in by_name:
consumed.add(low)
out.extend(p for p in file_profiles if p.name.lower() not in consumed)
return out


Expand Down
Loading