From 313700dc25af8f2671a8dca67479db9e77adee28 Mon Sep 17 00:00:00 2001 From: Mukul Sharma Date: Mon, 19 Oct 2026 08:24:28 -0700 Subject: [PATCH 1/2] Read agent sessions from cmux, not the registry and ledger files cmux 0.64.25 records every agent session itself, and `cmux sessions list --json --all` prints those records without its socket. bin/cmux_sessions.py is the one place that runs it: it returns typed records, finds the CLI via CMUX_BIN, an executable CMUX_BUNDLED_CLI_PATH, PATH, then the app bundle, and returns [] with one stderr line when cmux can't answer. world-scanner, session-context-watcher, build-ws-context and transcript-tail now read it instead of ~/.claude/cmux-registry.json and ~/.cmux-session-ledger.jsonl. Only the session cmux marks active on a surface counts as running there, and first_recorded_at comes from cmux's started_at. Output fields keep their names and values. Remove the ledger hook and cmux-restore-sessions (use cmux restore-session or cmux restore --surface). install.sh drops their old links, and patch-settings.py now removes the ledger and cmux-registry.py registrations instead of adding them. Keep hooks/cmux-auto-resume.py: agents started without cmux's Claude wrapper (absolute ~/.local/bin/claude, the fleet's scheduled spawns) are only recorded by cmux because it forwards their SessionStart. The coverage gate now measures hooks/ and install/ Python. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 42 +++ README.md | 4 +- bin/build-ws-context.py | 95 ++--- bin/cmux-restore-sessions.py | 409 ---------------------- bin/cmux_sessions.py | 126 +++++++ bin/session-context-watcher.py | 53 +-- bin/transcript-tail.py | 23 +- bin/world-scanner.py | 142 ++++---- hooks/cmux-auto-resume.py | 15 +- hooks/cmux-session-ledger.py | 183 ---------- install.sh | 49 +-- install/patch-settings.py | 161 ++++----- tests/README.md | 4 +- tests/check_change_coverage.py | 8 +- tests/fake_cmux.py | 78 +++++ tests/mutation_smoke.py | 93 +++++ tests/run_python_coverage.py | 10 +- tests/test_build_ws_context_in_process.py | 231 +++++++----- tests/test_change_coverage_gate.py | 7 +- tests/test_cmux_restore_sessions.py | 155 -------- tests/test_cmux_session_ledger.py | 107 ------ tests/test_cmux_sessions.py | 148 ++++++++ tests/test_coverage_capture.py | 8 +- tests/test_install_cmux_sessions.py | 154 ++++++++ tests/test_patch_settings.py | 314 ++++++++++------- tests/test_self_update.py | 2 +- tests/test_session_context_watcher.py | 217 ++++++------ tests/test_world_scanner.py | 287 +++++++-------- tests/test_writers_in_process.py | 55 ++- 29 files changed, 1547 insertions(+), 1633 deletions(-) delete mode 100755 bin/cmux-restore-sessions.py create mode 100644 bin/cmux_sessions.py delete mode 100755 hooks/cmux-session-ledger.py create mode 100644 tests/fake_cmux.py delete mode 100644 tests/test_cmux_restore_sessions.py delete mode 100644 tests/test_cmux_session_ledger.py create mode 100644 tests/test_cmux_sessions.py create mode 100644 tests/test_install_cmux_sessions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index adc0258..af7f252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,48 @@ The version is carried in `pyproject.toml` and `src/assistant/__init__.py` ## [Unreleased] +### Changed +- Read agent sessions from cmux instead of from two hook-written files. cmux + 0.64.25 records every agent session itself, and `cmux sessions list --json --all` + prints those records without the cmux socket. The new `bin/cmux_sessions.py` + is the one place that runs it, and it returns nothing when cmux is missing or + fails. `world-scanner.py`, `session-context-watcher.py`, `build-ws-context.py`, + and `transcript-tail.py --closed-cwd` now read it instead of + `~/.claude/cmux-registry.json` (950 stale entries on this machine) and + `~/.cmux-session-ledger.jsonl` (29 MB and growing). Only the session cmux + marks active on a surface counts as running there, so a session left behind + by `/clear` in the same process no longer looks live. Output fields keep their + names, and `registry_live_pid` and `session_start_registry` keep their + values. Some values now come from different sources: + - **First recorded** comes from cmux's `started_at`. A session restored into + another workspace shows its original start, not when it arrived there. + - A session's `cwd` is its launch directory, and `ts` is cmux's latest hook + update instead of the last SessionStart. + - `transcript-tail.py --closed-cwd` picks the most recently active session. + - Sessions of agents the fleet doesn't run, such as Copilot, are left out. + - Claude sessions that cmux has no record for can no longer be verified. This + happens when `cmux-auto-resume.py` failed for a session started outside + cmux's wrapper; one live session on this machine is affected. +- Keep `hooks/cmux-auto-resume.py`. cmux's Claude wrapper injects cmux's hooks, + but agents started without it get none. That includes an absolute + `~/.local/bin/claude` like the warm comms session, and the scheduled fleet + spawns. This hook forwards their SessionStart to cmux, so cmux still records + them. +- Log one stderr line when `cmux sessions list` can't be run or read, instead + of silently reporting no sessions. +- Measure Python under `install/` and `hooks/` in the new-code coverage gate, + so changes there can pass it. + +### Removed +- Remove `hooks/cmux-session-ledger.py` and `bin/cmux-restore-sessions.py`. Use + `cmux restore-session`, or `cmux restore --surface` for one pane. + `install.sh --apply` removes the links an older install made to them. + `install/patch-settings.py` now removes the ledger hook's registrations, and + `cmux-registry.py`'s, from `~/.claude/settings.json` and + `~/.factory/hooks.json` instead of adding them. It reports a file it can't + read and still cleans the others. The installer still installs cmux's own + Factory hooks. + ### Added - Add the `cmux-ws-numberer` LaunchAgent as an opt-in feature. It adds a `[N]` suffix to cmux workspace titles and gives each workspace an unused sidebar diff --git a/README.md b/README.md index 0db8fe3..b3cabe4 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,8 @@ and next action back to the original session. Copying never sends it automatical Saved context must match the current workspace and session identities. Missing or unsupported session bindings stay unknown instead of borrowing another -session's notes. **First recorded** means the earliest matching start in your -local session history, not the session's original creation date. Pending task +session's notes. **First recorded** is the session's start as cmux recorded it; +a session restored into another workspace keeps that original start. Pending task dates come from their creation records. The updated `/back-off` skill saves the workspace identity so the overview can confirm **Parked**. If you run the dashboard from a separate checkout, use its displayed confirmation command for older pause diff --git a/bin/build-ws-context.py b/bin/build-ws-context.py index a7bed99..ee7540c 100755 --- a/bin/build-ws-context.py +++ b/bin/build-ws-context.py @@ -12,7 +12,8 @@ "title": "...", "cwd": "...", "transcript_path": "/Users/.../.jsonl" | null, - "transcript_source": "screen_session_id" | "registry_live_pid" | null, + "transcript_source": "screen_session_id" | "cmux_resume_binding" + | "registry_live_pid" | null, "session_id8": "<8hex>" | null, "agent_surface": "surface:N" | null, "observation_identity": { @@ -44,10 +45,12 @@ one carrying a Claude status bar / boot banner. Its screen is `screen_text`. - `transcript_path` resolves from the session-id the agent stamps on its OWN status bar (`… │ #<8hex>`), then confirms the file's internal `sessionId` - matches — exact, self-verifying. Fallback: a cmux-registry row for the - agent pane's surface UUID, but ONLY when its `claude_pid` is still alive - AND it agrees with the live screen (the registry goes stale on surface - reuse — the ws:12 trap). `transcript_source` records which gate passed. + matches — exact, self-verifying. Next: the live cmux resume binding for + the pane. Last: cmux's own session record for the agent pane's surface + UUID (`cmux sessions list`), but ONLY when its pid is still alive AND it + agrees with the live screen (a record goes stale on surface reuse — the + ws:12 trap). `transcript_source` records which gate passed; that last gate + keeps its historical name, `registry_live_pid`. The Observer is still told to trust the screen over transcript-derived signals on conflict — the screen is what the agent is showing RIGHT NOW. @@ -63,6 +66,9 @@ import time from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import cmux_sessions # noqa: E402 + HOME = Path(os.environ["HOME"]) PROTECTED_REFS = {"workspace:3", "workspace:108", "workspace:7"} CMUX_BIN = os.environ.get( @@ -119,10 +125,10 @@ # - `read-screen --workspace` reads only the FOCUSED pane; in a split # workspace the focused pane is often a shell, not the agent. We enumerate # ALL panes and pick the agent. -# - The cmux registry's surface→session map goes STALE when a surface is -# reused (ws:12: registry pointed at a dead headless pulse's session while -# the live agent ran a different one). Registry entries are trusted only -# when their claude_pid is still alive AND they agree with the live screen. +# - A surface→session record goes STALE when a surface is reused (ws:12: the +# record pointed at a dead headless pulse's session while the live agent +# ran a different one). cmux session records are trusted only when their +# pid is still alive AND they agree with the live screen. # The Claude Code status bar's last cell is the running session-id prefix, # rendered as `… │ #<8hex>` at the END of a box-drawing line. Anchoring to that @@ -340,44 +346,39 @@ def transcript_from_session_id(sid8: str | None) -> str | None: return None -def registry_transcript_for_surface(surface_uuid: str, expect_sid8: str | None = None) -> dict | None: - """Transcript for a surface via the cmux registry — SAFE variant. +def recorded_transcript_for_surface(surface_uuid: str, expect_sid8: str | None = None) -> dict | None: + """Transcript for a surface via cmux's own session record — SAFE variant. - The registry maps surface UUID → {session_id, claude_pid, transcript_path}, - but the mapping goes stale when a surface is reused. We therefore return an - entry ONLY when its claude_pid is still ALIVE (a dead pid means the entry - describes a session that no longer occupies the surface — the ws:12 trap). - When `expect_sid8` is given (a live status-bar id), the registry must AGREE - with it; on disagreement we trust the screen and return None here. + cmux records which session is active on each surface UUID, with its pid + and transcript, but that record goes stale when a surface is reused. We + therefore return it ONLY when its pid is still ALIVE (a dead pid means the + record describes a session that no longer occupies the surface — the ws:12 + trap). When `expect_sid8` is given (a live status-bar id), the record must + AGREE with it; on disagreement we trust the screen and return None here. - Returns {transcript_path, session_id8} or None. + Returns {transcript_path, session_id8, agent} or None. """ if not surface_uuid: return None - try: - reg = json.load(open(HOME / ".claude/cmux-registry.json")) - except Exception: - return None want = surface_uuid.upper() - entries = [e for e in reg.values() - if (e.get("surface_id") or "").upper() == want] - if not entries: + records = [r for r in cmux_sessions.list_sessions() + if r.active_for_surface and r.provider and (r.surface_id or "").upper() == want] + if len(records) != 1: return None - entry = max(entries, key=lambda e: e.get("ts", 0)) - if not _pid_alive(entry.get("agent_pid") or entry.get("claude_pid")): - return None # stale registry row — do NOT trust - sid = (entry.get("session_id") or "") - sid8 = sid[:8] if sid else None - if expect_sid8 and sid8 and sid8 != expect_sid8: - return None # registry disagrees with the live screen → screen wins - tp = entry.get("transcript_path") + record = records[0] + if not _pid_alive(record.pid): + return None # stale record — do NOT trust + sid8 = record.session_id[:8] + if expect_sid8 and sid8 != expect_sid8: + return None # the record disagrees with the live screen → screen wins + tp = record.transcript_path if not tp or not os.path.exists(tp): return None # Final identity gate: the file must self-identify as this session. internal = _transcript_internal_sid(tp) - if internal and sid8 and not internal.startswith(sid8): + if internal and not internal.startswith(sid8): return None - return {"transcript_path": tp, "session_id8": sid8} + return {"transcript_path": tp, "session_id8": sid8, "agent": record.provider} def resume_binding_for_surface(surface_ref: str, ws_ref: str) -> dict | None: @@ -498,10 +499,12 @@ def resolve_workspace_screen_and_transcript(ws_ref: str) -> dict: Resolution priority (first verified wins; otherwise None): 1. screen_session_id — id from the agent's own status bar, glob+verified. Strongest: it's what the running agent says about itself, right now. - 2. registry_live_pid — registry row for the agent pane's surface UUID, - but only with a LIVE pid and agreement with the screen. Covers a - booting agent whose status bar hasn't rendered yet. - 3. None — no verified signal. The Observer falls back to screen_text. + 2. cmux_resume_binding — the live cmux resume binding for the pane. + 3. registry_live_pid — cmux's session record for the agent pane's + surface UUID, but only with a LIVE pid and agreement with the screen + and pane agent. Covers a booting agent whose status bar hasn't + rendered yet. + 4. None — no verified signal. The Observer falls back to screen_text. (We deliberately do NOT guess by mtime/cwd/title — that was the bug.) """ pane = find_agent_pane(ws_ref) @@ -530,11 +533,11 @@ def resolve_workspace_screen_and_transcript(ws_ref: str) -> dict: source = "cmux_resume_binding" if transcript is None and surface_uuid: - reg = registry_transcript_for_surface(surface_uuid, expect_sid8=sid8) - if reg: - transcript = reg["transcript_path"] - sid8 = sid8 or reg["session_id8"] - agent = agent or "claude" + recorded = recorded_transcript_for_surface(surface_uuid, expect_sid8=sid8) + if recorded and (not agent or recorded["agent"] == agent): + transcript = recorded["transcript_path"] + sid8 = sid8 or recorded["session_id8"] + agent = agent or recorded["agent"] source = "registry_live_pid" return { @@ -641,8 +644,8 @@ def main() -> int: args = ap.parse_args() # Find the agent pane, read its screen, and resolve its transcript using - # ONLY verified signals (status-bar session id, or a live-pid registry row - # that agrees with the screen). transcript_path is None when nothing + # ONLY verified signals (status-bar session id, cmux resume binding, or a + # live-pid cmux session record that agrees with the screen). transcript_path is None when nothing # verifies — by design: a wrong transcript is worse than none. observed_at = time.time() identity_before = workspace_identity_snapshot(args.ws_ref) diff --git a/bin/cmux-restore-sessions.py b/bin/cmux-restore-sessions.py deleted file mode 100755 index 0e07f51..0000000 --- a/bin/cmux-restore-sessions.py +++ /dev/null @@ -1,409 +0,0 @@ -#!/usr/bin/env python3 -""" -Layer 3: cmux-restore-sessions — one-shot session recovery CLI. - -Run after a manual cmux restart (or at boot) to respawn Claude Code or -Factory Droid panels whose sessions did not auto-resume. - -Algorithm: -1. Read cmux state to enumerate panels with a supported agent kind. -2. For each panel that has a resumeBinding but NO live agent PID: - a. If autoResume=true, cmux handles it, so skip. - b. If the resumeBinding has a checkpointId (session_id), respawn. -3. For panels with NO resumeBinding at all, fall back to the ledger - (~/.cmux-session-ledger.jsonl) to find the last session for that panel. -4. Respawn by sending the resume command to the panel's surface. - -Usage: - python3 ~/.claude/bin/cmux-restore-sessions.py [--dry-run] [--verbose] -""" -import argparse -import json -import os -import shlex -import subprocess -import sys -from pathlib import Path - -LEDGER = Path.home() / ".cmux-session-ledger.jsonl" -CMUX_STATE = Path.home() / "Library/Application Support/cmux/session-com.cmuxterm.app.json" - - -def run_cmux(*args, timeout=5): - try: - r = subprocess.run( - ["/Applications/cmux.app/Contents/Resources/bin/cmux", *args], - capture_output=True, text=True, timeout=timeout - ) - return r.returncode, r.stdout.strip(), r.stderr.strip() - except Exception as e: - return 1, "", str(e) - - -def load_state(): - with open(CMUX_STATE) as f: - return json.load(f) - - -def load_ledger(): - """Returns {panel_id: last_start_entry} from the ledger.""" - if not LEDGER.exists(): - return {} - starts = {} - with open(LEDGER) as f: - for line in f: - line = line.strip() - if not line: - continue - try: - entry = json.loads(line) - except Exception: - continue - if entry.get("event") == "start": - panel_id = entry.get("panel_id") or entry.get("surface_id", "") - if panel_id: - existing = starts.get(panel_id) - if not existing or entry.get("ts", 0) > existing.get("ts", 0): - starts[panel_id] = entry - elif entry.get("event") == "end": - # Mark the session as ended - sid = entry.get("session_id", "") - for pid, e in list(starts.items()): - if e.get("session_id") == sid: - starts[pid]["ended"] = True - return starts - - -def is_agent_alive(pid: int) -> bool: - if not pid: - return False - try: - os.kill(pid, 0) - return True - except OSError: - return False - - -is_claude_alive = is_agent_alive - - -def pgrep_session(session_id: str) -> bool: - """Return True if any process has this agent session id in its argv.""" - if not session_id: - return False - try: - r = subprocess.run( - ["pgrep", "-f", session_id], - capture_output=True, text=True, timeout=2, - ) - return r.returncode == 0 and r.stdout.strip() != "" - except Exception: - return False - - -def normalize_provider(value): - value = str(value or "").strip().lower() - if value in {"factory", "droid"}: - return "factory" - if value == "claude": - return "claude" - return "" - - -def infer_ledger_provider(entry): - if not entry: - return "" - for key in ("provider", "agent", "kind"): - provider = normalize_provider(entry.get(key)) - if provider: - return provider - transcript = str(entry.get("transcript_path", "")).replace("\\", "/") - if "/.factory/sessions" in transcript: - return "factory" - argv = entry.get("launch_argv") or [] - if argv and os.path.basename(str(argv[0])).lower() == "droid": - return "factory" - return "" - - -def panel_provider(panel, ledger_entry=None): - agent = panel.get("terminal", {}).get("agent", {}) - return ( - normalize_provider(agent.get("kind")) - or infer_ledger_provider(ledger_entry) - or "" - ) - - -def get_all_panels(state, ledger=None): - """Yield panels belonging to Claude/Droid, including ledger-only matches.""" - ledger = ledger or {} - windows = state.get("windows", []) - for window in windows: - tab_mgr = window.get("tabManager", {}) - for ws in tab_mgr.get("workspaces", []): - for panel in ws.get("panels", []): - terminal = panel.get("terminal", {}) - agent = terminal.get("agent", {}) - panel_id = panel.get("id", "") - ledger_entry = ( - ledger.get(panel_id) or ledger.get(terminal.get("surfaceId", "")) - ) - kind = normalize_provider(agent.get("kind")) - rb = terminal.get("resumeBinding") or {} - has_session = ( - agent.get("sessionId") - or rb.get("checkpointId") - or (ledger_entry or {}).get("session_id") - ) - if has_session and (kind or ledger_entry): - yield ws, panel - - -def _strip_session_args(args): - filtered = [] - skip_next = False - value_flags = {"--resume", "-r", "--session-id", "--fork"} - switch_flags = {"--continue", "-c"} - for arg in args: - if skip_next: - skip_next = False - continue - if arg in value_flags: - skip_next = True - continue - if arg in switch_flags: - continue - if any( - arg.startswith(prefix) - for prefix in ("--resume=", "--session-id=", "--fork=") - ): - continue - filtered.append(arg) - return filtered - - -def _replace_option(args, option, value): - result = [] - skip_next = False - for arg in args: - if skip_next: - skip_next = False - continue - if arg == option: - skip_next = True - continue - if arg.startswith(option + "="): - continue - result.append(arg) - result.extend([option, value]) - return result - - -def _strip_option(args, option): - result = [] - skip_next = False - for arg in args: - if skip_next: - skip_next = False - continue - if arg == option: - skip_next = True - continue - if arg.startswith(option + "="): - continue - result.append(arg) - return result - - -def _launch_argv(agent, ledger_entry): - ledger_argv = (ledger_entry or {}).get("launch_argv") or [] - if ledger_argv: - return [str(arg) for arg in ledger_argv] - launch = agent.get("launchCommand") or {} - return [str(arg) for arg in launch.get("arguments", [])] - - -def build_resume_cmd(panel, ledger_entry=None): - """Build a provider-correct resume command for a panel.""" - terminal = panel.get("terminal", {}) - agent = terminal.get("agent", {}) - rb = terminal.get("resumeBinding", {}) or {} - provider = panel_provider(panel, ledger_entry) - if not provider: - return None, None - - # Prefer ledger session_id (most recent) over state checkpoint - if ledger_entry and not ledger_entry.get("ended"): - session_id = ( - ledger_entry.get("session_id") - or rb.get("checkpointId") - or agent.get("sessionId", "") - ) - cwd = ( - ledger_entry.get("cwd") - or rb.get("cwd") - or agent.get("workingDirectory") - or panel.get("directory", os.getcwd()) - ) - else: - session_id = rb.get("checkpointId") or agent.get("sessionId", "") - cwd = ( - rb.get("cwd") - or agent.get("workingDirectory") - or panel.get("directory", os.getcwd()) - ) - - # A previous resume binding is the most exact command. Droid bindings from - # before the GLM migration are rebuilt so the required runtime settings are - # always present. - command = rb.get("command") - droid_configured = ( - "droid-glm-settings.json" in (command or "") - and "--append-system-prompt-file" in (command or "") - and "--auto high" in (command or "") - ) - if ( - command - and session_id - and session_id in command - and (provider == "claude" or droid_configured) - ): - return cwd, command - - if not session_id: - return None, None - - args = _launch_argv(agent, ledger_entry) - default_executable = "droid" if provider == "factory" else "claude" - executable = args[0] if args else default_executable - preserved = _strip_session_args(args[1:]) - - if provider == "factory": - settings = str(Path.home() / ".assistant" / "droid-glm-settings.json") - lessons = str(Path.home() / ".claude" / "CLAUDE.md") - preserved = _replace_option(preserved, "--settings", settings) - preserved = _replace_option(preserved, "--auto", "high") - preserved = _strip_option( - preserved, "--append-system-prompt-file") - if Path(lessons).is_file(): - preserved.extend(["--append-system-prompt-file", lessons]) - - argv = [executable, "--resume", session_id, *preserved] - cmd = f"cd {shlex.quote(str(cwd))} && {shlex.join(argv)}" - - return cwd, cmd - - -def respawn_panel(ws, panel, cmd: str, dry_run: bool, verbose: bool): - panel_id = panel.get("id", "") - surface_id = panel.get("terminal", {}).get("surfaceId", panel_id) - ws_title = ws.get("customTitle", ws.get("title", "")) - - if verbose: - print(f" Respawning: ws={ws_title!r} panel={panel_id[:8]}") - print(f" Command: {cmd[:100]}...") - - if dry_run: - print(f" [DRY-RUN] Would send: {cmd}") - return True - - # Try to respawn by sending the command to the surface - rpc_params = json.dumps({"surface_id": surface_id, "text": cmd}) - rc, out, err = run_cmux("rpc", "surface.send_text", rpc_params, timeout=5) - if rc != 0: - # Fall back to surface ref - rc, out, err = run_cmux("rpc", "surface.send_text", - json.dumps({"surface_id": panel_id, "text": cmd}), timeout=5) - if rc == 0: - # Send Enter - run_cmux("rpc", "surface.send_key", - json.dumps({"surface_id": surface_id or panel_id, "key": "enter"}), timeout=5) - return True - - if verbose: - print(f" ERROR: send_text failed: {err}") - return False - - -def main(): - parser = argparse.ArgumentParser(description="Restore dead cmux agent sessions") - parser.add_argument( - "--dry-run", action="store_true", help="Print what would be done, don't do it" - ) - parser.add_argument( - "--verbose", "-v", action="store_true", help="Verbose output" - ) - args = parser.parse_args() - - try: - state = load_state() - except Exception as e: - print(f"Error reading cmux state: {e}", file=sys.stderr) - sys.exit(1) - - ledger = load_ledger() - if args.verbose: - print(f"Ledger entries: {len(ledger)}") - - restored = 0 - skipped = 0 - dead = 0 - - for ws, panel in get_all_panels(state, ledger): - terminal = panel.get("terminal", {}) - agent = terminal.get("agent", {}) - rb = terminal.get("resumeBinding") or {} - panel_id = panel.get("id", "") - ws_title = ws.get("customTitle", "") - - ledger_entry = ledger.get(panel_id) or ledger.get( - terminal.get("surfaceId", "") - ) - - # Check if the agent is alive. cmux state records agent.pid=0 in - # practice, so primary liveness check is pgrep on the session_id. - agent_pid = ( - agent.get("pid", 0) - or (ledger_entry or {}).get("agent_pid", 0) - or (ledger_entry or {}).get("pid", 0) - ) - sid = ( - ((ledger_entry or {}).get("session_id") - if not (ledger_entry or {}).get("ended") else "") - or rb.get("checkpointId") - or agent.get("sessionId", "") - ) - if is_agent_alive(agent_pid) or pgrep_session(sid): - if args.verbose: - print(f"ALIVE: {ws_title!r} (pid={agent_pid} sid={sid[:8]})") - skipped += 1 - continue - - # If autoResume=true, cmux handles it automatically - if rb.get("autoResume"): - if args.verbose: - print(f"AUTO-RESUME: {ws_title!r}, cmux handles this") - skipped += 1 - continue - - dead += 1 - if args.verbose: - print(f"DEAD: {ws_title!r} panel={panel_id[:8]}") - - cwd, cmd = build_resume_cmd(panel, ledger_entry) - if not cmd: - print(f" SKIP: no session_id for {ws_title!r}") - continue - - if respawn_panel(ws, panel, cmd, args.dry_run, args.verbose): - restored += 1 - print(f"RESTORED: {ws_title!r}") - else: - print(f"FAILED: {ws_title!r}") - - print(f"\nSummary: {restored} restored, {skipped} skipped, {dead} dead") - - -if __name__ == "__main__": - main() diff --git a/bin/cmux_sessions.py b/bin/cmux_sessions.py new file mode 100644 index 0000000..4e432ba --- /dev/null +++ b/bin/cmux_sessions.py @@ -0,0 +1,126 @@ +"""cmux_sessions — the agent sessions cmux records for its own workspaces. + +cmux's agent hooks (injected by its Claude wrapper; installed for Factory Droid +by ``cmux hooks factory install``) keep one record per agent session under +``~/.cmuxterm/-hook-sessions.json``. ``cmux sessions list --json --all`` +prints every saved record without needing the cmux socket. This module is the +one place that runs it, so readers get typed records instead of raw JSON. + +The CLI is ``$CMUX_BIN`` when set (the override every cmux caller in this repo +honors, and what the test suite points at a nonexistent binary), else an +executable ``$CMUX_BUNDLED_CLI_PATH``, else ``cmux`` on PATH, else the app +bundle's copy. Any failure to run or parse it yields ``[]`` and one line on +stderr (captured in the calling daemon's launchd .err log): a missing or broken +cmux must never take down a caller, but it shouldn't look like "no sessions". +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +APP_CLI = "/Applications/cmux.app/Contents/Resources/bin/cmux" +TIMEOUT_SEC = 5 + +# cmux's agent names for the two agents the fleet drives, mapped to the +# fleet's own provider names (agent_session.CLAUDE / agent_session.DROID). +_PROVIDERS = {"claude": "claude", "factory": "droid", "droid": "droid"} + + +@dataclass(frozen=True) +class Session: + session_id: str + agent: str + workspace_id: Optional[str] + surface_id: Optional[str] + cwd: Optional[str] + launch_cwd: Optional[str] + pid: Optional[int] + transcript_path: Optional[str] + started_at: Optional[float] + updated_at: Optional[float] + active_for_surface: bool + + @property + def provider(self) -> Optional[str]: + """``"claude"`` or ``"droid"``; None for agents the fleet doesn't run.""" + return _PROVIDERS.get(self.agent) + + +def cli_path() -> str: + override = os.environ.get("CMUX_BIN") + if override: + return override + bundled = os.environ.get("CMUX_BUNDLED_CLI_PATH") + if bundled and os.access(bundled, os.X_OK): + return bundled + return shutil.which("cmux") or APP_CLI + + +def list_sessions() -> list[Session]: + """Every session record cmux has saved, active or not.""" + cli = cli_path() + try: + result = subprocess.run( + [cli, "sessions", "list", "--json", "--all"], + capture_output=True, text=True, timeout=TIMEOUT_SEC, + ) + except (OSError, subprocess.SubprocessError) as exc: + return _unavailable(cli, exc) + if result.returncode != 0: + return _unavailable(cli, f"exit {result.returncode}: {result.stderr.strip()[:200]}") + try: + document = json.loads(result.stdout) + except ValueError: + return _unavailable(cli, "output is not JSON") + rows = document.get("sessions") if isinstance(document, dict) else None + if not isinstance(rows, list): + return _unavailable(cli, "output has no sessions list") + return [session for session in map(_parse, rows) if session is not None] + + +def _unavailable(cli, reason) -> list[Session]: + print(f"cmux_sessions: `{cli} sessions list` unavailable ({reason})", file=sys.stderr) + return [] + + +def _parse(row) -> Optional[Session]: + if not isinstance(row, dict) or not _text(row.get("session_id")): + return None + pid = row.get("pid") + updated = row.get("updated_at_unix") + return Session( + session_id=row["session_id"], + agent=str(row.get("agent") or "").lower(), + workspace_id=_text(row.get("workspace_id")), + surface_id=_text(row.get("surface_id")), + cwd=_text(row.get("cwd")), + launch_cwd=_text(row.get("launch_working_directory")), + pid=pid if type(pid) is int and pid > 0 else None, + transcript_path=_text(row.get("transcript_path")), + started_at=_epoch(row.get("started_at")), + updated_at=float(updated) if type(updated) in (int, float) else None, + active_for_surface=row.get("active_for_surface") is True, + ) + + +def _text(value) -> Optional[str]: + return value if isinstance(value, str) and value else None + + +def _epoch(value) -> Optional[float]: + if not isinstance(value, str): + return None + try: + moment = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return moment.timestamp() diff --git a/bin/session-context-watcher.py b/bin/session-context-watcher.py index fea45b5..b062313 100755 --- a/bin/session-context-watcher.py +++ b/bin/session-context-watcher.py @@ -45,9 +45,9 @@ if str(Path(__file__).resolve().parent) not in sys.path: sys.path.insert(0, str(Path(__file__).resolve().parent)) import agent_session +import cmux_sessions HOME = Path(os.environ["HOME"]) -CMUX_REGISTRY = HOME / ".claude/cmux-registry.json" WORLD_PATH = HOME / ".claude/cache/world.json" ORCHESTRATOR_REGISTRY = HOME / ".architect/orchestrator-registry.json" OUT_PATH = HOME / ".claude/cache/session-context.json" @@ -117,7 +117,7 @@ def pid_alive(pid): def load_live_agent_sessions(): - """Prefer verified world bindings; use legacy registrations without identity data.""" + """Prefer verified world bindings; use cmux's session records without identity data.""" try: world = json.loads(WORLD_PATH.read_text()) except (OSError, ValueError) as exc: @@ -134,32 +134,20 @@ def load_live_agent_sessions(): for workspace in world.get("workspaces", []) or [] if isinstance(workspace, dict) for surface in workspace.get("surfaces", []) or [] ) - reg = {} - if not identity_schema: - try: - reg = json.loads(CMUX_REGISTRY.read_text()) - except Exception: - pass + # Each surface's active record is what runs there now; earlier records on + # the same surface (a /clear, a finished session) may share its live pid. + recorded = [] if identity_schema else cmux_sessions.list_sessions() out = {} - for tab_id, entry in reg.items(): - pid = entry.get("claude_pid") - if not pid_alive(pid): - continue - sid = entry.get("session_id") - if not sid: - continue - # Multiple registry entries can share a session_id (re-registration on resume). - # Keep the most recent. - prev = out.get(sid) - if prev and prev.get("ts", 0) > entry.get("ts", 0): + for record in recorded: + if not (record.active_for_surface and record.provider and pid_alive(record.pid)): continue - out[sid] = { - "pid": int(pid), - "cwd": entry.get("cwd"), - "transcript_path": entry.get("transcript_path"), - "provider": entry.get("provider") or "claude", - "ts": entry.get("ts"), - "tab_id": tab_id, + out[record.session_id] = { + "pid": record.pid, + "cwd": record.launch_cwd or record.cwd, + "transcript_path": record.transcript_path, + "provider": record.provider, + "ts": record.updated_at, + "tab_id": record.workspace_id, } for entry in world_sessions: if not isinstance(entry, dict): @@ -636,16 +624,9 @@ def find_active_transcripts(self, cutoff=None): The cutoff argument remains accepted for compatibility. """ live = load_live_agent_sessions() - # Tag the cron workers so we can mark them. We need session_id for that - # join. The orchestrator-registry has workspace_ref; correlate by the - # cmux-registry tab→workspace mapping. Easier: for now, infer by the - # known cmux workspaces of the cron workers (workspace:104/105/106/97). - # The cmux-registry doesn't store workspace_ref directly, so we tag - # cron sessions by matching the live session's transcript path stem - # against known orchestrator workspace session IDs we've seen recently. - # Simpler approach: read orchestrator-registry → workspace_ref names, but - # to map workspace_ref → session_id we need cmux tree. Skip the precise - # mapping; just tag by cwd heuristic for now. + # Tag the cron workers so we can mark them. The orchestrator-registry + # names workers by workspace_ref, and mapping a ref to a session needs + # the cmux tree, so skip the precise join and tag by cwd instead. cron_cwds = {str(HOME / ".architect")} out = [] for sid, meta in live.items(): diff --git a/bin/transcript-tail.py b/bin/transcript-tail.py index 5c45541..23f34f4 100755 --- a/bin/transcript-tail.py +++ b/bin/transcript-tail.py @@ -20,9 +20,12 @@ import json import os import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import cmux_sessions # noqa: E402 WORLD = os.path.expanduser("~/.claude/cache/world.json") -REGISTRY = os.path.expanduser("~/.claude/cmux-registry.json") def find_via_world(ws_ref): @@ -36,19 +39,13 @@ def find_via_world(ws_ref): return None -def find_via_registry(cwd): - try: - reg = json.load(open(REGISTRY)) - except Exception: - return None - matches = [] - for tab_id, e in reg.items(): - if e.get("cwd") == cwd and e.get("transcript_path"): - matches.append((e.get("started_at", ""), e["transcript_path"])) +def find_via_cmux_sessions(cwd): + """The most recently active session cmux recorded in `cwd` (current or launch directory).""" + matches = [s for s in cmux_sessions.list_sessions() + if s.transcript_path and cwd in (s.cwd, s.launch_cwd)] if not matches: return None - matches.sort(reverse=True) - return matches[0][1] + return max(matches, key=lambda s: s.updated_at or 0).transcript_path def read_tail(path, n_bytes): @@ -107,7 +104,7 @@ def main(): if args.ws: path = find_via_world(args.ws) if not path and args.closed_cwd: - path = find_via_registry(args.closed_cwd) + path = find_via_cmux_sessions(args.closed_cwd) if not path or not os.path.exists(path): print(json.dumps({"error": "transcript_not_found", "tried_ws": args.ws, diff --git a/bin/world-scanner.py b/bin/world-scanner.py index 38ee104..c5895a6 100755 --- a/bin/world-scanner.py +++ b/bin/world-scanner.py @@ -9,7 +9,8 @@ Inputs: - cmux tree (live workspaces + surfaces + tty) - ps (which Claude PIDs are alive) - - ~/.claude/cmux-registry.json (session_id ↔ tab_id ↔ cwd ↔ transcript_path) + - `cmux sessions list --json --all` (cmux's own agent session records: + session_id ↔ workspace/surface UUID ↔ cwd ↔ transcript_path ↔ pid ↔ start) - ~/.architect/orchestrator-registry.json (workers list — for is_cron tag) - ~/.claude/cache/session-context.json (transcript turns; maintained by watcher) - ~/.claude/cache/dashboard-state.json (workspace classifications + screen hashes) @@ -21,17 +22,17 @@ - vm_stat (memory pressure) - ~/.assistant/events.jsonl (event-spine health: counts + latest-event age per source, so a stalled spine is visible) - - ~/.cmux-session-ledger.jsonl (first recorded start for a verified identity) Cadence: 30s via LaunchAgent. Stdlib only. Workspace and surface UUIDs identify current cmux objects. A session's -identity_status is verified when a live resume binding and process agree, or a -SessionStart registry entry matches both UUIDs and a foreground provider process -whose start predates that hook. Registry keys are workspace UUIDs, not surfaces. +identity_status is verified when a live resume binding and process agree, or +cmux's active session record for the surface matches both UUIDs and a foreground +provider process that started before the record's latest hook update. context_status verifies the cached session/provider association, not freshness; context_built_at supplies the cache clock. Missing bindings and unsupported -providers stay unknown. first_recorded_at is not a session creation timestamp. +providers stay unknown. first_recorded_at is cmux's recorded start for the +session (started_at), which a session restored into another workspace keeps. Internal session keys include provider, full session ID, workspace UUID, and surface UUID, so resumed sessions retain each binding in the exported list. """ @@ -46,6 +47,9 @@ from datetime import datetime, timedelta, timezone from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import cmux_sessions # noqa: E402 + # The canonical connector tri-state (not_configured|ok|error) + the known- # connector registry live in the connector base, so world-scanner, the brief # and the dashboard never drift on what "connected" means. Import it the way the @@ -68,10 +72,8 @@ HOME = Path(os.environ["HOME"]) OUT_PATH = HOME / ".claude/cache/world.json" -CMUX_REGISTRY = HOME / ".claude/cmux-registry.json" ORCH_REGISTRY = HOME / ".architect/orchestrator-registry.json" SESSION_CTX = HOME / ".claude/cache/session-context.json" -SESSION_LEDGER = HOME / ".cmux-session-ledger.jsonl" DASHBOARD_STATE = HOME / ".claude/cache/dashboard-state.json" TODO_PATH = HOME / ".claude/assistant-todo.json" PROPOSALS_DIR = HOME / ".architect/orchestrator-proposals" @@ -319,40 +321,41 @@ def agent_pid_on_tty(tty, provider): return process[0] if process else None -def registry_binding_for_surface(registry, workspace, surface): - """Verify the hook's workspace key, surface UUID, session, and process age.""" +def recorded_binding_for_surface(records, workspace, surface): + """Verify cmux's active record for this surface: both UUIDs, pid, and process age. + + A resumed session keeps its original started_at, so the age check uses the + record's latest hook update, which the current process must have written. + """ workspace_id = workspace.get("workspace_id") surface_id = surface.get("surface_id") tty = surface.get("tty") if not workspace_id or not surface_id or not tty: return None matches = [ - entry for key, entry in registry.items() - if key.lower() == str(workspace_id).lower() - and str(entry.get("surface_id") or "").lower() == str(surface_id).lower() + record for record in records + if record.active_for_surface + and str(record.workspace_id or "").lower() == str(workspace_id).lower() + and str(record.surface_id or "").lower() == str(surface_id).lower() ] if len(matches) != 1: return None - entry = matches[0] - if entry.get("workspace_id") and str(entry["workspace_id"]).lower() != str(workspace_id).lower(): - return None - sid = entry.get("session_id") - provider = normalize_provider(entry.get("provider") or "claude") - recorded = entry.get("ts") - if not isinstance(sid, str) or not sid or provider not in {"claude", "droid"}: - return None - if isinstance(recorded, bool) or not isinstance(recorded, (int, float)): + record = matches[0] + provider = record.provider + recorded = record.updated_at + if provider is None or recorded is None: return None process = foreground_provider_process(tty, provider) if process is None: return None pid, started = process - if entry.get("claude_pid") and str(entry["claude_pid"]) != str(pid): + if record.pid and record.pid != pid: return None if not started <= recorded < utc_now().timestamp() + 1: return None return { - "session_id": sid, "provider": provider, "cwd": entry.get("cwd"), + "session_id": record.session_id, "provider": provider, + "cwd": record.launch_cwd or record.cwd, "pid": pid, "ts": recorded, "tab_id": workspace_id, "identity_source": "session_start_registry", } @@ -367,39 +370,33 @@ def session_identity_key(session): ) -def build_live_sessions(workspaces=None): - """Build the canonical live-session list by joining cmux-registry (records - every Claude session ever) with pid-alive filter. Each entry carries - session_id, pid, cwd, tty, transcript_path, and the workspace_ref it lives - in (best-effort via tty join with cmux tree).""" - reg = load_json(CMUX_REGISTRY, {}) +def build_live_sessions(workspaces=None, records=()): + """Build the canonical live-session list from cmux's session records (one + per agent session cmux has seen) with a pid-alive filter, then bind each + terminal surface. Each entry carries session_id, pid, cwd, tty, + transcript_path, and the workspace_ref it lives in (best-effort via tty + join with cmux tree).""" out = {} - for tab_id, e in reg.items(): - pid = e.get("claude_pid") - if not pid_alive(pid): - continue - sid = e.get("session_id") - if not sid: + for record in records: + # Only each surface's active record: earlier sessions on the surface + # (a /clear, a finished run) can share its still-live pid. + if not (record.active_for_surface and record.provider and pid_alive(record.pid)): continue session = { - "session_id": sid, - "pid": int(pid), - "cwd": e.get("cwd"), - "transcript_path": e.get("transcript_path"), - "provider": normalize_provider(e.get("provider") or "claude"), - "tab_id": tab_id, - "ts": e.get("ts"), - "workspace_id": e.get("workspace_id") or (tab_id if e.get("surface_id") else None), - "surface_id": e.get("surface_id"), + "session_id": record.session_id, + "pid": record.pid, + "cwd": record.launch_cwd or record.cwd, + "transcript_path": record.transcript_path, + "provider": record.provider, + "tab_id": record.workspace_id, + "ts": record.updated_at, + "workspace_id": record.workspace_id, + "surface_id": record.surface_id, "identity_status": "unknown", "identity_source": "registry", "agent_status": "unknown", } - key = session_identity_key(session) - prev = out.get(key) - if prev and prev.get("ts", 0) > e.get("ts", 0): - continue - out[key] = session + out[session_identity_key(session)] = session for ws in workspaces or []: for surface in ws.get("surfaces", []): if surface.get("type") != "terminal": @@ -409,7 +406,7 @@ def build_live_sessions(workspaces=None): ws.get("workspace_id") or ws.get("ws_ref"), ) if binding is None: - binding = registry_binding_for_surface(reg, ws, surface) + binding = recorded_binding_for_surface(records, ws, surface) if not binding or binding.get("unverified"): continue sid = binding["session_id"] @@ -586,8 +583,8 @@ def normalize_provider(provider): return "droid" if provider in {"factory", "droid"} else provider -def merge_first_recorded(live_sessions, now): - """Join recorded starts by workspace UUID, provider, and full session ID.""" +def merge_first_recorded(live_sessions, records, now): + """Join cmux's recorded starts by workspace UUID, provider, and full session ID.""" targets = {} for session in live_sessions.values(): session["first_recorded_at"] = None @@ -600,34 +597,14 @@ def merge_first_recorded(live_sessions, now): ) if all(key): targets.setdefault(key, []).append(session) - if not targets: - return first = {} - try: - with SESSION_LEDGER.open(errors="replace") as ledger: - for line in ledger: - try: - row = json.loads(line) - except ValueError: - continue - if not isinstance(row, dict) or row.get("event") != "start": - continue - key = ( - str(row.get("workspace_id") or "").lower(), - normalize_provider(row.get("provider")), - row.get("session_id"), - ) - if not isinstance(key[2], str) or key not in targets: - continue - timestamp = row.get("ts") - if isinstance(timestamp, bool) or not isinstance(timestamp, (int, float)): - continue - if not 0 < timestamp <= now.timestamp(): - continue - if key not in first or timestamp < first[key]: - first[key] = timestamp - except OSError: - return + for record in records: + key = (str(record.workspace_id or "").lower(), record.provider, record.session_id) + timestamp = record.started_at + if key not in targets or timestamp is None or not 0 < timestamp <= now.timestamp(): + continue + if key not in first or timestamp < first[key]: + first[key] = timestamp for key, timestamp in first.items(): recorded = datetime.fromtimestamp(timestamp, tz=timezone.utc).replace(microsecond=0) for session in targets[key]: @@ -777,11 +754,12 @@ def build(): tree = cmux_tree() workspaces = build_workspace_index(tree) - live_sessions = build_live_sessions(workspaces) + records = cmux_sessions.list_sessions() + live_sessions = build_live_sessions(workspaces, records) join_workspaces_to_sessions(workspaces, live_sessions) tag_cron_workers(live_sessions) merge_session_context(live_sessions) - merge_first_recorded(live_sessions, now) + merge_first_recorded(live_sessions, records, now) # Compute per-session activity age and bucket. for sess in live_sessions.values(): diff --git a/hooks/cmux-auto-resume.py b/hooks/cmux-auto-resume.py index b4b3636..a55555e 100755 --- a/hooks/cmux-auto-resume.py +++ b/hooks/cmux-auto-resume.py @@ -1,8 +1,13 @@ #!/usr/bin/env python3 -"""Layer 1: register a provider-correct cmux resume binding on SessionStart. - -Supports both Claude Code and Factory Droid. The hook is deliberately silent -on every failure because a restore helper must never break the agent session. +"""Register a provider-correct cmux session record and resume binding on SessionStart. + +cmux's Claude wrapper injects its own hooks, but an agent started without the +wrapper (an absolute `~/.local/bin/claude`, or `claude` ahead of the wrapper on +PATH) gets none. This hook forwards SessionStart to cmux's own +`cmux hooks session-start` so cmux still records that session (what +`cmux sessions list` and `surface resume show` read), and sets a resume binding +when the wrapper didn't. Supports both Claude Code and Factory Droid. The hook +is deliberately silent on every failure: it must never break the agent session. """ import json import os @@ -115,7 +120,7 @@ def main(): # Fallback: if wrapper didn't set a binding, set one ourselves. # We can't set autoResume=true via the CLI, but we set the correct resume - # command so Layer 3 (cmux-restore-sessions) can find and respawn this panel. + # command so `cmux restore --surface` can respawn this panel. if not os.environ.get("CMUX_AGENT_LAUNCH_ARGV_B64"): default_executable = "droid" if provider == "factory" else "claude" executable = os.environ.get( diff --git a/hooks/cmux-session-ledger.py b/hooks/cmux-session-ledger.py deleted file mode 100755 index 38bae4a..0000000 --- a/hooks/cmux-session-ledger.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 -""" -Layer 2: Append-only session ledger. - -Fires on SessionStart and Stop (via the event arg in argv[1]). -Appends one JSON line to ~/.cmux-session-ledger.jsonl per event so that -cmux-restore-sessions can reconstruct the session→workspace mapping even -if cmux state is wiped. - -SessionStart line: - {"event":"start","ts":..., "session_id":"...", "cwd":"...", "transcript_path":"...", - "provider":"factory", "agent":"droid", "ws_title":"...", "panel_id":"...", - "workspace_id":"...", "agent_pid":...} - -Stop/SessionEnd line: - {"event":"end","ts":..., "session_id":"...", "provider":"factory", - "agent":"droid", "agent_pid":...} -""" -import base64 -import fcntl -import json -import os -import sys -import time - -LEDGER = os.path.expanduser("~/.cmux-session-ledger.jsonl") -LOCK = LEDGER + ".lock" - - -def normalize_provider(value): - value = str(value or "").strip().lower() - if value in {"factory", "droid"}: - return "factory", "droid" - if value == "claude": - return "claude", "claude" - return "", "" - - -def infer_provider(payload): - for key in ("provider", "agent", "agent_type", "agentType", "kind"): - provider, agent = normalize_provider(payload.get(key)) - if provider: - return provider, agent - - transcript = str( - payload.get("transcript_path") or payload.get("transcriptPath") or "" - ).replace("\\", "/") - if "/.factory/sessions" in transcript: - return "factory", "droid" - if "/.claude/" in transcript: - return "claude", "claude" - - provider, agent = normalize_provider(os.environ.get("CMUX_AGENT_LAUNCH_KIND")) - if provider: - return provider, agent - executable = os.path.basename( - os.environ.get("CMUX_AGENT_LAUNCH_EXECUTABLE", "") - ).lower() - if executable == "droid": - return "factory", "droid" - if executable == "claude": - return "claude", "claude" - return "", "" - - -def agent_pid(provider): - names = ( - ( - "CMUX_AGENT_PID", - "CMUX_DROID_PID", - "CMUX_FACTORY_PID", - "CMUX_CLAUDE_PID", - ) - if provider == "factory" - else ( - "CMUX_AGENT_PID", - "CMUX_CLAUDE_PID", - "CMUX_DROID_PID", - "CMUX_FACTORY_PID", - ) - ) - for name in names: - try: - value = int(os.environ.get(name, 0)) - except (TypeError, ValueError): - continue - if value: - return value - return 0 - - -def launch_argv(): - encoded = os.environ.get("CMUX_AGENT_LAUNCH_ARGV_B64", "") - if not encoded: - executable = os.environ.get("CMUX_AGENT_LAUNCH_EXECUTABLE", "") - return [executable] if executable else [] - try: - raw = base64.b64decode(encoded).decode() - return [arg for arg in raw.split("\0") if arg] - except Exception: - return [] - - -def append_line(entry: dict): - os.makedirs(os.path.dirname(LEDGER) or ".", exist_ok=True) - with open(LOCK, "a+") as lockf: - fcntl.flock(lockf, fcntl.LOCK_EX) - with open(LEDGER, "a") as f: - f.write(json.dumps(entry) + "\n") - - -def main(): - event = sys.argv[1] if len(sys.argv) > 1 else "start" - - try: - payload = json.load(sys.stdin) - except Exception: - payload = {} - - sid = payload.get("session_id") or payload.get("sessionId", "") - if not sid: - return - - ts = time.time() - provider, agent = infer_provider(payload) - if not provider: - return - pid = agent_pid(provider) - - if event == "start": - # Get workspace title from cmux if possible - ws_title = "" - try: - import subprocess - result = subprocess.run( - ["cmux", "identify", "--json"], - capture_output=True, text=True, timeout=2 - ) - if result.returncode == 0: - info = json.loads(result.stdout) - ws_title = ( - info.get("caller", {}).get("workspace", {}).get("title", "") - ) - except Exception: - pass - - entry = { - "event": "start", - "ts": ts, - "session_id": sid, - "provider": provider, - "agent": agent, - "cwd": payload.get("cwd", os.getcwd()), - "transcript_path": ( - payload.get("transcript_path") or payload.get("transcriptPath", "") - ), - "ws_title": ws_title, - "panel_id": os.environ.get("CMUX_PANEL_ID", ""), - "surface_id": os.environ.get("CMUX_SURFACE_ID", ""), - "workspace_id": os.environ.get("CMUX_WORKSPACE_ID", ""), - "launch_argv": launch_argv(), - "agent_pid": pid, - "pid": pid, - } - else: - entry = { - "event": "end", - "ts": ts, - "session_id": sid, - "provider": provider, - "agent": agent, - "agent_pid": pid, - "pid": pid, - } - - append_line(entry) - - -if __name__ == "__main__": - try: - main() - except Exception: - pass diff --git a/install.sh b/install.sh index c2d1e73..abe51cb 100755 --- a/install.sh +++ b/install.sh @@ -97,9 +97,9 @@ After --apply: (--with-memory), workspace-watcher (--with-crash-resume), cmux-ws-numberer (--with-ws-numberer); comms + slack-reactor copied but hand-loaded after token setup - - cmux session-restore (vendored): hooks/ → ~/.claude/hooks/ (symlinks), - bin/cmux-restore-sessions.py → ~/.local/bin/cmux-restore-sessions, - and ~/.claude/settings.json SessionStart/SessionEnd hooks patched in + - cmux records agent sessions itself: hooks/cmux-auto-resume.py → ~/.claude/hooks/ + for agents started outside cmux's wrapper, Factory hooks via cmux, and the + retired hand-written session hooks removed from Claude and Factory settings - launchd: kickstart -k each agent (load if not loaded) Skills are symlinked (not copied), so the repo is the single source of truth: @@ -109,7 +109,7 @@ real directory at the target is backed up to ~/.claude/skills-backups/ before the symlink replaces it; recover edits from there with --pull-skills. What is NOT touched: - - ~/.claude/cache/, ~/.claude/projects/, ~/.claude/cmux-registry.json + - ~/.claude/cache/, ~/.claude/projects/ - ~/.claude/assistant-todo.json, ~/.claude/assistant-dashboard.html - ~/.claude/skills/cmux/ and any other unrelated skills - ~/.architect/ @@ -775,22 +775,29 @@ else fi log "" -# --- 4. cmux session-restore (vendored) ------------------------------------- -# Three layers that make Claude and Factory Droid panes survive restart/reboot: -# Layer 1+2 hooks/cmux-auto-resume.py + cmux-session-ledger.py -# → symlinked into ~/.claude/hooks/ (a MIXED dir — symlink the -# files individually, never the directory) -# Layer 3 bin/cmux-restore-sessions.py → ~/.local/bin/cmux-restore-sessions -# settings install/patch-settings.py registers the SessionStart/SessionEnd -# hook commands in Claude settings and Factory hooks (idempotent) -# Vendored from the former elitecoder/cmux-session-restore repo so a single -# `assistant install --apply` rebuilds all three layers from git on any machine -# — claude.tgz-style home backups only capture ~/.claude and silently drop the -# ~/.local/bin CLI, leaving a deceptive half-working state. -log "[4/5] Wiring cmux session-restore (hooks + CLI + settings)" -ensure_symlink "$HOME_DIR/.claude/hooks/cmux-auto-resume.py" "$REPO_ROOT/hooks/cmux-auto-resume.py" -ensure_symlink "$HOME_DIR/.claude/hooks/cmux-session-ledger.py" "$REPO_ROOT/hooks/cmux-session-ledger.py" -ensure_symlink "$HOME_DIR/.local/bin/cmux-restore-sessions" "$REPO_ROOT/bin/cmux-restore-sessions.py" +# --- 4. cmux session hooks ------------------------------------------------- +# cmux records agent sessions itself (`cmux sessions list --json`, read by +# bin/cmux_sessions.py) and restores them (`cmux restore-session`). Its Claude +# wrapper injects its own hooks; Factory Droid gets them from +# `cmux hooks factory install`. hooks/cmux-auto-resume.py forwards SessionStart +# to cmux for agents started WITHOUT the wrapper (absolute-path or PATH-order +# launches), so it stays, symlinked into ~/.claude/hooks/ (a MIXED dir — link +# files individually, never the directory). The ledger hook and restore CLI are +# retired: drop the links an older install made to them (only the exact links +# into this repo), and install/patch-settings.py removes their registrations. +log "[4/5] cmux session hooks (native) + retired hook cleanup" +ensure_symlink "$HOME_DIR/.claude/hooks/cmux-auto-resume.py" "$REPO_ROOT/hooks/cmux-auto-resume.py" +remove_retired_link() { + local link="$1" target="$2" + if [[ -L "$link" && "$(readlink "$link")" == "$target" ]]; then + note "REMOVE retired link $link (cmux records sessions itself)" + if [[ $APPLY -eq 1 ]]; then + rm "$link" + fi + fi +} +remove_retired_link "$HOME_DIR/.claude/hooks/cmux-session-ledger.py" "$REPO_ROOT/hooks/cmux-session-ledger.py" +remove_retired_link "$HOME_DIR/.local/bin/cmux-restore-sessions" "$REPO_ROOT/bin/cmux-restore-sessions.py" if [[ $APPLY -eq 1 ]]; then CMUX_CLI="/Applications/cmux.app/Contents/Resources/bin/cmux" if [[ -x "$CMUX_CLI" ]]; then @@ -805,7 +812,7 @@ if [[ $APPLY -eq 1 ]]; then python3 "$REPO_ROOT/install/patch-settings.py" \ "$HOME_DIR/.claude/settings.json" "$HOME_DIR/.factory/hooks.json" \ 2>&1 | sed 's/^/ /' \ - || warn "patch-settings failed (continuing — session-restore hooks may be incomplete)" + || warn "patch-settings failed (continuing — retired session hooks may still be registered)" else note "would install cmux Factory hooks and patch Claude + Factory lifecycle hooks" fi diff --git a/install/patch-settings.py b/install/patch-settings.py index 04708ab..314739f 100755 --- a/install/patch-settings.py +++ b/install/patch-settings.py @@ -1,14 +1,21 @@ #!/usr/bin/env python3 -"""Idempotent hook patcher for Claude settings and Factory hooks. +"""Idempotent removal of retired cmux session hooks from agent hook settings. -Ensures: - - SessionStart contains: cmux-session-ledger.py start - - SessionEnd contains: cmux-session-ledger.py end - - Stop does NOT contain: cmux-session-ledger.py end (legacy bug) +cmux records agent sessions itself (`cmux sessions list --json`), so two +hand-written hooks that kept their own copy of that mapping are retired: -Accepts one or more paths, including ``~/.claude/settings.json`` and -``~/.factory/hooks.json``. Existing files are backed up before writing; newly -created files are not. + - cmux-session-ledger.py appended start/end events to a JSONL ledger + - cmux-registry.py mapped each cmux workspace to its latest session + +hooks/cmux-auto-resume.py stays: it is what makes cmux record agents started +without cmux's Claude wrapper. + +This drops every hook whose command runs a retired script, under every event, +plus any matcher block or event it leaves empty. Accepts one or more paths, +including ``~/.claude/settings.json`` and ``~/.factory/hooks.json``. A file is +rewritten only when something was removed, and is backed up first. A missing, +malformed, or unreadable file is left alone; an unreadable one fails the run +after the remaining paths are handled. """ import json import os @@ -17,92 +24,68 @@ import time from pathlib import Path -LEDGER_START_CMD = "python3 $HOME/.claude/hooks/cmux-session-ledger.py start" -LEDGER_END_CMD = "python3 $HOME/.claude/hooks/cmux-session-ledger.py end" - - -def has_command(blocks, cmd): - for block in blocks or []: - for h in block.get("hooks", []): - if h.get("command") == cmd: - return True - return False - - -def add_hook(blocks, cmd, timeout): - blocks.append({ - "matcher": "", - "hooks": [{"type": "command", "command": cmd, "timeout": timeout}], - }) - - -def remove_command(blocks, cmd): - out = [] - for block in blocks or []: - kept = [h for h in block.get("hooks", []) if h.get("command") != cmd] - if kept: - block["hooks"] = kept - out.append(block) - return out +RETIRED_HOOK_SCRIPTS = ( + "cmux-session-ledger.py", + "cmux-registry.py", +) + + +def is_retired(entry): + command = entry.get("command") if isinstance(entry, dict) else None + return any(os.path.basename(word) in RETIRED_HOOK_SCRIPTS + for word in str(command or "").split()) + + +def remove_retired(hooks): + """Drop retired commands from `hooks` in place; return what was removed.""" + removed = [] + for event, blocks in list(hooks.items()): + if not isinstance(blocks, list): + continue + kept_blocks = [] + for block in blocks: + entries = block.get("hooks") if isinstance(block, dict) else None + if isinstance(entries, list) and any(map(is_retired, entries)): + removed += [f"{event}: {entry['command']}" for entry in entries if is_retired(entry)] + block["hooks"] = [entry for entry in entries if not is_retired(entry)] + if not block["hooks"]: + continue + kept_blocks.append(block) + if kept_blocks: + hooks[event] = kept_blocks + elif blocks: + del hooks[event] + return removed def patch_path(path): path = Path(path).expanduser() # Atomic replace on a SYMLINK would sever it (swap the link for a regular - # file), disconnecting an operator/machine-config-managed target. These are - # functional session-restore hooks that must still apply, so resolve to the - # real file and write THROUGH the symlink instead of replacing it. + # file), disconnecting an operator/machine-config-managed target, so + # resolve to the real file and write THROUGH the symlink instead. write_target = Path(os.path.realpath(path)) if path.is_symlink() else path - existed = path.exists() - settings = {} - if existed: - try: - loaded = json.loads(path.read_text()) - settings = loaded if isinstance(loaded, dict) else {} - except json.JSONDecodeError: - # A corrupt settings file must be recovered (backed up + rebuilt), - # not raise — that would abort the installer under `set -e`. - corrupt = path.with_suffix(path.suffix + f".corrupt-{int(time.time())}") - shutil.copy2(path, corrupt) - print(f" {path} was malformed JSON — backed up to {corrupt}, rebuilding") - settings = {} - hooks = settings.setdefault("hooks", {}) - - ss = hooks.setdefault("SessionStart", []) - se = hooks.setdefault("SessionEnd", []) - stop = hooks.setdefault("Stop", []) - - changed = False - - if not has_command(ss, LEDGER_START_CMD): - add_hook(ss, LEDGER_START_CMD, 5) - print(f" + SessionStart: {LEDGER_START_CMD}") - changed = True - else: - print(f" = SessionStart already has {LEDGER_START_CMD}") - - if not has_command(se, LEDGER_END_CMD): - add_hook(se, LEDGER_END_CMD, 5) - print(f" + SessionEnd: {LEDGER_END_CMD}") - changed = True - else: - print(f" = SessionEnd already has {LEDGER_END_CMD}") - - if has_command(stop, LEDGER_END_CMD): - hooks["Stop"] = remove_command(stop, LEDGER_END_CMD) - print(f" - Stop: removed legacy {LEDGER_END_CMD}") - changed = True - - if not changed: + if not path.exists(): + print(f" {path} does not exist — nothing to remove") + return False + try: + settings = json.loads(path.read_text()) + except json.JSONDecodeError: + print(f" {path} is malformed JSON — left untouched") + return False + hooks = settings.get("hooks") if isinstance(settings, dict) else None + if not isinstance(hooks, dict): print(" no changes needed") return False + removed = remove_retired(hooks) + if not removed: + print(" no changes needed") + return False + for line in removed: + print(f" - {line}") - if write_target.exists(): - bak = write_target.with_suffix( - write_target.suffix + f".bak-{int(time.time())}") - shutil.copy2(write_target, bak) - print(f" backed up to {bak}") - write_target.parent.mkdir(parents=True, exist_ok=True) + bak = write_target.with_suffix(write_target.suffix + f".bak-{int(time.time())}") + shutil.copy2(write_target, bak) + print(f" backed up to {bak}") # Atomic write: a crash mid-write must not truncate settings.json (which # would brick every later `--apply`). A pid-unique tmp name avoids two # concurrent `--apply` runs racing on a shared tmp path. @@ -120,10 +103,16 @@ def main(): file=sys.stderr, ) raise SystemExit(2) + failed = False for index, raw_path in enumerate(sys.argv[1:]): if index: print() - patch_path(raw_path) + try: + patch_path(raw_path) + except (OSError, ValueError) as exc: + print(f" {raw_path} could not be cleaned: {exc}", file=sys.stderr) + failed = True + raise SystemExit(1 if failed else 0) if __name__ == "__main__": diff --git a/tests/README.md b/tests/README.md index 19181d8..8ec1fe9 100644 --- a/tests/README.md +++ b/tests/README.md @@ -73,8 +73,8 @@ statement, rather than disappearing from the count. Python coverage of an HTML string doesn't count as JavaScript coverage. Layout and CSS still need browser measurements and visual inspection. -The current coverage adapters cover Python under `bin/` and `src/` and the -dashboard's embedded JavaScript. Changes to other executable code fail until +The current coverage adapters cover Python under `bin/`, `hooks/`, `install/`, +and `src/` and the dashboard's embedded JavaScript. Changes to other executable code fail until an adapter is added; they aren't silently treated as covered. The checked-in workflow runs these checks for pull requests and pushes to diff --git a/tests/check_change_coverage.py b/tests/check_change_coverage.py index 91161dc..f784e2c 100644 --- a/tests/check_change_coverage.py +++ b/tests/check_change_coverage.py @@ -15,7 +15,7 @@ from coverage.parser import PythonParser from mutation_smoke import CHECKS -from run_python_coverage import canonical_report_hash +from run_python_coverage import PYTHON_SOURCE_DIRS, canonical_report_hash # Shell files with no line-coverage tool; each changed code hunk needs a # mutation_smoke check that breaks only changed code lines in it. A check can @@ -40,11 +40,11 @@ def changed_lines(repo: Path, base: str) -> dict[str, set[int]]: if name in MUTATION_PROVEN: continue if file.suffix in {".py", ".js", ".cjs", ".mjs", ".ts", ".tsx", ".jsx", ".sh"}: - if file.suffix != ".py" or file.parts[0] not in {"bin", "src"}: + if file.suffix != ".py" or file.parts[0] not in PYTHON_SOURCE_DIRS: raise ValueError(f"Coverage is not configured for changed executable code: {name}") result = subprocess.run( ["git", "-C", str(repo), "diff", "--no-ext-diff", "--unified=0", base_commit, - "--", "bin", "src"], check=True, capture_output=True, text=True) + "--", *PYTHON_SOURCE_DIRS], check=True, capture_output=True, text=True) files: dict[str, set[int]] = {} name = None line = 0 @@ -72,7 +72,7 @@ def changed_lines(repo: Path, base: str) -> dict[str, set[int]]: line += 1 untracked = subprocess.check_output( ["git", "-C", str(repo), "ls-files", "--others", "--exclude-standard", "-z", - "--", "bin", "src"], text=True) + "--", *PYTHON_SOURCE_DIRS], text=True) for name in untracked.split("\0"): if name.endswith(".py"): files[name] = set(range(1, len((repo / name).read_text().splitlines()) + 1)) diff --git a/tests/fake_cmux.py b/tests/fake_cmux.py new file mode 100644 index 0000000..3308798 --- /dev/null +++ b/tests/fake_cmux.py @@ -0,0 +1,78 @@ +"""A stand-in `cmux` executable for `cmux sessions list --json`. + +`install(directory, records)` writes an executable `cmux` into `directory` that +answers `sessions list` with the given records in cmux 0.64.25's JSON shape and +logs each argv to `cmux-argv.jsonl` beside it. Point `CMUX_BIN` at the returned +path so bin/cmux_sessions.py runs it as a real subprocess. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +SCRIPT = """#!{python} +import json, pathlib, sys +here = pathlib.Path(__file__).resolve().parent +with open(here / "cmux-argv.jsonl", "a") as log: + log.write(json.dumps(sys.argv[1:]) + "\\n") +if sys.argv[1:3] != ["sessions", "list"]: + sys.exit(2) +sys.stdout.write((here / "cmux-sessions.out").read_text()) +sys.exit(int((here / "cmux-sessions.rc").read_text())) +""" + + +def record(session_id: str, **fields) -> dict: + """One `sessions` row as cmux prints it, with fixture defaults. The launch + directory defaults to the cwd, as it is for most live sessions.""" + fields.setdefault("launch_working_directory", fields.get("cwd", "/work")) + row = { + "session_id": session_id, + "agent": "claude", + "agent_display_name": "Claude Code", + "agent_lifecycle": "running", + "workspace_id": "AAAAAAAA-0000-0000-0000-000000000001", + "surface_id": "BBBBBBBB-0000-0000-0000-000000000001", + "cwd": "/work", + "launch_working_directory": "/work", + "pid": None, + "transcript_path": None, + "started_at": "2026-10-18T15:48:38.207Z", + "updated_at": "2026-10-19T14:07:50.256Z", + "updated_at_unix": 1792418870.2557909, + "active_for_surface": True, + "active_for_workspace": True, + "is_restorable": True, + "store_path": "/fixture/.cmuxterm/claude-hook-sessions.json", + } + row.update(fields) + return row + + +def write(directory: Path, records=(), *, stdout: str | None = None, returncode: int = 0) -> None: + """Replace what the fake prints. `stdout` overrides the JSON document.""" + if stdout is None: + stdout = json.dumps({ + "sessions": list(records), "total_matches": len(records), "limit": None, + "state_dir": "/fixture/.cmuxterm", "stores": [], + }, indent=2) + (directory / "cmux-sessions.out").write_text(stdout) + (directory / "cmux-sessions.rc").write_text(str(returncode)) + + +def install(directory: Path, records=(), **kwargs) -> Path: + directory.mkdir(parents=True, exist_ok=True) + cli = directory / "cmux" + cli.write_text(SCRIPT.format(python=sys.executable)) + cli.chmod(0o755) + write(directory, records, **kwargs) + return cli + + +def calls(directory: Path) -> list[list[str]]: + log = directory / "cmux-argv.jsonl" + if not log.exists(): + return [] + return [json.loads(line) for line in log.read_text().splitlines()] diff --git a/tests/mutation_smoke.py b/tests/mutation_smoke.py index 1712935..b6b7630 100644 --- a/tests/mutation_smoke.py +++ b/tests/mutation_smoke.py @@ -154,6 +154,99 @@ "", "tests/test_install_optin_prompt.py::test_apply_removes_the_legacy_ws_numberer_plist", ), + ( + "cmux session records include every saved record", + "bin/cmux_sessions.py", + '[cli, "sessions", "list", "--json", "--all"],', + '[cli, "sessions", "list", "--json"],', + "tests/test_cmux_sessions.py::test_lists_every_saved_record_with_typed_fields", + ), + ( + "a session cleared off its surface is not live", + "bin/world-scanner.py", + "if not (record.active_for_surface and record.provider and pid_alive(record.pid)):", + "if not (record.provider and pid_alive(record.pid)):", + "tests/test_world_scanner.py::test_build_live_sessions_keeps_live_active_fleet_records", + ), + ( + "a resumed session is verified by its latest hook update", + "bin/world-scanner.py", + " recorded = record.updated_at\n", + " recorded = record.started_at\n", + "tests/test_world_scanner.py::test_current_session_record_verifies_missing_native_binding", + ), + ( + "a dead-pid cmux record never attaches a transcript", + "bin/build-ws-context.py", + " if not _pid_alive(record.pid):\n return None # stale record — do NOT trust\n", + "", + "tests/test_build_ws_context_in_process.py::RecordedTranscriptTests::test_dead_pid_record_rejected", + ), + ( + "help says cmux records sessions itself", + "install.sh", + " - cmux records agent sessions itself: hooks/cmux-auto-resume.py → ~/.claude/hooks/\n" + " for agents started outside cmux's wrapper, Factory hooks via cmux, and the\n" + " retired hand-written session hooks removed from Claude and Factory settings\n", + "", + "tests/test_install_cmux_sessions.py::test_help_documents_native_cmux_sessions", + ), + ( + "help no longer lists the registry among untouched files", + "install.sh", + " - ~/.claude/cache/, ~/.claude/projects/\n", + " - ~/.claude/cache/\n", + "tests/test_install_cmux_sessions.py::test_help_documents_native_cmux_sessions", + ), + ( + "apply removes a retired link", + "install.sh", + ' rm "$link"\n', + " :\n", + "tests/test_install_cmux_sessions.py::test_apply_removes_the_retired_links_and_registrations", + ), + ( + "a dry run keeps retired links", + "install.sh", + ' if [[ $APPLY -eq 1 ]]; then\n rm "$link"\n', + ' if true; then\n rm "$link"\n', + "tests/test_install_cmux_sessions.py::test_dry_run_reports_without_removing", + ), + ( + "only links into this checkout are removed", + "install.sh", + ' if [[ -L "$link" && "$(readlink "$link")" == "$target" ]]; then\n', + ' if [[ -L "$link" ]]; then\n', + "tests/test_install_cmux_sessions.py::test_retired_link_into_another_checkout_is_kept", + ), + ( + "the retired restore CLI link is removed", + "install.sh", + 'remove_retired_link "$HOME_DIR/.local/bin/cmux-restore-sessions" "$REPO_ROOT/bin/cmux-restore-sessions.py"\n', + "", + "tests/test_install_cmux_sessions.py::test_apply_removes_the_retired_links_and_registrations", + ), + ( + "the auto-resume hook stays linked", + "install.sh", + 'ensure_symlink "$HOME_DIR/.claude/hooks/cmux-auto-resume.py" "$REPO_ROOT/hooks/cmux-auto-resume.py"\n', + "", + "tests/test_install_cmux_sessions.py::test_fresh_apply_links_the_auto_resume_hook", + ), + ( + "the auto-resume hook stays registered", + "install/patch-settings.py", + 'RETIRED_HOOK_SCRIPTS = (\n "cmux-session-ledger.py",\n', + 'RETIRED_HOOK_SCRIPTS = (\n "cmux-auto-resume.py",\n "cmux-session-ledger.py",\n', + "tests/test_patch_settings.py::test_removes_every_retired_hook_and_keeps_the_rest", + ), + ( + "a failed settings cleanup is reported", + "install.sh", + ' || warn "patch-settings failed (continuing — retired session hooks may still be registered)"\n', + " || true\n", + "tests/test_install_cmux_sessions.py::test_failed_settings_cleanup_warns_and_the_install_continues", + ), ) diff --git a/tests/run_python_coverage.py b/tests/run_python_coverage.py index 4bf50d7..f9b3f24 100644 --- a/tests/run_python_coverage.py +++ b/tests/run_python_coverage.py @@ -11,11 +11,15 @@ import uuid from pathlib import Path +# Production Python this adapter measures; the change-coverage gate rejects +# changed executable code anywhere else. +PYTHON_SOURCE_DIRS = ("bin", "hooks", "install", "src") + def source_fingerprints(repo: Path) -> dict[str, str]: return { path.relative_to(repo).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() - for directory in ("bin", "src") + for directory in PYTHON_SOURCE_DIRS for path in sorted((repo / directory).rglob("*.py")) if path.is_file() } @@ -38,12 +42,12 @@ def capture_coverage(repo: Path, output_dir: Path, pytest_args=(), *, head = runner(head_command, check=True, capture_output=True, text=True).stdout.strip() before = source_fingerprints(repo) if not before: - raise ValueError("No Python sources found under bin/ or src/") + raise ValueError("No Python sources found under bin/, hooks/, install/, or src/") environment = os.environ.copy() environment["COVERAGE_FILE"] = str(output_dir / f"python.{run_id}.data") command = [ sys.executable, "-m", "pytest", "tests/", *pytest_args, - f"--cov={repo / 'bin'}", f"--cov={repo / 'src'}", + *(f"--cov={repo / directory}" for directory in PYTHON_SOURCE_DIRS), f"--cov-config={repo / 'tests/coverage.ini'}", f"--cov-report=json:{report_path}", ] diff --git a/tests/test_build_ws_context_in_process.py b/tests/test_build_ws_context_in_process.py index c7e4462..ff66410 100644 --- a/tests/test_build_ws_context_in_process.py +++ b/tests/test_build_ws_context_in_process.py @@ -10,8 +10,9 @@ - find_agent_pane: picks the Claude pane out of a multi-pane workspace, not the focused shell; falls back to a booting agent. - transcript_from_session_id: glob + internal-sessionId verification. - - registry_transcript_for_surface: live-pid gate, screen-agreement gate, - internal-sid gate — rejects the stale-registry trap. + - recorded_transcript_for_surface: cmux session record (fake `cmux`, + tests/fake_cmux.py) behind the live-pid, active-record, screen-agreement, + and internal-sid gates — rejects the stale-record trap. - resolve_workspace_screen_and_transcript: end-to-end, never-guess priority. - transcript_signals / cwd_state / screen_shows_error / main(). """ @@ -29,6 +30,8 @@ from tempfile import TemporaryDirectory from unittest import mock +import fake_cmux + REPO = Path(__file__).resolve().parent.parent SCRIPT = REPO / "bin/build-ws-context.py" @@ -48,6 +51,14 @@ def fixture_home(tmp: Path) -> Path: return tmp +def use_fake_cmux(test: unittest.TestCase, records=()) -> None: + """Point CMUX_BIN at a fake cmux whose `sessions list` returns `records`.""" + cli = fake_cmux.install(test._tmp / "cmux-bin", records) + patcher = mock.patch.dict(os.environ, {"CMUX_BIN": str(cli)}) + patcher.start() + test.addCleanup(patcher.stop) + + def _status_bar(sid8: str, branch: str = "main") -> str: """A realistic Claude status-bar line carrying the session stamp.""" return f" assistant {branch} │ ●3 ●1 │ context 39% │ $42.20 │ #{sid8}" @@ -158,9 +169,9 @@ def test_resolves_droid_session_start_id(self): self.mod.transcript_from_session_id(full), str(tp)) -class RegistryTranscriptTests(unittest.TestCase): - """The stale-registry trap (ws:12): the registry maps surface→session, but - a reused surface leaves a DEAD-pid row pointing at the old session.""" +class RecordedTranscriptTests(unittest.TestCase): + """The stale-record trap (ws:12): cmux records which session runs on each + surface, but a reused surface leaves a DEAD-pid record for the old session.""" def setUp(self): self._tmp_obj = TemporaryDirectory() @@ -170,10 +181,6 @@ def setUp(self): def tearDown(self): self._tmp_obj.cleanup() - def _write_registry(self, entries: list[dict]): - reg = {f"tab-{i}": e for i, e in enumerate(entries)} - (self._tmp / ".claude/cmux-registry.json").write_text(json.dumps(reg)) - def _mk_transcript(self, sid_full: str) -> str: proj = self._tmp / ".claude/projects/-Users-x-dev" proj.mkdir(parents=True, exist_ok=True) @@ -182,75 +189,97 @@ def _mk_transcript(self, sid_full: str) -> str: "message": {"role": "user", "content": "hi"}}) + "\n") return str(tp) - def test_live_pid_entry_resolves(self): + def _surface(self, *records): + use_fake_cmux(self, records) + return self.mod.recorded_transcript_for_surface + + @staticmethod + def _record(sid: str, **fields) -> dict: + return fake_cmux.record(sid, surface_id="SURF-A", **fields) + + def test_live_pid_record_resolves(self): full = "11112222-aaaa-bbbb-cccc-dddddddddddd" tp = self._mk_transcript(full) - self._write_registry([{ - "surface_id": "SURF-A", "session_id": full, - "claude_pid": str(os.getpid()), # our own pid → alive - "ts": 100, "transcript_path": tp, - }]) - out = self.mod.registry_transcript_for_surface("surf-a") # case-insensitive - self.assertIsNotNone(out) - self.assertEqual(out["transcript_path"], tp) - self.assertEqual(out["session_id8"], "11112222") - - def test_dead_pid_entry_rejected(self): - # This is the ws:12 trap: a stale row whose process is gone. + lookup = self._surface(self._record(full, pid=os.getpid(), transcript_path=tp)) + self.assertEqual(lookup("surf-a"), # case-insensitive + {"transcript_path": tp, "session_id8": "11112222", "agent": "claude"}) + + def test_dead_pid_record_rejected(self): + # This is the ws:12 trap: a stale record whose process is gone. full = "deadbeef-aaaa-bbbb-cccc-dddddddddddd" tp = self._mk_transcript(full) - self._write_registry([{ - "surface_id": "SURF-A", "session_id": full, - "claude_pid": "999999", # not a live pid - "ts": 100, "transcript_path": tp, - }]) - self.assertIsNone(self.mod.registry_transcript_for_surface("SURF-A")) + lookup = self._surface(self._record(full, pid=999999, transcript_path=tp)) + self.assertIsNone(lookup("SURF-A")) + + def test_record_without_pid_rejected(self): + full = "0bddcd25-aaaa-bbbb-cccc-dddddddddddd" + tp = self._mk_transcript(full) + lookup = self._surface(self._record(full, pid=None, transcript_path=tp)) + self.assertIsNone(lookup("SURF-A")) def test_disagreement_with_screen_id_rejected(self): - # Live pid, but the registry's session != the live status-bar id → - # the screen wins, registry path is NOT used. + # Live pid, but the record's session != the live status-bar id → + # the screen wins, the record's path is NOT used. full = "11112222-aaaa-bbbb-cccc-dddddddddddd" tp = self._mk_transcript(full) - self._write_registry([{ - "surface_id": "SURF-A", "session_id": full, - "claude_pid": str(os.getpid()), "ts": 100, "transcript_path": tp, - }]) - self.assertIsNone(self.mod.registry_transcript_for_surface( - "SURF-A", expect_sid8="99999999")) + lookup = self._surface(self._record(full, pid=os.getpid(), transcript_path=tp)) + self.assertIsNone(lookup("SURF-A", expect_sid8="99999999")) def test_agreement_with_screen_id_accepted(self): full = "11112222-aaaa-bbbb-cccc-dddddddddddd" tp = self._mk_transcript(full) - self._write_registry([{ - "surface_id": "SURF-A", "session_id": full, - "claude_pid": str(os.getpid()), "ts": 100, "transcript_path": tp, - }]) - out = self.mod.registry_transcript_for_surface("SURF-A", expect_sid8="11112222") - self.assertIsNotNone(out) + lookup = self._surface(self._record(full, pid=os.getpid(), transcript_path=tp)) + self.assertEqual(lookup("SURF-A", expect_sid8="11112222")["transcript_path"], tp) def test_missing_transcript_file_rejected(self): - self._write_registry([{ - "surface_id": "SURF-A", "session_id": "11112222-x", - "claude_pid": str(os.getpid()), "ts": 100, - "transcript_path": "/no/such/file.jsonl", - }]) - self.assertIsNone(self.mod.registry_transcript_for_surface("SURF-A")) - - def test_no_entry_for_surface(self): - self._write_registry([]) - self.assertIsNone(self.mod.registry_transcript_for_surface("SURF-A")) - - def test_picks_newest_entry_for_surface(self): + lookup = self._surface(self._record( + "11112222-x", pid=os.getpid(), transcript_path="/no/such/file.jsonl")) + self.assertIsNone(lookup("SURF-A")) + + def test_no_record_for_surface(self): + full = "11112222-aaaa-bbbb-cccc-dddddddddddd" + lookup = self._surface(fake_cmux.record( + full, surface_id="SURF-B", pid=os.getpid(), transcript_path=self._mk_transcript(full))) + self.assertIsNone(lookup("SURF-A")) + self.assertIsNone(lookup("")) + + def test_uses_the_surfaces_active_record(self): + # A /clear leaves the earlier session's record on the surface with the + # same live pid; only the surface's active record runs there now. + cleared = "0ld00000-1111-1111-1111-111111111111" live = "aaaa1111-1111-1111-1111-111111111111" - tp = self._mk_transcript(live) - self._write_registry([ - {"surface_id": "SURF-A", "session_id": "old00000-x", - "claude_pid": "999999", "ts": 50, "transcript_path": "/old.jsonl"}, - {"surface_id": "SURF-A", "session_id": live, - "claude_pid": str(os.getpid()), "ts": 200, "transcript_path": tp}, - ]) - out = self.mod.registry_transcript_for_surface("SURF-A") - self.assertEqual(out["session_id8"], "aaaa1111") + lookup = self._surface( + self._record(cleared, pid=os.getpid(), active_for_surface=False, + transcript_path=self._mk_transcript(cleared)), + self._record(live, pid=os.getpid(), transcript_path=self._mk_transcript(live)), + ) + self.assertEqual(lookup("SURF-A")["session_id8"], "aaaa1111") + + def test_inactive_record_alone_rejected(self): + full = "aaaa1111-1111-1111-1111-111111111111" + lookup = self._surface(self._record( + full, pid=os.getpid(), active_for_surface=False, + transcript_path=self._mk_transcript(full))) + self.assertIsNone(lookup("SURF-A")) + + def test_droid_record_reports_droid(self): + full = "d0d01234-7134-4060-bcf1-6c509c9983cd" + tp = self._tmp / ".factory/sessions/-Users-x-dev" / f"{full}.jsonl" + tp.parent.mkdir(parents=True, exist_ok=True) + tp.write_text(json.dumps({"type": "session_start", "id": full}) + "\n") + lookup = self._surface(self._record( + full, agent="factory", pid=os.getpid(), transcript_path=str(tp))) + self.assertEqual(lookup("SURF-A")["agent"], "droid") + + def test_agent_the_fleet_does_not_run_is_ignored(self): + full = "c0dec0de-1111-1111-1111-111111111111" + lookup = self._surface(self._record( + full, agent="codex", pid=os.getpid(), transcript_path=self._mk_transcript(full))) + self.assertIsNone(lookup("SURF-A")) + + def test_unavailable_cmux_resolves_nothing(self): + # conftest points CMUX_BIN at a binary that doesn't exist. + self.assertIsNone(self.mod.recorded_transcript_for_surface("SURF-A")) class TranscriptSignalsTests(unittest.TestCase): @@ -603,15 +632,13 @@ def test_screen_session_id_is_primary(self): self.assertEqual(res["transcript_path"], tp) self.assertEqual(res["session_id8"], "6fb0c668") - def test_falls_back_to_registry_when_no_screen_id_but_pid_alive(self): - # Booting agent: status bar not rendered yet (sid None), but the - # registry has a LIVE-pid row for its surface UUID. + def test_falls_back_to_cmux_record_when_no_screen_id_but_pid_alive(self): + # Booting agent: status bar not rendered yet (sid None), but cmux has + # a LIVE-pid record for its surface UUID. full = "1d27a528-aaaa-bbbb-cccc-dddddddddddd" tp = self._mk_transcript(full) - reg = {"tab-0": {"surface_id": "U2", "session_id": full, - "claude_pid": str(os.getpid()), "ts": 100, - "transcript_path": tp}} - (self._tmp / ".claude/cmux-registry.json").write_text(json.dumps(reg)) + use_fake_cmux(self, [fake_cmux.record( + full, surface_id="U2", pid=os.getpid(), transcript_path=tp)]) with mock.patch.object(self.mod, "find_agent_pane", return_value={ "surface_ref": "surface:2", "surface_uuid": "U2", "screen_text": "booting…", "sid8": None}), \ @@ -621,19 +648,50 @@ def test_falls_back_to_registry_when_no_screen_id_but_pid_alive(self): self.assertEqual(res["transcript_source"], "registry_live_pid") self.assertEqual(res["transcript_path"], tp) self.assertEqual(res["session_id8"], "1d27a528") + self.assertEqual(res["agent_provider"], "claude") + + def test_cmux_record_for_another_agent_than_the_pane_is_not_used(self): + full = "1d27a528-aaaa-bbbb-cccc-dddddddddddd" + use_fake_cmux(self, [fake_cmux.record( + full, surface_id="U2", pid=os.getpid(), transcript_path=self._mk_transcript(full))]) + with mock.patch.object(self.mod, "find_agent_pane", return_value={ + "surface_ref": "surface:2", "surface_uuid": "U2", + "screen_text": "GLM-5.2 · allow all commands", "sid8": None, + "agent": "droid"}), \ + mock.patch.object(self.mod, "resume_binding_for_surface", + return_value=None): + res = self.mod.resolve_workspace_screen_and_transcript("workspace:9") + self.assertIsNone(res["transcript_path"]) + self.assertIsNone(res["transcript_source"]) + self.assertEqual(res["agent_provider"], "droid") + + def test_droid_cmux_record_names_the_provider_for_an_unidentified_pane(self): + full = "d0d01234-7134-4060-bcf1-6c509c9983cd" + tp = self._tmp / ".factory/sessions/-Users-x-dev" / f"{full}.jsonl" + tp.parent.mkdir(parents=True, exist_ok=True) + tp.write_text(json.dumps({"type": "session_start", "id": full}) + "\n") + use_fake_cmux(self, [fake_cmux.record( + full, agent="factory", surface_id="U2", pid=os.getpid(), transcript_path=str(tp))]) + with mock.patch.object(self.mod, "find_agent_pane", return_value={ + "surface_ref": "surface:2", "surface_uuid": "U2", + "screen_text": "", "sid8": None}), \ + mock.patch.object(self.mod, "resume_binding_for_surface", + return_value=None): + res = self.mod.resolve_workspace_screen_and_transcript("workspace:9") + self.assertEqual(res["transcript_source"], "registry_live_pid") + self.assertEqual(res["transcript_path"], str(tp)) + self.assertEqual(res["agent_provider"], "droid") - def test_stale_registry_never_wins_over_live_screen(self): - # THE ws:12 SCENARIO: live screen says 6fb0c668, but the registry's - # surface row is a DEAD-pid pointer at a different (old) session. The - # screen id resolves; the stale registry path is never emitted. + def test_stale_record_never_wins_over_live_screen(self): + # THE ws:12 SCENARIO: live screen says 6fb0c668, but cmux's record for + # the surface is a DEAD-pid pointer at a different (old) session. The + # screen id resolves; the stale record's path is never emitted. live = "6fb0c668-7134-4060-bcf1-6c509c9983cd" stale = "b5f8d913-a9cb-433e-989d-63df9e14c253" live_tp = self._mk_transcript(live) stale_tp = self._mk_transcript(stale, project="-Users-x-dev-assistant") - reg = {"tab-0": {"surface_id": "U2", "session_id": stale, - "claude_pid": "999999", # dead - "ts": 100, "transcript_path": stale_tp}} - (self._tmp / ".claude/cmux-registry.json").write_text(json.dumps(reg)) + use_fake_cmux(self, [fake_cmux.record( + stale, surface_id="U2", pid=999999, transcript_path=stale_tp)]) # dead with mock.patch.object(self.mod, "find_agent_pane", return_value={ "surface_ref": "surface:2", "surface_uuid": "U2", "screen_text": self.CLAUDE, "sid8": "6fb0c668"}): @@ -643,7 +701,7 @@ def test_stale_registry_never_wins_over_live_screen(self): self.assertEqual(res["transcript_source"], "screen_session_id") def test_no_verified_signal_yields_null_transcript(self): - # Agent found, but no transcript on disk yet and no registry row → + # Agent found, but no transcript on disk yet and no cmux record → # transcript_path MUST be None (never a guess). screen still provided. with mock.patch.object(self.mod, "find_agent_pane", return_value={ "surface_ref": "surface:2", "surface_uuid": "U2", @@ -966,19 +1024,18 @@ def test_transcript_from_session_id_no_projects_dir(self): result = self.mod.transcript_from_session_id("aabbccdd") self.assertIsNone(result) - def test_registry_transcript_empty_uuid_returns_none(self): - result = self.mod.registry_transcript_for_surface("") + def test_recorded_transcript_empty_uuid_returns_none(self): + result = self.mod.recorded_transcript_for_surface("") self.assertIsNone(result) - def test_registry_transcript_internal_sid_disagreement(self): - # Registry entry has a live pid, but transcript's own sid disagrees. + def test_recorded_transcript_internal_sid_disagreement(self): + # The record has a live pid, but the transcript's own sid disagrees. tp = str(self._tmp / ".claude/projects/-p/aabbccdd-t.jsonl") Path(tp).parent.mkdir(parents=True, exist_ok=True) Path(tp).write_text(json.dumps({"sessionId": "11223344-diff"}) + "\n") - reg = {"S1": {"surface_id": "AAAA-BBBB", "claude_pid": os.getpid(), - "ts": 1, "session_id": "aabbccdd", "transcript_path": tp}} - (self._tmp / ".claude/cmux-registry.json").write_text(json.dumps(reg)) - result = self.mod.registry_transcript_for_surface("AAAA-BBBB") + use_fake_cmux(self, [fake_cmux.record( + "aabbccdd", surface_id="AAAA-BBBB", pid=os.getpid(), transcript_path=tp)]) + result = self.mod.recorded_transcript_for_surface("AAAA-BBBB") # Internal sid "11223344" doesn't start with "aabbccdd" → rejected. self.assertIsNone(result) diff --git a/tests/test_change_coverage_gate.py b/tests/test_change_coverage_gate.py index d44ea63..c815323 100644 --- a/tests/test_change_coverage_gate.py +++ b/tests/test_change_coverage_gate.py @@ -92,7 +92,7 @@ def test_missing_or_line_only_reports_are_explicit_errors(source_repo): {"meta": {"branch_coverage": True}, "files": {}}) -@pytest.mark.parametrize("unsupported", ["hooks/new.py", "src/new.js", "bin/new.sh"]) +@pytest.mark.parametrize("unsupported", ["slack-reactor/new.py", "src/new.js", "bin/new.sh"]) def test_git_diff_and_untracked_production_files_are_both_measured(tmp_path, unsupported): subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) (tmp_path / "src").mkdir() @@ -104,8 +104,11 @@ def test_git_diff_and_untracked_production_files_are_both_measured(tmp_path, uns check=True) file.write_text("one = 1\ntwo = 2\n") (tmp_path / "src/new.py").write_text("new = True\n") + for directory in ("install", "hooks"): + (tmp_path / directory).mkdir() + (tmp_path / directory / "new.py").write_text("added = True\n") assert gate.changed_lines(tmp_path, "HEAD") == { - "src/example.py": {2}, "src/new.py": {1}} + "src/example.py": {2}, "src/new.py": {1}, "install/new.py": {1}, "hooks/new.py": {1}} (tmp_path / unsupported).parent.mkdir(exist_ok=True) (tmp_path / unsupported).write_text("run_hook()\n") with pytest.raises(ValueError, match="not configured"): diff --git a/tests/test_cmux_restore_sessions.py b/tests/test_cmux_restore_sessions.py deleted file mode 100644 index fe01895..0000000 --- a/tests/test_cmux_restore_sessions.py +++ /dev/null @@ -1,155 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -from pathlib import Path - -REPO = Path(__file__).resolve().parent.parent - - -def load_module(): - spec = importlib.util.spec_from_file_location( - "cmux_restore_sessions_test", - REPO / "bin" / "cmux-restore-sessions.py", - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def panel(kind="claude", arguments=None, binding=None): - return { - "id": "panel-1", - "directory": "/tmp/repo", - "terminal": { - "surfaceId": "surface-1", - "agent": { - "kind": kind, - "sessionId": "state-session", - "workingDirectory": "/tmp/repo", - "launchCommand": {"arguments": arguments or []}, - }, - "resumeBinding": binding or {}, - }, - } - - -def test_get_all_panels_accepts_claude_factory_and_droid(): - module = load_module() - state = { - "windows": [{ - "tabManager": { - "workspaces": [{ - "panels": [ - panel("claude"), - panel("factory"), - panel("droid"), - panel("codex"), - ], - }], - }, - }], - } - found = list(module.get_all_panels(state)) - assert [p["terminal"]["agent"]["kind"] for _, p in found] == [ - "claude", "factory", "droid", - ] - - -def test_claude_reuses_exact_previous_resume_binding(): - module = load_module() - previous = "cd /tmp/repo && /custom/claude --resume state-session --model opus" - cwd, command = module.build_resume_cmd( - panel( - "claude", - arguments=["/custom/claude", "--model", "opus"], - binding={ - "checkpointId": "state-session", - "cwd": "/tmp/repo", - "command": previous, - }, - ) - ) - assert cwd == "/tmp/repo" - assert command == previous - - -def test_factory_resume_preserves_args_and_enforces_glm_configuration( - tmp_path, monkeypatch): - monkeypatch.setenv("HOME", str(tmp_path)) - lessons = tmp_path / ".claude/CLAUDE.md" - lessons.parent.mkdir(parents=True) - lessons.write_text("rules") - module = load_module() - ledger = { - "session_id": "ledger-session", - "provider": "factory", - "cwd": "/tmp/project with space", - "launch_argv": [ - "/custom/droid", - "--settings", "/old/settings.json", - "--auto", "low", - "--append-system-prompt-file", "/old/prompt.md", - "--use-spec", - ], - } - cwd, command = module.build_resume_cmd( - panel("factory", arguments=["droid", "--auto", "medium"]), - ledger, - ) - assert cwd == "/tmp/project with space" - assert command.startswith("cd '/tmp/project with space' && /custom/droid") - assert "--resume ledger-session" in command - assert "--use-spec" in command - assert "/old/settings.json" not in command - assert "/old/prompt.md" not in command - assert "droid-glm-settings.json" in command - assert "--auto high" in command - assert "--append-system-prompt-file" in command - - -def test_factory_rebuilds_unconfigured_old_binding(): - module = load_module() - old = "cd /tmp/repo && droid --resume state-session" - _, command = module.build_resume_cmd( - panel( - "droid", - arguments=["droid"], - binding={ - "checkpointId": "state-session", - "cwd": "/tmp/repo", - "command": old, - }, - ) - ) - assert command != old - assert "droid-glm-settings.json" in command - assert "--resume state-session" in command - - -def test_old_ledger_row_without_provider_fails_closed(tmp_path): - module = load_module() - module.LEDGER = tmp_path / "ledger.jsonl" - module.LEDGER.write_text(json.dumps({ - "event": "start", - "ts": 1, - "session_id": "old-session", - "panel_id": "panel-1", - "cwd": "/tmp/repo", - }) + "\n") - ledger = module.load_ledger() - old_panel = panel("", arguments=[]) - cwd, command = module.build_resume_cmd(old_panel, ledger["panel-1"]) - assert cwd is None - assert command is None - - -def test_ledger_transcript_can_identify_factory(): - module = load_module() - ledger = { - "session_id": "sid", - "transcript_path": "/Users/test/.factory/sessions/repo/sid.jsonl", - } - _, command = module.build_resume_cmd(panel("", arguments=[]), ledger) - assert "droid --resume sid" in command - assert "droid-glm-settings.json" in command diff --git a/tests/test_cmux_session_ledger.py b/tests/test_cmux_session_ledger.py deleted file mode 100644 index 892ee83..0000000 --- a/tests/test_cmux_session_ledger.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations - -import base64 -import importlib.util -import io -import json -import os -from pathlib import Path -from unittest import mock - -REPO = Path(__file__).resolve().parent.parent - - -def load_module(): - spec = importlib.util.spec_from_file_location( - "cmux_session_ledger_test", REPO / "hooks" / "cmux-session-ledger.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def encoded_argv(*args): - return base64.b64encode("\0".join(args).encode()).decode() - - -def test_factory_start_records_provider_agent_pid_and_launch_args(tmp_path): - module = load_module() - module.LEDGER = str(tmp_path / "ledger.jsonl") - module.LOCK = module.LEDGER + ".lock" - payload = { - "session_id": "factory-session", - "cwd": "/tmp/repo", - "transcript_path": "/Users/test/.factory/sessions/repo/session.jsonl", - } - env = { - "CMUX_DROID_PID": "4321", - "CMUX_PANEL_ID": "panel-1", - "CMUX_SURFACE_ID": "surface-1", - "CMUX_WORKSPACE_ID": "workspace-1", - "CMUX_AGENT_LAUNCH_ARGV_B64": encoded_argv( - "droid", "--settings", "/tmp/settings.json", "--auto", "high" - ), - } - identify = mock.Mock(returncode=0, stdout=json.dumps({ - "caller": {"workspace": {"title": "Droid work"}}, - })) - with ( - mock.patch.dict(os.environ, env, clear=True), - mock.patch.object(module.sys, "argv", ["ledger", "start"]), - mock.patch.object(module.sys, "stdin", io.StringIO(json.dumps(payload))), - mock.patch("subprocess.run", return_value=identify), - ): - module.main() - - entry = json.loads(Path(module.LEDGER).read_text()) - assert entry["provider"] == "factory" - assert entry["agent"] == "droid" - assert entry["agent_pid"] == 4321 - assert entry["pid"] == 4321 - assert entry["launch_argv"][0] == "droid" - assert entry["ws_title"] == "Droid work" - - -def test_factory_pid_alias_and_end_event(tmp_path): - module = load_module() - module.LEDGER = str(tmp_path / "ledger.jsonl") - module.LOCK = module.LEDGER + ".lock" - env = { - "CMUX_FACTORY_PID": "987", - "CMUX_AGENT_LAUNCH_EXECUTABLE": "droid", - } - with ( - mock.patch.dict(os.environ, env, clear=True), - mock.patch.object(module.sys, "argv", ["ledger", "end"]), - mock.patch.object( - module.sys, "stdin", io.StringIO('{"sessionId":"factory-session"}') - ), - ): - module.main() - - entry = json.loads(Path(module.LEDGER).read_text()) - assert entry["event"] == "end" - assert entry["provider"] == "factory" - assert entry["agent"] == "droid" - assert entry["agent_pid"] == 987 - - -def test_claude_pid_remains_supported(tmp_path): - module = load_module() - module.LEDGER = str(tmp_path / "ledger.jsonl") - module.LOCK = module.LEDGER + ".lock" - env = {"CMUX_CLAUDE_PID": "111", "CMUX_PANEL_ID": "panel-1"} - payload = {"session_id": "claude-session", "provider": "claude"} - identify = mock.Mock(returncode=1, stdout="") - with ( - mock.patch.dict(os.environ, env, clear=True), - mock.patch.object(module.sys, "argv", ["ledger", "start"]), - mock.patch.object(module.sys, "stdin", io.StringIO(json.dumps(payload))), - mock.patch("subprocess.run", return_value=identify), - ): - module.main() - - entry = json.loads(Path(module.LEDGER).read_text()) - assert entry["provider"] == "claude" - assert entry["agent"] == "claude" - assert entry["agent_pid"] == 111 diff --git a/tests/test_cmux_sessions.py b/tests/test_cmux_sessions.py new file mode 100644 index 0000000..82be617 --- /dev/null +++ b/tests/test_cmux_sessions.py @@ -0,0 +1,148 @@ +"""bin/cmux_sessions.py runs a real `cmux` subprocess (tests/fake_cmux.py) and +returns typed session records, or [] whenever cmux can't answer.""" +from __future__ import annotations + +import os +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +import cmux_sessions +import fake_cmux + + +@pytest.fixture +def cli(tmp_path, monkeypatch): + directory = tmp_path / "cmux-bin" + path = fake_cmux.install(directory) + monkeypatch.setenv("CMUX_BIN", str(path)) + return directory + + +def test_lists_every_saved_record_with_typed_fields(cli, capsys): + fake_cmux.write(cli, [ + fake_cmux.record( + "f5ee1b35-11e2-4283-bc19-b9aec09efb58", + workspace_id="7FA03E6B-BA29-4D3A-8655-FF0087CBFE7E", + surface_id="13F23F97-EEDA-401D-852F-74DE856E6F2E", + cwd="/Users/x/dev/assistant", launch_working_directory="/Users/x/dev/ffp", + pid=47047, transcript_path="/Users/x/.claude/projects/p/f5ee1b35.jsonl", + started_at="2026-10-18T15:48:38.207Z", updated_at_unix=1792418870.25, + active_for_surface=True, + ), + fake_cmux.record("droid-1", agent="factory", active_for_surface=False), + fake_cmux.record("codex-1", agent="codex"), + ]) + sessions = cmux_sessions.list_sessions() + assert fake_cmux.calls(cli) == [["sessions", "list", "--json", "--all"]] + assert sessions[0] == cmux_sessions.Session( + session_id="f5ee1b35-11e2-4283-bc19-b9aec09efb58", + agent="claude", + workspace_id="7FA03E6B-BA29-4D3A-8655-FF0087CBFE7E", + surface_id="13F23F97-EEDA-401D-852F-74DE856E6F2E", + cwd="/Users/x/dev/assistant", + launch_cwd="/Users/x/dev/ffp", + pid=47047, + transcript_path="/Users/x/.claude/projects/p/f5ee1b35.jsonl", + started_at=datetime(2026, 10, 18, 15, 48, 38, 207000, tzinfo=timezone.utc).timestamp(), + updated_at=1792418870.25, + active_for_surface=True, + ) + assert [(s.session_id, s.provider, s.active_for_surface) for s in sessions] == [ + ("f5ee1b35-11e2-4283-bc19-b9aec09efb58", "claude", True), + ("droid-1", "droid", False), + ("codex-1", None, True), + ] + assert capsys.readouterr().err == "" + + +def test_skips_unusable_rows_and_blanks_unusable_fields(cli): + fake_cmux.write(cli, [ + "not a record", + {"agent": "claude"}, + fake_cmux.record(""), + fake_cmux.record("bool-pid", pid=True, started_at="not a date", + updated_at_unix="1792418870", cwd="", workspace_id=None, + active_for_surface="yes"), + fake_cmux.record("zero-pid", pid=0, started_at=None, agent=None), + fake_cmux.record("naive-start", pid=-5, started_at="2026-10-18T15:48:38", + updated_at_unix=1792418870), + ]) + sessions = {s.session_id: s for s in cmux_sessions.list_sessions()} + assert list(sessions) == ["bool-pid", "zero-pid", "naive-start"] + bad = sessions["bool-pid"] + assert (bad.pid, bad.started_at, bad.updated_at, bad.cwd, bad.workspace_id, + bad.active_for_surface) == (None, None, None, None, None, False) + assert (sessions["zero-pid"].pid, sessions["zero-pid"].started_at, + sessions["zero-pid"].agent, sessions["zero-pid"].provider) == (None, None, "", None) + naive = sessions["naive-start"] + assert naive.pid is None + assert naive.started_at == datetime(2026, 10, 18, 15, 48, 38, tzinfo=timezone.utc).timestamp() + assert naive.updated_at == 1792418870.0 + + +@pytest.mark.parametrize("stdout,returncode,reason", [ + ('{"sessions": [{"session_id": "s"}]}', 1, "exit 1"), + ("Error: sessions list: unknown agent", 0, "output is not JSON"), + ('[{"session_id": "s"}]', 0, "output has no sessions list"), + ('{"sessions": {"s": {}}}', 0, "output has no sessions list"), +]) +def test_unusable_cli_answers_yield_no_sessions(cli, capsys, stdout, returncode, reason): + fake_cmux.write(cli, stdout=stdout, returncode=returncode) + assert cmux_sessions.list_sessions() == [] + assert len(fake_cmux.calls(cli)) == 1 + assert f"sessions list` unavailable ({reason}" in capsys.readouterr().err + + +def test_missing_cli_yields_no_sessions(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("CMUX_BIN", str(tmp_path / "no-cmux-here")) + assert cmux_sessions.list_sessions() == [] + assert f"`{tmp_path / 'no-cmux-here'} sessions list` unavailable (" in capsys.readouterr().err + + +def test_hung_cli_times_out_to_no_sessions(tmp_path, monkeypatch, capsys): + hung = tmp_path / "cmux" + hung.write_text("#!/bin/sh\nexec sleep 30\n") + hung.chmod(0o755) + monkeypatch.setenv("CMUX_BIN", str(hung)) + monkeypatch.setattr(cmux_sessions, "TIMEOUT_SEC", 0.2) + assert cmux_sessions.list_sessions() == [] + assert "timed out" in capsys.readouterr().err + + +def _executable(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\n") + path.chmod(0o755) + return path + + +def test_cli_resolution_order(tmp_path, monkeypatch): + bundled = _executable(tmp_path / "bundle/cmux") + on_path = _executable(tmp_path / "path/cmux") + monkeypatch.setenv("PATH", str(on_path.parent)) + monkeypatch.setenv("CMUX_BUNDLED_CLI_PATH", str(bundled)) + monkeypatch.setenv("CMUX_BIN", str(tmp_path / "override")) + assert cmux_sessions.cli_path() == str(tmp_path / "override") + + monkeypatch.delenv("CMUX_BIN") + assert cmux_sessions.cli_path() == str(bundled) + + bundled.chmod(0o644) + assert cmux_sessions.cli_path() == str(on_path) + + monkeypatch.delenv("CMUX_BUNDLED_CLI_PATH") + assert cmux_sessions.cli_path() == str(on_path) + + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + assert cmux_sessions.cli_path() == cmux_sessions.APP_CLI + + +def test_bundled_cli_is_what_runs_without_an_override(tmp_path, monkeypatch): + directory = tmp_path / "bundle" + path = fake_cmux.install(directory, [fake_cmux.record("from-bundle")]) + monkeypatch.delenv("CMUX_BIN") + monkeypatch.setenv("CMUX_BUNDLED_CLI_PATH", str(path)) + monkeypatch.setenv("PATH", os.defpath) + assert [s.session_id for s in cmux_sessions.list_sessions()] == ["from-bundle"] diff --git a/tests/test_coverage_capture.py b/tests/test_coverage_capture.py index 8ec9e51..938210e 100644 --- a/tests/test_coverage_capture.py +++ b/tests/test_coverage_capture.py @@ -30,11 +30,13 @@ def setUp(self): self.root = Path(directory.name) self.repo = self.root / "repo" self.output = self.root / "results" - for name in ("bin", "src/package", "tests"): + for name in ("bin", "hooks", "install", "src/package", "tests"): (self.repo / name).mkdir(parents=True) self.source = self.repo / "src/package/example.py" self.source.write_text("answer = 42\n") (self.repo / "bin/tool.py").write_text("print('tool')\n") + (self.repo / "install/patch.py").write_text("print('patch')\n") + (self.repo / "hooks/hook.py").write_text("print('hook')\n") (self.repo / "src/data.txt").write_text("not code") (self.repo / "tests/test_example.py").write_text("assert True\n") self.report = { @@ -74,7 +76,7 @@ def run_capture(self, *args): def test_fingerprints_include_all_production_python_and_hash_bytes(self): expected = { name: hashlib.sha256((self.repo / name).read_bytes()).hexdigest() - for name in ("bin/tool.py", "src/package/example.py") + for name in ("bin/tool.py", "hooks/hook.py", "install/patch.py", "src/package/example.py") } self.assertEqual(capture.source_fingerprints(self.repo), expected) self.source.write_bytes(b"answer = 42\r\n") @@ -101,6 +103,8 @@ def test_real_report_is_bound_to_sources_and_inherited_process_configuration(sel command, options = self.calls[1] self.assertEqual(command[:7], [sys.executable, "-m", "pytest", "tests/", "-q", "-k", "example"]) self.assertIn(f"--cov={self.repo / 'bin'}", command) + self.assertIn(f"--cov={self.repo / 'hooks'}", command) + self.assertIn(f"--cov={self.repo / 'install'}", command) self.assertIn(f"--cov={self.repo / 'src'}", command) self.assertIn(f"--cov-config={self.repo / 'tests/coverage.ini'}", command) self.assertEqual(options["cwd"], self.repo) diff --git a/tests/test_install_cmux_sessions.py b/tests/test_install_cmux_sessions.py new file mode 100644 index 0000000..8194d76 --- /dev/null +++ b/tests/test_install_cmux_sessions.py @@ -0,0 +1,154 @@ +"""install.sh section 4: cmux records sessions itself, so the installer removes +the retired hand-written session hooks instead of wiring them, and keeps +hooks/cmux-auto-resume.py, which makes cmux record agents started outside its +Claude wrapper. + +Runs the real installer against an isolated HOME with recording service +commands (tests/installer_sandbox.py). +""" +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from installer_sandbox import assert_recorded_services, installer_env + +REPO = Path(__file__).resolve().parent.parent +INSTALL = REPO / "install.sh" + +RETIRED = { + ".claude/hooks/cmux-session-ledger.py": REPO / "hooks/cmux-session-ledger.py", + ".local/bin/cmux-restore-sessions": REPO / "bin/cmux-restore-sessions.py", +} +AUTO_RESUME_LINK = ".claude/hooks/cmux-auto-resume.py" +AUTO_RESUME = "python3 $HOME/.claude/hooks/cmux-auto-resume.py" +LEDGER_START = "python3 $HOME/.claude/hooks/cmux-session-ledger.py start" + + +@pytest.fixture +def install(tmp_path): + """Run install.sh with these flags against the isolated HOME `tmp_path`.""" + env = installer_env(tmp_path) + + def run(*flags: str) -> subprocess.CompletedProcess: + if "--apply" not in flags: + # A dry run expects a previous install's plists. + plists = tmp_path / "Library/LaunchAgents" + plists.mkdir(parents=True, exist_ok=True) + for template in (REPO / "launchagents").glob("*.plist"): + (plists / template.name).write_text("\n") + result = subprocess.run(["bash", str(INSTALL), *flags], capture_output=True, text=True, + env=env, stdin=subprocess.DEVNULL, timeout=120) + assert result.returncode == 0, f"{result.stdout[-2000:]}\n{result.stderr[-1000:]}" + assert_recorded_services(tmp_path, apply="--apply" in flags) + return result + + return run + + +def _seed_previous_install(home: Path) -> dict[str, Path]: + """Links an older install.sh made, plus links it never owned.""" + links = {} + for relative, target in RETIRED.items(): + link = home / relative + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to(target) + links[relative] = link + kept = home / AUTO_RESUME_LINK + kept.symlink_to(REPO / "hooks/cmux-auto-resume.py") + links["auto-resume"] = kept + foreign = home / ".claude/hooks/cmux-registry.py" + foreign.symlink_to(home / "dev/mukul-memory/hooks/cmux-registry.py") + links["foreign"] = foreign + elsewhere = home / ".claude/hooks/block-hook-bypass.py" + elsewhere.symlink_to(REPO / "hooks/block-hook-bypass.py") + links["elsewhere"] = elsewhere + settings = home / ".claude/settings.json" + settings.write_text(json.dumps({"hooks": {"SessionStart": [ + {"matcher": "", "hooks": [{"type": "command", "command": LEDGER_START}]}, + {"matcher": "", "hooks": [{"type": "command", "command": AUTO_RESUME}]}, + ]}})) + factory = home / ".factory/hooks.json" + factory.parent.mkdir(parents=True) + factory.write_text(json.dumps({"hooks": {"SessionStart": [ + {"matcher": "", "hooks": [{"type": "command", "command": LEDGER_START}]}, + ]}})) + return links + + +def test_apply_removes_the_retired_links_and_registrations(tmp_path, install): + links = _seed_previous_install(tmp_path) + + result = install("--apply") + + for relative in RETIRED: + assert not links[relative].is_symlink(), relative + assert f"REMOVE retired link {links[relative]}" in result.stdout + assert os.readlink(links["auto-resume"]) == str(REPO / "hooks/cmux-auto-resume.py") + assert os.readlink(links["foreign"]) == str(tmp_path / "dev/mukul-memory/hooks/cmux-registry.py") + assert os.readlink(links["elsewhere"]) == str(REPO / "hooks/block-hook-bypass.py") + settings = json.loads((tmp_path / ".claude/settings.json").read_text()) + assert settings["hooks"] == {"SessionStart": [ + {"matcher": "", "hooks": [{"type": "command", "command": AUTO_RESUME}]}]} + assert json.loads((tmp_path / ".factory/hooks.json").read_text())["hooks"] == {} + + again = install("--apply") + assert "REMOVE retired link" not in again.stdout + assert not any(links[relative].is_symlink() for relative in RETIRED) + + +def test_retired_link_into_another_checkout_is_kept(tmp_path, install): + other = tmp_path / "other-checkout/hooks/cmux-session-ledger.py" + link = tmp_path / ".claude/hooks/cmux-session-ledger.py" + link.parent.mkdir(parents=True) + link.symlink_to(other) + + result = install("--apply") + + assert os.readlink(link) == str(other) + assert "REMOVE retired link" not in result.stdout + + +def test_fresh_apply_links_the_auto_resume_hook(tmp_path, install): + install("--apply") + assert os.readlink(tmp_path / AUTO_RESUME_LINK) == str(REPO / "hooks/cmux-auto-resume.py") + + +def test_dry_run_reports_without_removing(tmp_path, install): + links = _seed_previous_install(tmp_path) + settings = (tmp_path / ".claude/settings.json").read_text() + + result = install() + + for relative in RETIRED: + assert links[relative].is_symlink(), relative + assert f"REMOVE retired link {links[relative]}" in result.stdout + assert (tmp_path / ".claude/settings.json").read_text() == settings + assert "would install cmux Factory hooks and patch Claude + Factory lifecycle hooks" in result.stdout + + +def test_failed_settings_cleanup_warns_and_the_install_continues(tmp_path, install): + (tmp_path / ".claude/settings.json").mkdir(parents=True) + + result = install("--apply") + + assert ("patch-settings failed (continuing — retired session hooks may still be registered)" + in result.stderr) + assert "[5/5] Reloading launchd agents that changed" in result.stdout + + +def test_help_documents_native_cmux_sessions(tmp_path): + result = subprocess.run(["bash", str(INSTALL), "--help"], capture_output=True, text=True, + env=installer_env(tmp_path), stdin=subprocess.DEVNULL, timeout=30) + assert result.returncode == 0 + assert (" - cmux records agent sessions itself: hooks/cmux-auto-resume.py → ~/.claude/hooks/\n" + " for agents started outside cmux's wrapper, Factory hooks via cmux, and the\n" + " retired hand-written session hooks removed from Claude and Factory settings\n" + in result.stdout) + assert "What is NOT touched:\n - ~/.claude/cache/, ~/.claude/projects/\n" in result.stdout + for retired in ("cmux-registry", "cmux-session-ledger", "cmux-restore-sessions"): + assert retired not in result.stdout diff --git a/tests/test_patch_settings.py b/tests/test_patch_settings.py index 38348bc..ad88717 100644 --- a/tests/test_patch_settings.py +++ b/tests/test_patch_settings.py @@ -1,15 +1,11 @@ -"""patch-settings.py — hook patcher coverage. - -Two layers: - - Direct patch_path() unit tests: preserves existing hooks, no backup for a - newly-created file, and (the load-bearing one) writes THROUGH a symlinked - target instead of severing it (machine-config sync must survive an --apply). - - Fresh-machine CLI tests (subprocess, exercising main()'s multi-path loop): - on a truly fresh machine ~/.claude/settings.json does not exist, so an - unconditional `shutil.copy2(path, bak)` backup crashed with FileNotFoundError - (caught by a live fresh-install run 2026-07-07). The installer must create - the file fresh instead of backing up a nonexistent one, stay idempotent on a - second run, and preserve unrelated user settings. +"""install/patch-settings.py removes the retired cmux session hooks. + +cmux records agent sessions itself, so the installer unregisters the +hand-written cmux-session-ledger.py and cmux-registry.py hooks from Claude +settings and Factory hooks, and never adds them back. cmux-auto-resume.py stays +registered: it is what makes cmux record agents started without its wrapper. +Direct patch_path() tests pin what is removed and what survives; CLI tests run +main() over several paths, as install.sh does. """ from __future__ import annotations @@ -22,100 +18,180 @@ REPO = Path(__file__).resolve().parent.parent PATCH = REPO / "install" / "patch-settings.py" +AUTO_RESUME = "python3 $HOME/.claude/hooks/cmux-auto-resume.py" +LEDGER_START = "python3 $HOME/.claude/hooks/cmux-session-ledger.py start" +LEDGER_END = "python3 $HOME/.claude/hooks/cmux-session-ledger.py end" +REGISTRY = "$HOME/.claude/hooks/cmux-registry.py" + def load_module(): - spec = importlib.util.spec_from_file_location( - "patch_settings_test", REPO / "install" / "patch-settings.py" - ) + spec = importlib.util.spec_from_file_location("patch_settings_test", PATCH) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module +def hook(command, timeout=5): + return {"type": "command", "command": command, "timeout": timeout} + + +def block(*commands, matcher=""): + return {"matcher": matcher, "hooks": [hook(c) for c in commands]} + + def commands(document, event): return [ - hook["command"] - for block in document.get("hooks", {}).get(event, []) - for hook in block.get("hooks", []) + entry["command"] + for item in document.get("hooks", {}).get(event, []) + for entry in item.get("hooks", []) ] -def _run(path: Path): - return subprocess.run([sys.executable, str(PATCH), str(path)], +def live_claude_settings(): + """The shape install.sh and mukul-memory's link-hooks left on a real machine.""" + return { + "permissions": {"allow": ["Read"]}, + "hooks": { + "SessionStart": [ + block(REGISTRY), + {"matcher": "", "hooks": [hook(AUTO_RESUME, 10)]}, + block(LEDGER_START), + block("python3 $HOME/.claude/hooks/memory-boot.py"), + ], + "SessionEnd": [block(LEDGER_END)], + "PreToolUse": [block("python3 $HOME/.claude/hooks/block-hook-bypass.py", + matcher="Bash")], + }, + } + + +def _run(*paths: Path): + return subprocess.run([sys.executable, str(PATCH), *map(str, paths)], capture_output=True, text=True, timeout=30) -def _session_start_cmds(data): - out = [] - for block in data["hooks"].get("SessionStart", []): - for h in block.get("hooks", []): - out.append(h.get("command", "")) - return out +def test_removes_every_retired_hook_and_keeps_the_rest(tmp_path): + module = load_module() + settings = tmp_path / ".claude" / "settings.json" + settings.parent.mkdir() + settings.write_text(json.dumps(live_claude_settings())) + + assert module.patch_path(settings) is True + document = json.loads(settings.read_text()) + assert document["permissions"] == {"allow": ["Read"]} + assert document["hooks"]["SessionStart"] == [ + {"matcher": "", "hooks": [hook(AUTO_RESUME, 10)]}, + block("python3 $HOME/.claude/hooks/memory-boot.py"), + ] + assert "SessionEnd" not in document["hooks"] + assert document["hooks"]["PreToolUse"] == [ + block("python3 $HOME/.claude/hooks/block-hook-bypass.py", matcher="Bash")] + backups = list(settings.parent.glob("settings.json.bak-*")) + assert len(backups) == 1 + assert json.loads(backups[0].read_text()) == live_claude_settings() -# --------------------------------------------------------------------------- direct patch_path() -def test_patches_claude_and_factory_without_deleting_existing_hooks(tmp_path): +def test_mixed_block_keeps_its_matcher_and_other_hooks(tmp_path): module = load_module() - claude = tmp_path / ".claude" / "settings.json" - factory = tmp_path / ".factory" / "hooks.json" - claude.parent.mkdir() - factory.parent.mkdir() - claude.write_text(json.dumps({ - "permissions": {"allow": ["Read"]}, - "hooks": { - "SessionStart": [{ - "matcher": "", - "hooks": [{"type": "command", "command": "existing-claude"}], - }], - "Stop": [{ - "matcher": "", - "hooks": [{ - "type": "command", - "command": module.LEDGER_END_CMD, - }], - }], - }, - })) - factory.write_text(json.dumps({ - "custom": True, - "hooks": { - "PostToolUse": [{ - "matcher": "Edit", - "hooks": [{"type": "command", "command": "existing-factory"}], - }], - }, - })) + path = tmp_path / ".factory" / "hooks.json" + path.parent.mkdir() + path.write_text(json.dumps({"custom": True, "hooks": { + "SessionStart": [block(REGISTRY, "existing-factory", LEDGER_START, matcher="startup")], + }})) - assert module.patch_path(claude) - assert module.patch_path(factory) + assert module.patch_path(path) is True - claude_doc = json.loads(claude.read_text()) - factory_doc = json.loads(factory.read_text()) - assert claude_doc["permissions"] == {"allow": ["Read"]} - assert "existing-claude" in commands(claude_doc, "SessionStart") - assert module.LEDGER_END_CMD not in commands(claude_doc, "Stop") - assert factory_doc["custom"] is True - assert "existing-factory" in commands(factory_doc, "PostToolUse") - for document in (claude_doc, factory_doc): - assert module.LEDGER_START_CMD in commands(document, "SessionStart") - assert module.LEDGER_END_CMD in commands(document, "SessionEnd") + assert json.loads(path.read_text()) == {"custom": True, "hooks": { + "SessionStart": [block("existing-factory", matcher="startup")], + }} - assert len(list(claude.parent.glob("settings.json.bak-*"))) == 1 - assert len(list(factory.parent.glob("hooks.json.bak-*"))) == 1 + +def test_leaves_untouched_events_and_blocks_alone(tmp_path): + module = load_module() + path = tmp_path / "settings.json" + path.write_text(json.dumps({"hooks": { + "Stop": [], + "Notification": [{"matcher": "", "hooks": []}, {"matcher": "x"}], + "SessionEnd": [block(LEDGER_END)], + }})) + + assert module.patch_path(path) is True + + assert json.loads(path.read_text()) == {"hooks": { + "Stop": [], + "Notification": [{"matcher": "", "hooks": []}, {"matcher": "x"}], + }} -def test_new_factory_hooks_file_has_no_backup_and_is_idempotent(tmp_path): +def test_only_the_retired_scripts_match(tmp_path): module = load_module() - path = tmp_path / ".factory" / "hooks.json" + kept = [ + AUTO_RESUME, + "python3 $HOME/bin/not-cmux-registry.py", + "python3 $HOME/.claude/hooks/cmux-registry.py.disabled", + "echo cmux-session-ledger", + "cmux hooks claude session-start", + ] + path = tmp_path / "settings.json" + path.write_text(json.dumps({"hooks": {"SessionStart": [block(*kept)]}})) - assert module.patch_path(path) + assert module.patch_path(path) is False + assert commands(json.loads(path.read_text()), "SessionStart") == kept + assert not list(tmp_path.glob("settings.json.bak-*")) + + +def test_unexpected_shapes_are_skipped_not_fatal(tmp_path): + module = load_module() + path = tmp_path / "settings.json" + path.write_text(json.dumps({"hooks": { + "Stop": {"not": "a list"}, + "Notification": ["not a block", {"matcher": "", "hooks": "not a list"}], + "PreToolUse": [{"matcher": "", "hooks": ["not an entry", hook(REGISTRY)]}], + "SessionEnd": [block(LEDGER_END)], + }})) + + assert module.patch_path(path) is True + + assert json.loads(path.read_text()) == {"hooks": { + "Stop": {"not": "a list"}, + "Notification": ["not a block", {"matcher": "", "hooks": "not a list"}], + "PreToolUse": [{"matcher": "", "hooks": ["not an entry"]}], + }} + + +def test_second_run_changes_nothing(tmp_path, capsys): + module = load_module() + path = tmp_path / "settings.json" + path.write_text(json.dumps(live_claude_settings())) + assert module.patch_path(path) is True first = path.read_text() - assert not list(path.parent.glob("hooks.json.bak-*")) assert module.patch_path(path) is False + assert path.read_text() == first - assert not list(path.parent.glob("hooks.json.bak-*")) + assert len(list(tmp_path.glob("settings.json.bak-*"))) == 1 + assert capsys.readouterr().out.rstrip().endswith("no changes needed") + + +def test_missing_file_is_not_created(tmp_path): + module = load_module() + path = tmp_path / ".factory" / "hooks.json" + assert module.patch_path(path) is False + assert not path.parent.exists() + + +def test_malformed_or_unexpected_documents_are_left_untouched(tmp_path): + module = load_module() + for name, text in [("corrupt.json", "{ not json"), ("list.json", "[]"), + ("no-hooks.json", '{"model": "opus"}'), + ("bad-hooks.json", '{"hooks": []}')]: + path = tmp_path / name + path.write_text(text) + assert module.patch_path(path) is False + assert path.read_text() == text + assert sorted(p.name for p in tmp_path.iterdir()) == [ + "bad-hooks.json", "corrupt.json", "list.json", "no-hooks.json"] def test_symlinked_target_is_written_through_not_severed(tmp_path): @@ -125,63 +201,55 @@ def test_symlinked_target_is_written_through_not_severed(tmp_path): module = load_module() real = tmp_path / "machine-config" / "settings.json" real.parent.mkdir(parents=True) - real.write_text(json.dumps({"existing": "keep"})) + real.write_text(json.dumps({"existing": "keep", "hooks": { + "SessionStart": [block(LEDGER_START), block("keep-me")]}})) link = tmp_path / ".claude" / "settings.json" link.parent.mkdir(parents=True) link.symlink_to(real) - assert module.patch_path(link) - # The symlink is intact and still points at the real file. + assert module.patch_path(link) is True + assert link.is_symlink() assert Path(link).resolve() == real.resolve() - # The hooks landed in the REAL file (written through), preserving prior keys. - doc = json.loads(real.read_text()) - assert doc["existing"] == "keep" - assert "SessionStart" in doc["hooks"] + assert json.loads(real.read_text()) == {"existing": "keep", "hooks": { + "SessionStart": [block("keep-me")]}} + assert len(list(real.parent.glob("settings.json.bak-*"))) == 1 -# --------------------------------------------------------------------------- fresh-machine CLI (main()) +def test_cli_cleans_claude_and_factory_files_in_one_run(tmp_path): + claude = tmp_path / ".claude" / "settings.json" + factory = tmp_path / ".factory" / "hooks.json" + claude.parent.mkdir() + claude.write_text(json.dumps(live_claude_settings())) -def test_fresh_machine_no_settings_file(tmp_path): - # settings.json absent (+ a not-yet-created .claude dir) — the fresh case. - settings = tmp_path / ".claude" / "settings.json" - r = _run(settings) - assert r.returncode == 0, f"patch-settings crashed on a fresh machine:\n{r.stderr}" - assert settings.exists(), "settings.json should be created fresh" - data = json.loads(settings.read_text()) - hooks = data["hooks"] - assert "SessionStart" in hooks and "SessionEnd" in hooks - # commands live at SessionStart[].hooks[].command (nested matcher blocks) - cmds = _session_start_cmds(data) - assert any("cmux-session-ledger.py start" in c for c in cmds) - - -def test_no_backup_created_when_file_absent(tmp_path): - settings = tmp_path / ".claude" / "settings.json" - _run(settings) - baks = list((tmp_path / ".claude").glob("settings.json.bak-*")) - assert not baks, f"no .bak should be made for a file that never existed: {baks}" + result = _run(claude, factory) + assert result.returncode == 0, result.stderr + assert f" - SessionStart: {REGISTRY}" in result.stdout + assert f" - SessionEnd: {LEDGER_END}" in result.stdout + assert f"{factory} does not exist" in result.stdout + assert not factory.exists() + remaining = json.loads(claude.read_text()) + assert commands(remaining, "SessionStart") == [ + AUTO_RESUME, "python3 $HOME/.claude/hooks/memory-boot.py"] + assert "SessionEnd" not in remaining["hooks"] -def test_idempotent_second_run(tmp_path): - settings = tmp_path / ".claude" / "settings.json" - _run(settings) # fresh create - first = json.loads(settings.read_text()) - r2 = _run(settings) # re-run on existing file - assert r2.returncode == 0 - second = json.loads(settings.read_text()) - # no duplicate hook commands added on the second pass - assert _session_start_cmds(first) == _session_start_cmds(second), \ - "second run must not duplicate hooks" +def test_cli_cleans_the_other_paths_when_one_is_unreadable(tmp_path): + unreadable = tmp_path / ".claude" / "settings.json" + unreadable.mkdir(parents=True) + factory = tmp_path / ".factory" / "hooks.json" + factory.parent.mkdir() + factory.write_text(json.dumps({"hooks": {"SessionEnd": [block(LEDGER_END)]}})) -def test_preserves_existing_user_settings(tmp_path): - settings = tmp_path / ".claude" / "settings.json" - settings.parent.mkdir(parents=True) - settings.write_text(json.dumps({"model": "opus", "hooks": {}})) - r = _run(settings) - assert r.returncode == 0 - data = json.loads(settings.read_text()) - assert data["model"] == "opus", "must preserve unrelated user settings" - # and a backup of the pre-existing file WAS made - assert list((tmp_path / ".claude").glob("settings.json.bak-*")), "existing file should be backed up" + result = _run(unreadable, factory) + + assert result.returncode == 1 + assert f"{unreadable} could not be cleaned:" in result.stderr + assert json.loads(factory.read_text()) == {"hooks": {}} + + +def test_cli_requires_a_path(): + result = _run() + assert result.returncode == 2 + assert "usage: patch-settings.py PATH" in result.stderr diff --git a/tests/test_self_update.py b/tests/test_self_update.py index 77d08eb..b7db6f3 100644 --- a/tests/test_self_update.py +++ b/tests/test_self_update.py @@ -759,7 +759,7 @@ def test_conflict_markers_are_refused_before_touching_the_tree(self): f"{new_sha} python{sys.version_info[0]}.{sys.version_info[1]}") def test_broken_runtime_python_outside_bin_is_refused(self): - for rel in ("src/assistant/model_tiers.py", "hooks/cmux-session-ledger.py", + for rel in ("src/assistant/model_tiers.py", "hooks/example-hook.py", "install/patch-settings.py"): with self.subTest(rel=rel), TemporaryDirectory() as t: tmp = Path(t) diff --git a/tests/test_session_context_watcher.py b/tests/test_session_context_watcher.py index 2c0183f..7071f1b 100644 --- a/tests/test_session_context_watcher.py +++ b/tests/test_session_context_watcher.py @@ -3,9 +3,10 @@ The script is a hyphenated CLI (not an importable module name), so it's loaded by file path via importlib. The module binds HOME-derived path constants AT IMPORT, so we set os.environ["HOME"] to a tmp dir BEFORE the first import — every -constant (CMUX_REGISTRY, ORCHESTRATOR_REGISTRY, OUT_PATH, LOG_DIR, LOCK_FILE) -then resolves under that tmp dir. Per-test we monkeypatch individual -module-level constants so nothing ever touches the real ~/.claude or ~/.architect. +constant (ORCHESTRATOR_REGISTRY, OUT_PATH, LOG_DIR, LOCK_FILE) then resolves +under that tmp dir. Per-test we monkeypatch individual module-level constants so +nothing ever touches the real ~/.claude or ~/.architect, and CMUX_BIN points at +a fake `cmux` (tests/fake_cmux.py) that answers `sessions list`. kqueue is real here (macOS); we exercise add_watch / drop_watch / handle_event / discover / flush against real tmp files and a real select.kqueue. @@ -25,6 +26,7 @@ import pytest +import fake_cmux from assistant.session_guidance import matching_note REPO = Path(__file__).resolve().parent.parent @@ -58,14 +60,13 @@ def tmp_home(tmp_path, monkeypatch): so log() writes are safe.""" home = tmp_path / "home" home.mkdir() - cmux_reg = home / ".claude/cmux-registry.json" + monkeypatch.setenv("CMUX_BIN", str(fake_cmux.install(home / "cmux-bin"))) orch_reg = home / ".architect/orchestrator-registry.json" out_path = home / ".claude/cache/session-context.json" world_path = home / ".claude/cache/world.json" log_dir = home / ".assistant/logs" lock_file = home / ".architect/.session-context-watcher.lock" monkeypatch.setattr(scw, "HOME", home) - monkeypatch.setattr(scw, "CMUX_REGISTRY", cmux_reg) monkeypatch.setattr(scw, "ORCHESTRATOR_REGISTRY", orch_reg) monkeypatch.setattr(scw, "OUT_PATH", out_path) monkeypatch.setattr(scw, "WORLD_PATH", world_path) @@ -139,17 +140,24 @@ def test_pid_alive_non_int(): # ─── load_live_claude_sessions ─────────────────────────────────────────────── -def test_load_live_sessions_missing_registry(tmp_home): - # No file → empty dict, no crash. +def test_load_live_sessions_without_cmux_records(tmp_home): + # No records → empty dict, no crash. assert scw.load_live_claude_sessions() == {} +def test_load_live_sessions_when_cmux_fails(tmp_home): + live = fake_cmux.record("S-LIVE", pid=os.getpid(), transcript_path="/t/live.jsonl") + _write_sessions(tmp_home, stdout=json.dumps({"sessions": [live]}), returncode=1) + assert scw.load_live_claude_sessions() == {} + _write_sessions(tmp_home, [live]) + assert set(scw.load_live_claude_sessions()) == {"S-LIVE"} + + @pytest.mark.parametrize("contents", [None, "{incomplete", "[]", "null"]) -def test_unreadable_world_never_restores_historical_registry(tmp_home, monkeypatch, contents): - _write_registry(tmp_home, { - "historical": {"claude_pid": os.getpid(), "session_id": "old-session", - "transcript_path": "/history.jsonl"}, - }) +def test_unreadable_world_never_restores_historical_records(tmp_home, monkeypatch, contents): + _write_sessions(tmp_home, [ + fake_cmux.record("old-session", pid=os.getpid(), transcript_path="/history.jsonl"), + ]) if contents is None: monkeypatch.setattr(scw, "WORLD_PATH", scw.WORLD_PATH.with_name("missing.json")) else: @@ -161,18 +169,50 @@ def test_unreadable_world_never_restores_historical_registry(tmp_home, monkeypat def test_load_live_sessions_filters_dead_keeps_live(tmp_home): live_pid = os.getpid() dead_pid = 2_000_000_000 - scw.CMUX_REGISTRY.parent.mkdir(parents=True, exist_ok=True) - scw.CMUX_REGISTRY.write_text(json.dumps({ - "tab-live": {"claude_pid": live_pid, "session_id": "S-LIVE", - "cwd": "/x", "transcript_path": "/t/live.jsonl", "ts": 100}, - "tab-dead": {"claude_pid": dead_pid, "session_id": "S-DEAD", - "cwd": "/y", "transcript_path": "/t/dead.jsonl", "ts": 100}, - "tab-nosid": {"claude_pid": live_pid, "cwd": "/z", "ts": 100}, # no sid - })) + _write_sessions(tmp_home, [ + fake_cmux.record("S-LIVE", pid=live_pid, cwd="/x/later", launch_working_directory="/x", + transcript_path="/t/live.jsonl", + workspace_id="ws-live", surface_id="sf-live", updated_at_unix=100), + fake_cmux.record("S-NO-LAUNCH", pid=live_pid, cwd="/only-cwd", + launch_working_directory=None, transcript_path="/t/x.jsonl", + workspace_id="ws-x", surface_id="sf-x", updated_at_unix=100), + fake_cmux.record("S-DEAD", pid=dead_pid, cwd="/y", transcript_path="/t/dead.jsonl", + workspace_id="ws-dead", surface_id="sf-dead"), + fake_cmux.record("S-NOPID", cwd="/z", workspace_id="ws-nopid", surface_id="sf-nopid"), + ]) out = scw.load_live_claude_sessions() - assert set(out.keys()) == {"S-LIVE"} - assert out["S-LIVE"]["pid"] == live_pid - assert out["S-LIVE"]["tab_id"] == "tab-live" + assert out == { + "S-LIVE": { + "pid": live_pid, "cwd": "/x", "transcript_path": "/t/live.jsonl", + "provider": "claude", "ts": 100.0, "tab_id": "ws-live", + }, + "S-NO-LAUNCH": { + "pid": live_pid, "cwd": "/only-cwd", "transcript_path": "/t/x.jsonl", + "provider": "claude", "ts": 100.0, "tab_id": "ws-x", + }, + } + + +def test_load_live_sessions_keeps_only_each_surfaces_active_record(tmp_home): + # A /clear starts a new session in the same process: the earlier record on + # that surface shares the live pid but no longer runs there. + live_pid = os.getpid() + _write_sessions(tmp_home, [ + fake_cmux.record("before-clear", pid=live_pid, active_for_surface=False), + fake_cmux.record("after-clear", pid=live_pid, active_for_surface=True), + ]) + assert set(scw.load_live_claude_sessions()) == {"after-clear"} + + +def test_load_live_sessions_maps_droid_and_skips_agents_the_fleet_does_not_run(tmp_home): + live_pid = os.getpid() + _write_sessions(tmp_home, [ + fake_cmux.record("droid-session", agent="factory", pid=live_pid, surface_id="sf-1"), + fake_cmux.record("codex-session", agent="codex", pid=live_pid, surface_id="sf-2"), + ]) + out = scw.load_live_agent_sessions() + assert set(out) == {"droid-session"} + assert out["droid-session"]["provider"] == "droid" def test_load_live_sessions_includes_droid_from_world(tmp_home): @@ -196,11 +236,11 @@ def test_load_live_sessions_includes_droid_from_world(tmp_home): @pytest.mark.parametrize("binding", [ "unknown", "missing_status", "missing_workspace", "missing_surface", "verified", "dead", ]) -def test_load_live_sessions_identity_world_excludes_reused_registry_pids(tmp_home, binding): - _write_registry(tmp_home, { - "old": {"claude_pid": os.getpid(), "session_id": "historical", - "cwd": "/old", "transcript_path": "/history.jsonl", "ts": 100}, - }) +def test_load_live_sessions_identity_world_excludes_reused_record_pids(tmp_home, binding): + _write_sessions(tmp_home, [ + fake_cmux.record("historical", pid=os.getpid(), cwd="/old", + transcript_path="/history.jsonl"), + ]) entry = {"session_id": "current", "pid": os.getpid(), "provider": "claude", "workspace_id": "workspace", "surface_id": "surface", "identity_status": "verified", "transcript_path": "/current.jsonl"} @@ -222,11 +262,10 @@ def test_load_live_sessions_identity_world_excludes_reused_registry_pids(tmp_hom assert set(scw.load_live_agent_sessions()) == ({"current"} if binding == "verified" else set()) -def test_load_live_sessions_empty_identity_world_does_not_restore_registry(tmp_home): - _write_registry(tmp_home, { - "old": {"claude_pid": os.getpid(), "session_id": "historical", - "transcript_path": "/history.jsonl"}, - }) +def test_load_live_sessions_empty_identity_world_does_not_restore_records(tmp_home): + _write_sessions(tmp_home, [ + fake_cmux.record("historical", pid=os.getpid(), transcript_path="/history.jsonl"), + ]) scw.WORLD_PATH.parent.mkdir(parents=True, exist_ok=True) scw.WORLD_PATH.write_text(json.dumps({ "live_sessions": [], @@ -235,35 +274,6 @@ def test_load_live_sessions_empty_identity_world_does_not_restore_registry(tmp_h assert scw.load_live_agent_sessions() == {} -def test_load_live_sessions_dup_keeps_most_recent_ts(tmp_home): - live_pid = os.getpid() - scw.CMUX_REGISTRY.parent.mkdir(parents=True, exist_ok=True) - scw.CMUX_REGISTRY.write_text(json.dumps({ - "tab-old": {"claude_pid": live_pid, "session_id": "S", "cwd": "/old", - "transcript_path": "/t/old.jsonl", "ts": 100}, - "tab-new": {"claude_pid": live_pid, "session_id": "S", "cwd": "/new", - "transcript_path": "/t/new.jsonl", "ts": 200}, - })) - out = scw.load_live_claude_sessions() - # Same session_id; the higher-ts entry wins regardless of iteration order. - assert out["S"]["cwd"] == "/new" - assert out["S"]["ts"] == 200 - - -def test_load_live_sessions_dup_reverse_order(tmp_home): - # newer entry first, older second — the older must NOT clobber the newer. - live_pid = os.getpid() - scw.CMUX_REGISTRY.parent.mkdir(parents=True, exist_ok=True) - scw.CMUX_REGISTRY.write_text(json.dumps({ - "tab-new": {"claude_pid": live_pid, "session_id": "S", "cwd": "/new", - "transcript_path": "/t/new.jsonl", "ts": 200}, - "tab-old": {"claude_pid": live_pid, "session_id": "S", "cwd": "/old", - "transcript_path": "/t/old.jsonl", "ts": 100}, - })) - out = scw.load_live_claude_sessions() - assert out["S"]["cwd"] == "/new" - - # ─── load_cron_workers ─────────────────────────────────────────────────────── def test_load_cron_workers_missing(tmp_home): @@ -1505,18 +1515,16 @@ def test_to_dict_recent_turns_capped(tmp_path): # ─── Watcher.find_active_transcripts ───────────────────────────────────────── -def _write_registry(tmp_home, entries): - scw.CMUX_REGISTRY.parent.mkdir(parents=True, exist_ok=True) - scw.CMUX_REGISTRY.write_text(json.dumps(entries)) +def _write_sessions(tmp_home, records=(), **kwargs): + fake_cmux.write(tmp_home / "cmux-bin", records, **kwargs) def test_find_active_transcripts_recent_returned(tmp_home, tmp_path): tfile = tmp_path / "live.jsonl" tfile.write_text(_user_line("hi") + "\n") - _write_registry(tmp_home, { - "tab-1": {"claude_pid": os.getpid(), "session_id": "S1", "cwd": "/proj", - "transcript_path": str(tfile), "ts": 1}, - }) + _write_sessions(tmp_home, [ + fake_cmux.record("S1", pid=os.getpid(), cwd="/proj", transcript_path=str(tfile)), + ]) w = scw.Watcher() try: cutoff = (scw.utc_now() - timedelta(hours=scw.ACTIVITY_HOURS)).timestamp() @@ -1536,10 +1544,9 @@ def test_find_active_transcripts_old_mtime_retained(tmp_home, tmp_path): # Backdate mtime well past the activity window. old = time.time() - (scw.ACTIVITY_HOURS + 5) * 3600 os.utime(tfile, (old, old)) - _write_registry(tmp_home, { - "tab-1": {"claude_pid": os.getpid(), "session_id": "S1", "cwd": "/proj", - "transcript_path": str(tfile), "ts": 1}, - }) + _write_sessions(tmp_home, [ + fake_cmux.record("S1", pid=os.getpid(), cwd="/proj", transcript_path=str(tfile)), + ]) w = scw.Watcher() try: cutoff = (scw.utc_now() - timedelta(hours=scw.ACTIVITY_HOURS)).timestamp() @@ -1549,11 +1556,11 @@ def test_find_active_transcripts_old_mtime_retained(tmp_home, tmp_path): def test_find_active_transcripts_missing_file_skipped(tmp_home, tmp_path): - _write_registry(tmp_home, { - "tab-1": {"claude_pid": os.getpid(), "session_id": "S1", "cwd": "/proj", - "transcript_path": str(tmp_path / "ghost.jsonl"), "ts": 1}, - "tab-2": {"claude_pid": os.getpid(), "session_id": "S2", "cwd": "/proj"}, - }) # tab-2 has no transcript_path + _write_sessions(tmp_home, [ + fake_cmux.record("S1", pid=os.getpid(), cwd="/proj", + transcript_path=str(tmp_path / "ghost.jsonl"), surface_id="sf-1"), + fake_cmux.record("S2", pid=os.getpid(), cwd="/proj", surface_id="sf-2"), + ]) # S2 has no transcript_path w = scw.Watcher() try: cutoff = (scw.utc_now() - timedelta(hours=scw.ACTIVITY_HOURS)).timestamp() @@ -1566,11 +1573,10 @@ def test_find_active_transcripts_cron_tagging(tmp_home, tmp_path): tfile = tmp_path / "cron.jsonl" tfile.write_text(_user_line("cron work") + "\n") # cron cwd is derived from the (patched) module HOME — build it portably - _write_registry(tmp_home, { - "tab-c": {"claude_pid": os.getpid(), "session_id": "SC", - "cwd": str(tmp_home / ".architect"), - "transcript_path": str(tfile), "ts": 1}, - }) + _write_sessions(tmp_home, [ + fake_cmux.record("SC", pid=os.getpid(), cwd=str(tmp_home / ".architect"), + transcript_path=str(tfile)), + ]) w = scw.Watcher() try: cutoff = (scw.utc_now() - timedelta(hours=scw.ACTIVITY_HOURS)).timestamp() @@ -1587,10 +1593,9 @@ def test_find_active_transcripts_stat_oserror_skipped(tmp_home, tmp_path, monkey is skipped (lines 325-326).""" tfile = tmp_path / "racey.jsonl" tfile.write_text(_user_line("hi") + "\n") - _write_registry(tmp_home, { - "tab-1": {"claude_pid": os.getpid(), "session_id": "S1", "cwd": "/proj", - "transcript_path": str(tfile), "ts": 1}, - }) + _write_sessions(tmp_home, [ + fake_cmux.record("S1", pid=os.getpid(), cwd="/proj", transcript_path=str(tfile)), + ]) real_stat = Path.stat def flaky_stat(self, *a, **k): @@ -1789,20 +1794,20 @@ def test_handle_event_delete_drops_watch(tmp_home, tmp_path): # ─── Watcher.discover ───────────────────────────────────────────────────────── -def test_discover_identity_world_keeps_idle_verified_session_not_registry_ghosts( +def test_discover_identity_world_keeps_idle_verified_session_not_record_ghosts( tmp_home, tmp_path): paths = {} old = scw.utc_now() - timedelta(hours=72) - for name in ("verified", "unknown", "registry-only"): + for name in ("verified", "unknown", "record-only"): path = tmp_path / f"{name}.jsonl" path.write_text(_user_line("Resume this work.", scw.iso(old)) + "\n") os.utime(path, (old.timestamp(), old.timestamp())) paths[name] = path - _write_registry(tmp_home, { - name: {"claude_pid": os.getpid(), "session_id": name, - "transcript_path": str(path), "cwd": "/project"} + _write_sessions(tmp_home, [ + fake_cmux.record(name, pid=os.getpid(), transcript_path=str(path), cwd="/project", + surface_id=f"surface-{name}") for name, path in paths.items() - }) + ]) scw.WORLD_PATH.parent.mkdir(parents=True, exist_ok=True) scw.WORLD_PATH.write_text(json.dumps({"live_sessions": [ {"session_id": name, "pid": os.getpid(), "transcript_path": str(paths[name]), @@ -1834,9 +1839,9 @@ def test_discover_keeps_days_old_live_guidance_and_scanner_verifies_it( os.utime(path, (old.timestamp(), old.timestamp())) historical = tmp_path / "unregistered-history.jsonl" historical.write_text(_user_line("Do not watch historical sessions.") + "\n") - registration = {"claude_pid": os.getpid(), "session_id": path.stem, - "cwd": "/project", "transcript_path": str(path), "ts": 1} - _write_registry(tmp_home, {"tab-live": registration}) + registration = fake_cmux.record(path.stem, pid=os.getpid(), cwd="/project", + transcript_path=str(path)) + _write_sessions(tmp_home, [registration]) spec = importlib.util.spec_from_file_location( "scanner_for_idle_watcher", str(REPO / "bin/world-scanner.py")) scanner = importlib.util.module_from_spec(spec) @@ -1864,10 +1869,10 @@ def test_discover_keeps_days_old_live_guidance_and_scanner_verifies_it( assert live[path.stem]["context_checked_at"] == scanner.iso(checked) assert live[path.stem]["context_built_at"] == cached["_meta"]["built_at"] if drop_reason == "dead_pid": - registration["claude_pid"] = 2_000_000_000 + registration["pid"] = 2_000_000_000 else: registration.pop("session_id") - _write_registry(tmp_home, {"tab-live": registration}) + _write_sessions(tmp_home, [registration]) watcher.discover() watcher.flush() assert watcher.path_to_fd == {} @@ -1881,10 +1886,9 @@ def test_discover_keeps_days_old_live_guidance_and_scanner_verifies_it( def test_discover_adds_live_and_drops_dead(tmp_home, tmp_path): live_file = tmp_path / "live.jsonl" live_file.write_text(_user_line("active") + "\n") - _write_registry(tmp_home, { - "tab-1": {"claude_pid": os.getpid(), "session_id": "S1", "cwd": "/proj", - "transcript_path": str(live_file), "ts": 1}, - }) + _write_sessions(tmp_home, [ + fake_cmux.record("S1", pid=os.getpid(), cwd="/proj", transcript_path=str(live_file)), + ]) w = scw.Watcher() try: w.discover() @@ -2010,17 +2014,16 @@ def test_flush_recent_inputs_sorted_and_limited(tmp_home, tmp_path): def test_main_once_writes_out_path(tmp_home, tmp_path, monkeypatch, capsys): tfile = tmp_path / "live.jsonl" tfile.write_text(_user_line("hi", ts=scw.iso(scw.utc_now())) + "\n") - _write_registry(tmp_home, { - "tab-1": {"claude_pid": os.getpid(), "session_id": "S1", "cwd": "/proj", - "transcript_path": str(tfile), "ts": 1}, - }) + _write_sessions(tmp_home, [ + fake_cmux.record("S1", pid=os.getpid(), cwd="/proj", transcript_path=str(tfile)), + ]) monkeypatch.setattr(sys, "argv", ["session-context-watcher.py", "--once"]) scw.main() out = capsys.readouterr().out assert "flushed 1 sessions" in out assert scw.OUT_PATH.exists() payload = json.loads(scw.OUT_PATH.read_text()) - # by_session is keyed by the transcript file stem, not the registry sid. + # by_session is keyed by the transcript file stem, not the record's sid. assert "live" in payload["by_session"] assert payload["by_session"]["live"]["cwd"] == "/proj" diff --git a/tests/test_world_scanner.py b/tests/test_world_scanner.py index 4ceace9..a56af3a 100644 --- a/tests/test_world_scanner.py +++ b/tests/test_world_scanner.py @@ -2,10 +2,11 @@ Loaded by file path (hyphenated CLI, not an importable module). HOME is pointed at a tmp dir BEFORE import so every module-level path constant (OUT_PATH, -CMUX_REGISTRY, ORCH_REGISTRY, SESSION_CTX, ...) binds under the tmp HOME. No real -~/.claude or ~/.architect is touched, and cmux/ps/vm_stat are never shelled out: -every test monkeypatches the module's cmux_tree / ps_tty / read_mem_pct / -subprocess.run. +ORCH_REGISTRY, SESSION_CTX, ...) binds under the tmp HOME. No real ~/.claude or +~/.architect is touched, and the real cmux/ps/vm_stat are never shelled out: +tests monkeypatch the module's cmux_tree / ps_tty / read_mem_pct / +subprocess.run, or point CMUX_BIN at a fake `cmux` (tests/fake_cmux.py) for +`cmux sessions list`. """ from __future__ import annotations @@ -18,6 +19,9 @@ import pytest +import cmux_sessions +import fake_cmux + REPO = Path(__file__).resolve().parent.parent SCANNER_PATH = REPO / "bin/world-scanner.py" @@ -46,6 +50,18 @@ def fake_completed(stdout="", returncode=0): return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="") +def native(session_id="session", **fields): + """A parsed cmux session record, as bin/cmux_sessions.py returns it.""" + base = { + "session_id": session_id, "agent": "claude", "workspace_id": None, + "surface_id": None, "cwd": None, "launch_cwd": None, "pid": None, + "transcript_path": None, "started_at": None, "updated_at": None, + "active_for_surface": True, + } + base.update(fields) + return cmux_sessions.Session(**base) + + @pytest.mark.parametrize("provider,root", [ ("claude", ".claude/projects"), ("droid", ".factory/sessions"), ]) @@ -300,51 +316,35 @@ def test_build_workspace_index_none_tree(ws): # ─── build_live_sessions ────────────────────────────────────────────────────── -def test_build_live_sessions_filters_dead_and_dedups(ws): - live = os.getpid() - reg = { - "tab-live-old": {"claude_pid": live, "session_id": "sess-A", - "cwd": "/work/a", "transcript_path": "/t/a-old.jsonl", "ts": 100}, - "tab-live-new": {"claude_pid": live, "session_id": "sess-A", - "cwd": "/work/a2", "transcript_path": "/t/a-new.jsonl", "ts": 200}, - "tab-dead": {"claude_pid": 99999999, "session_id": "sess-B", - "cwd": "/work/b", "transcript_path": "/t/b.jsonl", "ts": 300}, - "tab-no-sid": {"claude_pid": live, "session_id": None, - "cwd": "/work/c", "ts": 400}, - } - ws.CMUX_REGISTRY.parent.mkdir(parents=True, exist_ok=True) - ws.CMUX_REGISTRY.write_text(json.dumps(reg)) - - out = ws.build_live_sessions() - # Dead pid filtered, sid-less filtered → only sess-A remains. - assert len(out) == 1 - a = next(iter(out.values())) - assert a["session_id"] == "sess-A" - # Dedup keeps the most-recent ts (200, cwd /work/a2). - assert a["ts"] == 200 - assert a["cwd"] == "/work/a2" - assert a["pid"] == live - assert a["tab_id"] == "tab-live-new" - assert a["transcript_path"] == "/t/a-new.jsonl" - - -def test_build_live_sessions_dedup_keeps_first_when_second_is_older(ws): - # When the newer entry is encountered FIRST, the older one that follows must - # be skipped (hits the prev.ts > e.ts guard). +def test_build_live_sessions_keeps_live_active_fleet_records(ws): live = os.getpid() - reg = { - "tab-new": {"claude_pid": live, "session_id": "sess-A", - "cwd": "/work/new", "ts": 200}, - "tab-old": {"claude_pid": live, "session_id": "sess-A", - "cwd": "/work/old", "ts": 100}, + records = [ + native("sess-A", pid=live, cwd="/work/a/sub", launch_cwd="/work/a", + transcript_path="/t/a.jsonl", + workspace_id="WS-A", surface_id="SF-A", updated_at=200.0), + native("sess-dead", pid=99999999, workspace_id="WS-B", surface_id="SF-B"), + native("sess-no-pid", workspace_id="WS-C", surface_id="SF-C"), + native("sess-cleared", pid=live, active_for_surface=False, + workspace_id="WS-A", surface_id="SF-A"), + native("sess-codex", agent="codex", pid=live, workspace_id="WS-D", surface_id="SF-D"), + native("sess-droid", agent="factory", pid=live, workspace_id="WS-E", surface_id="SF-E"), + ] + out = ws.build_live_sessions(records=records) + assert [s["session_id"] for s in out.values()] == ["sess-A", "sess-droid"] + a = out[("claude", "sess-A", "ws-a", "sf-a")] + assert a == { + "session_id": "sess-A", "pid": live, "cwd": "/work/a", + "transcript_path": "/t/a.jsonl", "provider": "claude", "tab_id": "WS-A", + "ts": 200.0, "workspace_id": "WS-A", "surface_id": "SF-A", + "identity_status": "unknown", "identity_source": "registry", + "agent_status": "unknown", } - ws.CMUX_REGISTRY.parent.mkdir(parents=True, exist_ok=True) - ws.CMUX_REGISTRY.write_text(json.dumps(reg)) - out = ws.build_live_sessions() - assert len(out) == 1 - session = next(iter(out.values())) - assert session["ts"] == 200 - assert session["cwd"] == "/work/new" + droid = out[("droid", "sess-droid", "ws-e", "sf-e")] + assert (droid["provider"], droid["cwd"]) == ("droid", None) + + +def test_build_live_sessions_without_records_or_workspaces(ws): + assert ws.build_live_sessions() == {} def test_build_live_sessions_adds_bound_droid_session(ws, monkeypatch): @@ -374,7 +374,7 @@ def test_build_live_sessions_adds_bound_droid_session(ws, monkeypatch): def _seed_identity_world(ws, monkeypatch, kinds=("claude", "factory"), session_ids=None): now = ws.parse_iso("2026-09-19T09:00:00Z") first = ws.parse_iso("2026-09-01T09:00:00Z") - workspaces, records, contexts, starts, calls = [], [], {}, [], [] + workspaces, records, contexts, cmux_records, calls = [], [], {}, [], [] for index, kind in enumerate(kinds, 1): provider = "droid" if kind in {"factory", "droid"} else kind sid = session_ids[index - 1] if session_ids else f"session-{index}" @@ -396,10 +396,10 @@ def _seed_identity_world(ws, monkeypatch, kinds=("claude", "factory"), session_i "last_user": {"text": f"Context for {sid}", "ts": ws.iso(now - timedelta(minutes=1))}, "queue_pending": 0, "user_unanswered": True, "recent_turns": [], } - starts.append({ - "event": "start", "ts": first.timestamp(), "session_id": sid, - "workspace_id": workspace_id.upper(), "provider": kind, - }) + cmux_records.append(fake_cmux.record( + sid, agent=kind, workspace_id=workspace_id.upper(), surface_id=surface_id, + started_at=ws.iso(first), + )) if kind in {"claude", "factory", "droid"}: root = ".claude/projects" if provider == "claude" else ".factory/sessions" path = ws.HOME / root / "same-cwd" / f"{sid}.jsonl" @@ -408,6 +408,8 @@ def _seed_identity_world(ws, monkeypatch, kinds=("claude", "factory"), session_i def run(args, **kwargs): calls.append(args) + if args[1:3] == ["sessions", "list"]: + return fake_completed(json.dumps({"sessions": cmux_records})) if "tree" in args: return fake_completed(json.dumps({"windows": [{"workspaces": workspaces}]})) if args[1:4] == ["surface", "resume", "show"]: @@ -438,9 +440,7 @@ def run(args, **kwargs): monkeypatch.setattr(ws, "pid_alive", lambda pid: bool(pid)) ws.SESSION_CTX.parent.mkdir(parents=True) ws.SESSION_CTX.write_text(json.dumps({"by_session": contexts})) - ledger = ws.HOME / ".cmux-session-ledger.jsonl" - ledger.write_text("".join(json.dumps(row) + "\n" for row in starts)) - return records, contexts, ledger, calls + return records, contexts, cmux_records, calls def test_build_identity_keeps_same_cwd_sessions_separate(ws, monkeypatch): @@ -448,6 +448,8 @@ def test_build_identity_keeps_same_cwd_sessions_separate(ws, monkeypatch): ws.build() world = json.loads(ws.OUT_PATH.read_text()) assert any(args[1:4] == ["--id-format", "both", "tree"] for args in calls) + assert [args[1:] for args in calls if args[1:3] == ["sessions", "list"]] == [ + ["sessions", "list", "--json", "--all"]] binding_calls = [args for args in calls if args[1:4] == ["surface", "resume", "show"]] assert len(binding_calls) == 2 for args, expected in zip(binding_calls, records): @@ -603,24 +605,22 @@ def test_context_copies_structured_pending_tool_use(ws, monkeypatch, pending): assert session["pending_tool_use"] is pending -def test_build_recycled_ref_rejects_old_registry_identity(ws, monkeypatch): - _, contexts, ledger, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) - ws.CMUX_REGISTRY.write_text(json.dumps({ - "old-tab": { - "claude_pid": 999, "session_id": "old-session", "ts": 10, - "cwd": "/work/shared", "workspace_id": "old-workspace", - "surface_id": "old-surface", - }, - })) +def test_build_recycled_ref_rejects_old_recorded_identity(ws, monkeypatch): + _, contexts, cmux_records, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) + cmux_records.append(fake_cmux.record( + "old-session", pid=999, updated_at_unix=10, cwd="/work/shared", + workspace_id="old-workspace", surface_id="old-surface", + started_at="1970-01-01T00:00:01Z", + )) contexts["old-session"] = { "provider": "claude", "last_user": {"text": "Old task", "ts": "2026-01-01T00:00:00Z"}, } ws.SESSION_CTX.write_text(json.dumps({"by_session": contexts})) - with ledger.open("a") as f: - f.write(json.dumps({ - "event": "start", "ts": 1, "workspace_id": "old-workspace", - "provider": "claude", "session_id": "session-1", - }) + "\n") + # The same session id recorded earlier in another workspace never counts. + cmux_records.append(fake_cmux.record( + "session-1", workspace_id="old-workspace", surface_id="old-surface", + started_at="1970-01-01T00:00:01Z", + )) ws.build() world = json.loads(ws.OUT_PATH.read_text()) assert world["workspaces"][0]["session_ids"] == ["session-1"] @@ -634,14 +634,9 @@ def test_build_recycled_ref_rejects_old_registry_identity(ws, monkeypatch): def test_first_recorded_is_not_last_activity(ws, monkeypatch): - _, contexts, ledger, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) - with ledger.open("a") as f: - f.write(json.dumps({ - "event": "start", "ts": ws.parse_iso("2026-09-18T09:00:00Z").timestamp(), - "workspace_id": "aaaaaaaa-0000-0000-0000-000000000001", - "provider": "claude", "session_id": "session-1", - }) + "\n") + _, contexts, cmux_records, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) for timestamp in ["2026-09-19T08:50:00Z", "2026-09-19T08:59:00Z"]: + cmux_records[0]["updated_at_unix"] = ws.parse_iso(timestamp).timestamp() contexts["session-1"]["last_user"]["ts"] = timestamp ws.SESSION_CTX.write_text(json.dumps({"by_session": contexts})) ws.build() @@ -650,7 +645,7 @@ def test_first_recorded_is_not_last_activity(ws, monkeypatch): assert session["last_turn_ts"] == timestamp -def test_context_and_ledger_accept_uuid_casing_differences(ws, monkeypatch): +def test_context_and_recorded_start_accept_uuid_casing_differences(ws, monkeypatch): records, contexts, _, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) contexts["session-1"].update({ "workspace_id": records[0]["workspace_id"].upper(), @@ -697,12 +692,10 @@ def test_unsupported_or_missing_binding_stays_unknown(ws, monkeypatch, kind): assert world["workspaces"][0]["session_ids"] == [] -def test_registry_without_live_binding_is_not_authoritative(ws, monkeypatch): - records, _, _, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) +def test_record_without_live_binding_is_not_authoritative(ws, monkeypatch): + _, _, cmux_records, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) monkeypatch.setattr(ws, "surface_resume_binding", lambda *_: None) - ws.CMUX_REGISTRY.write_text(json.dumps({ - "tab": {**records[0], "claude_pid": 101, "ts": 1, "cwd": "/work/shared"}, - })) + cmux_records[0].update(pid=101, updated_at_unix=1, cwd="/work/shared") ws.build() world = json.loads(ws.OUT_PATH.read_text()) session = world["live_sessions"][0] @@ -714,17 +707,16 @@ def test_registry_without_live_binding_is_not_authoritative(ws, monkeypatch): def _seed_current_hook(ws, monkeypatch, *, changes=None, process_change=None, native=None): - records, _, _, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) + records, _, cmux_records, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) registered = ws.utc_now().timestamp() - 60 - row = { - "session_id": "session-1", + cmux_records[0].update({ "surface_id": records[0]["surface_id"].upper(), - "claude_pid": "", - "ts": registered, - "cwd": "/work/shared", - } - row.update(changes or {}) - ws.CMUX_REGISTRY.write_text(json.dumps({records[0]["workspace_id"].upper(): row})) + "pid": None, + "updated_at_unix": registered, + "cwd": "/work/shared/later", + "launch_working_directory": "/work/shared", + }) + cmux_records[0].update(changes or {}) process = { "pid": 101, "pgid": 101, "tpgid": 101, "started": registered - 2, "command": "/Users/fixture/.local/bin/claude", @@ -752,14 +744,15 @@ def run(args, **kwargs): return calls -@pytest.mark.parametrize("recorded_pid", ["", "101"]) -def test_current_session_start_hook_verifies_missing_native_binding(ws, monkeypatch, recorded_pid): - calls = _seed_current_hook(ws, monkeypatch, changes={"claude_pid": recorded_pid}) +@pytest.mark.parametrize("recorded_pid", [None, 101]) +def test_current_session_record_verifies_missing_native_binding(ws, monkeypatch, recorded_pid): + calls = _seed_current_hook(ws, monkeypatch, changes={"pid": recorded_pid}) ws.build() world = json.loads(ws.OUT_PATH.read_text()) session = world["live_sessions"][0] assert session["session_id"] == "session-1" assert session["pid"] == 101 + assert session["cwd"] == "/work/shared" assert session["identity_status"] == "verified" assert session["identity_source"] == "session_start_registry" assert session["context_status"] == "verified" @@ -772,12 +765,14 @@ def test_current_session_start_hook_verifies_missing_native_binding(ws, monkeypa @pytest.mark.parametrize("changes,process_change", [ ({"surface_id": "different-surface"}, {}), ({"workspace_id": "different-workspace"}, {}), - ({"claude_pid": "999"}, {}), - ({"ts": 1}, {}), - ({"ts": "not-a-timestamp"}, {}), - ({"provider": "copilot"}, {}), + ({"active_for_surface": False}, {}), + ({"pid": 999}, {}), + ({"updated_at_unix": 1}, {}), + ({"updated_at_unix": "not-a-timestamp"}, {}), + ({"updated_at_unix": datetime.fromisoformat("2026-09-19T09:00:02+00:00").timestamp()}, {}), + ({"agent": "copilot"}, {}), ({}, {"started": datetime.fromisoformat("2026-09-19T08:59:30+00:00").timestamp()}), - ({"claude_pid": "101"}, {"started": datetime.fromisoformat("2026-09-19T08:59:30+00:00").timestamp()}), + ({"pid": 101}, {"started": datetime.fromisoformat("2026-09-19T08:59:30+00:00").timestamp()}), ({}, {"tpgid": 202}), ({}, {"command": "/usr/bin/login"}), ]) @@ -820,7 +815,7 @@ def run(args, **kwargs): @pytest.mark.parametrize("failure", ["invalid_json", "wrong_shape", "io_error"]) -def test_unreadable_native_binding_does_not_restore_registry_identity(ws, monkeypatch, failure): +def test_unreadable_native_binding_does_not_restore_recorded_identity(ws, monkeypatch, failure): _seed_current_hook(ws, monkeypatch) original_run = ws.subprocess.run @@ -872,16 +867,14 @@ def unexpected(*args, **kwargs): @pytest.mark.parametrize("override", [ - {"provider": "droid"}, + {"agent": "factory"}, {"workspace_id": "different-workspace"}, {"surface_id": "different-surface"}, {"surface_id": None}, ]) def test_tty_join_rejects_conflicting_or_missing_identity(ws, monkeypatch, override): - records, _, _, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) - ws.CMUX_REGISTRY.write_text(json.dumps({ - "old-tab": {**records[0], **override, "claude_pid": 999, "ts": 1}, - })) + _, _, cmux_records, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) + cmux_records[0].update(override, pid=999, updated_at_unix=1) ws.build() world = json.loads(ws.OUT_PATH.read_text()) historical = next(session for session in world["live_sessions"] if session["pid"] == 999) @@ -892,31 +885,41 @@ def test_tty_join_rejects_conflicting_or_missing_identity(ws, monkeypatch, overr assert world["workspaces"][0]["session_ids"] == ["session-1"] -def test_unsupported_registry_provider_cannot_inherit_claude_context(ws, monkeypatch): - records, _, _, _ = _seed_identity_world(ws, monkeypatch, (None,)) - ws.CMUX_REGISTRY.write_text(json.dumps({ - "old-tab": {**records[0], "provider": "copilot", "claude_pid": 999, "ts": 1}, - })) +def test_record_for_an_agent_the_fleet_does_not_run_is_not_a_live_session(ws, monkeypatch): + _, _, cmux_records, _ = _seed_identity_world(ws, monkeypatch, (None,)) + cmux_records[0].update(agent="copilot", pid=999, updated_at_unix=1) ws.build() - session = json.loads(ws.OUT_PATH.read_text())["live_sessions"][0] - assert session["provider"] == "copilot" - assert session["context_status"] == "unknown" - assert session["guidance_context"] is None - assert "last_user" not in session + world = json.loads(ws.OUT_PATH.read_text()) + assert world["live_sessions"] == [] + assert world["workspaces"][0]["surfaces"][0]["identity_status"] == "unknown" def test_recorded_start_requires_complete_verified_identity(ws): - ws.SESSION_LEDGER.write_text(json.dumps({ - "event": "start", "workspace_id": "", "provider": "claude", - "session_id": "session", "ts": 1, - }) + "\n") + records = [native("session", workspace_id=None, started_at=1.0)] sessions = {"session": { "session_id": "session", "provider": "claude", "identity_status": "verified", }} - ws.merge_first_recorded(sessions, ws.utc_now()) + ws.merge_first_recorded(sessions, records, ws.utc_now()) + assert sessions["session"]["first_recorded_at"] is None + sessions["session"].update(workspace_id="WS", identity_status="unknown") + ws.merge_first_recorded(sessions, [native("session", workspace_id="ws", started_at=1.0)], + ws.utc_now()) assert sessions["session"]["first_recorded_at"] is None +def test_first_recorded_is_the_earliest_matching_start(ws): + sessions = {"session": { + "session_id": "session", "provider": "claude", "identity_status": "verified", + "workspace_id": "WS", + }} + early = ws.parse_iso("2026-09-01T09:00:00Z").timestamp() + records = [native("session", workspace_id="ws", started_at=early + 60), + native("session", workspace_id="WS", started_at=early), + native("session", workspace_id="ws", started_at=early + 30)] + ws.merge_first_recorded(sessions, records, ws.utc_now()) + assert sessions["session"]["first_recorded_at"] == "2026-09-01T09:00:00Z" + + @pytest.mark.parametrize("override", [ {"provider": "droid"}, {"session_id": "different-session"}, @@ -935,9 +938,16 @@ def test_context_with_wrong_identity_is_not_attached(ws, monkeypatch, override): assert "last_user" not in session -def test_missing_ledger_keeps_first_recorded_unknown(ws, monkeypatch): +def test_unavailable_cmux_sessions_keep_first_recorded_unknown(ws, monkeypatch): _seed_identity_world(ws, monkeypatch, ("claude",)) - monkeypatch.setattr(ws, "SESSION_LEDGER", ws.HOME / "missing-ledger.jsonl") + original_run = ws.subprocess.run + + def run(args, **kwargs): + if args[1:3] == ["sessions", "list"]: + return fake_completed("Error: sessions list failed", returncode=1) + return original_run(args, **kwargs) + + monkeypatch.setattr(ws.subprocess, "run", run) ws.build() session = json.loads(ws.OUT_PATH.read_text())["live_sessions"][0] assert session["first_recorded_at"] is None @@ -948,17 +958,17 @@ def test_missing_ledger_keeps_first_recorded_unknown(ws, monkeypatch): {"workspace_id": "different-workspace"}, {"session_id": "different-session"}, {"session_id": "SESSION-1"}, - {"provider": "droid"}, - {"provider": None}, - {"event": "end"}, - {"ts": "not-a-time"}, - {"ts": 999999999999999}, + {"agent": "factory"}, + {"agent": None}, + {"started_at": None}, + {"started_at": "not-a-time"}, + {"started_at": "1970-01-01T00:00:00Z"}, + {"started_at": "2999-01-01T00:00:00Z"}, ]) def test_unmatched_recorded_start_stays_unknown(ws, monkeypatch, override): - _, _, ledger, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) - record = json.loads(ledger.read_text()) - record.update(override) - ledger.write_text("invalid\n[]\n" + json.dumps(record) + "\n") + _, _, cmux_records, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) + cmux_records[0].update(override) + cmux_records[:0] = ["invalid", []] ws.build() session = json.loads(ws.OUT_PATH.read_text())["live_sessions"][0] assert session["first_recorded_at"] is None @@ -1221,14 +1231,13 @@ def _seed_world(ws, monkeypatch, now): monkeypatch.setattr(ws, "ps_tty", lambda pid: "ttys010") monkeypatch.setattr(ws, "read_mem_pct", lambda: 42.0) - # cmux-registry: one live human session. - ws.CMUX_REGISTRY.parent.mkdir(parents=True, exist_ok=True) - ws.CMUX_REGISTRY.write_text(json.dumps({ - "tab-1": {"claude_pid": live, "session_id": "sess-A", - "cwd": "/work/repo", "transcript_path": "/t/a.jsonl", "ts": 100}, - "tab-dead": {"claude_pid": 99999999, "session_id": "sess-dead", - "cwd": "/work/x", "ts": 50}, - })) + # cmux sessions (a real fake-cmux subprocess): one live human session. + monkeypatch.setenv("CMUX_BIN", str(fake_cmux.install(ws.HOME / "cmux-bin", [ + fake_cmux.record("sess-A", pid=live, cwd="/work/repo", transcript_path="/t/a.jsonl", + workspace_id="tab-1", surface_id="surface-a", updated_at_unix=100), + fake_cmux.record("sess-dead", pid=99999999, cwd="/work/x", + workspace_id="tab-dead", surface_id="surface-dead"), + ]))) # orch-registry: a cron worker bound to workspace:9 (no live session there). ws.ORCH_REGISTRY.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_writers_in_process.py b/tests/test_writers_in_process.py index efe43f8..57dd0d3 100644 --- a/tests/test_writers_in_process.py +++ b/tests/test_writers_in_process.py @@ -18,6 +18,8 @@ from tempfile import TemporaryDirectory from unittest import mock +import fake_cmux + REPO = Path(__file__).resolve().parent.parent @@ -385,6 +387,11 @@ def setUp(self): self._tmp_obj = TemporaryDirectory() self._tmp = fixture_home(Path(self._tmp_obj.name)) self.mod = load_module(self._tmp, "transcript-tail.py") + self.cmux_dir = self._tmp / "cmux-bin" + cli = fake_cmux.install(self.cmux_dir) + env = mock.patch.dict(os.environ, {"CMUX_BIN": str(cli)}) + env.start() + self.addCleanup(env.stop) def tearDown(self): self._tmp_obj.cleanup() @@ -402,23 +409,35 @@ def test_find_via_world_handles_corrupt_json(self): Path(self.mod.WORLD).write_text("{ bad") self.assertIsNone(self.mod.find_via_world("workspace:1")) - def test_find_via_registry_returns_most_recent(self): - Path(self.mod.REGISTRY).write_text(json.dumps({ - "tab1": {"cwd": "/x", "transcript_path": "/old.jsonl", - "started_at": "2026-01-01T00:00:00Z"}, - "tab2": {"cwd": "/x", "transcript_path": "/new.jsonl", - "started_at": "2026-05-01T00:00:00Z"}, - "tab3": {"cwd": "/y", "transcript_path": "/other.jsonl"}, - })) - self.assertEqual(self.mod.find_via_registry("/x"), "/new.jsonl") + def test_find_via_cmux_sessions_returns_most_recently_active_in_cwd(self): + # "resumed" started first but ran last, so its transcript is the one to read. + fake_cmux.write(self.cmux_dir, [ + fake_cmux.record("resumed", cwd="/x", transcript_path="/resumed.jsonl", + started_at="2026-01-01T00:00:00Z", updated_at_unix=300), + fake_cmux.record("newer-start", cwd="/x", transcript_path="/newer-start.jsonl", + started_at="2026-05-01T00:00:00Z", updated_at_unix=200), + fake_cmux.record("latest-elsewhere", cwd="/y", transcript_path="/other.jsonl", + updated_at_unix=900), + fake_cmux.record("no-transcript", cwd="/x", updated_at_unix=900), + ]) + self.assertEqual(self.mod.find_via_cmux_sessions("/x"), "/resumed.jsonl") - def test_find_via_registry_returns_none_when_no_match(self): - Path(self.mod.REGISTRY).write_text(json.dumps({})) - self.assertIsNone(self.mod.find_via_registry("/no/match")) + def test_find_via_cmux_sessions_matches_the_launch_directory(self): + fake_cmux.write(self.cmux_dir, [ + fake_cmux.record("moved", cwd="/x/sub", launch_working_directory="/x", + transcript_path="/moved.jsonl"), + ]) + self.assertEqual(self.mod.find_via_cmux_sessions("/x"), "/moved.jsonl") + self.assertEqual(self.mod.find_via_cmux_sessions("/x/sub"), "/moved.jsonl") + self.assertIsNone(self.mod.find_via_cmux_sessions("/x/s")) - def test_find_via_registry_handles_corrupt_json(self): - Path(self.mod.REGISTRY).write_text("{ bad") - self.assertIsNone(self.mod.find_via_registry("/x")) + def test_find_via_cmux_sessions_returns_none_when_no_match(self): + fake_cmux.write(self.cmux_dir, []) + self.assertIsNone(self.mod.find_via_cmux_sessions("/no/match")) + + def test_find_via_cmux_sessions_returns_none_when_cmux_fails(self): + fake_cmux.write(self.cmux_dir, stdout="{ bad", returncode=1) + self.assertIsNone(self.mod.find_via_cmux_sessions("/x")) def test_read_tail_extracts_last_messages(self): path = self._tmp / "t.jsonl" @@ -497,9 +516,9 @@ def test_main_falls_back_to_closed_cwd(self): path = self._tmp / "t.jsonl" path.write_text(json.dumps({ "timestamp": "t1", "message": {"role": "user", "content": "hi"}})) - Path(self.mod.REGISTRY).write_text(json.dumps({ - "tab1": {"cwd": "/x", "transcript_path": str(path)} - })) + fake_cmux.write(self.cmux_dir, [ + fake_cmux.record("closed", cwd="/x", transcript_path=str(path)), + ]) sys.argv = ["transcript-tail.py", "--closed-cwd", "/x"] out = io.StringIO() with mock.patch("sys.stdout", out): From 47f922a2f393c7cf8a299d8208883b5112436f5c Mon Sep 17 00:00:00 2001 From: Mukul Sharma Date: Mon, 19 Oct 2026 10:14:28 -0700 Subject: [PATCH 2/2] Keep a registry fallback, make the cmux forward reliable, keep restore Two reviews blocked the first revision: cmux is not a complete source. Live session ee4ee641 (workspace:27, started outside cmux's wrapper) has no cmux record and no resume binding, because cmux-auto-resume.py forwarded its SessionStart once, fire-and-forget, and the forward failed. - bin/cmux_sessions.py: registry_fallback() returns ~/.claude/cmux-registry.json rows (mukul-memory's hook) only for sessions cmux has no record of; agent_process() rejects a reused pid; the CLI timeout is 15 s. - All readers use cmux first, then the fallback. world-scanner writes _meta.session_record_fallback and logs fallback_hits=N so the fallback can be retired after a soak with zero hits. On live state it verifies the same 10 sessions as base, ee4ee641 included; only the aea1111b pid-reuse ghost drops. - hooks/cmux-auto-resume.py starts a detached worker that checks the exit code, confirms the record with `cmux sessions list --session`, retries with backoff, and ledgers a final failure as cmux-session-forward-fail. - cmux-restore-sessions stays, reading cmux records then the registry, since native restore needs a resume binding. - hooks/cmux-session-ledger.py is a no-op stub for one release; its link stays, so a registration that outlives the pull still exits 0. - patch-settings.py registers cmux-auto-resume.py when missing, removes only the ledger registrations, leaves cmux-registry.py alone, and writes UTF-8. - Tests kill the two surviving len(...) != 1 mutants. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 103 ++++-- README.md | 5 +- bin/build-ws-context.py | 31 +- bin/cmux-restore-sessions.py | 409 ++++++++++++++++++++++ bin/cmux_sessions.py | 70 +++- bin/session-context-watcher.py | 16 +- bin/transcript-tail.py | 9 +- bin/world-scanner.py | 59 +++- hooks/cmux-auto-resume.py | 195 ++++++++--- hooks/cmux-session-ledger.py | 13 + install.sh | 55 ++- install/patch-settings.py | 129 ++++--- tests/mutation_smoke.py | 124 ++++--- tests/test_build_ws_context_in_process.py | 42 +++ tests/test_cmux_auto_resume.py | 322 +++++++++++++---- tests/test_cmux_restore_sessions.py | 217 ++++++++++++ tests/test_cmux_session_ledger.py | 41 +++ tests/test_cmux_sessions.py | 83 +++++ tests/test_install_cmux_sessions.py | 140 ++++---- tests/test_patch_settings.py | 157 +++++---- tests/test_session_context_watcher.py | 44 +++ tests/test_world_scanner.py | 131 ++++++- tests/test_writers_in_process.py | 17 + 23 files changed, 1957 insertions(+), 455 deletions(-) create mode 100755 bin/cmux-restore-sessions.py create mode 100755 hooks/cmux-session-ledger.py create mode 100644 tests/test_cmux_restore_sessions.py create mode 100644 tests/test_cmux_session_ledger.py diff --git a/CHANGELOG.md b/CHANGELOG.md index af7f252..ed70fe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,46 +10,79 @@ The version is carried in `pyproject.toml` and `src/assistant/__init__.py` ## [Unreleased] ### Changed -- Read agent sessions from cmux instead of from two hook-written files. cmux - 0.64.25 records every agent session itself, and `cmux sessions list --json --all` - prints those records without the cmux socket. The new `bin/cmux_sessions.py` - is the one place that runs it, and it returns nothing when cmux is missing or - fails. `world-scanner.py`, `session-context-watcher.py`, `build-ws-context.py`, - and `transcript-tail.py --closed-cwd` now read it instead of - `~/.claude/cmux-registry.json` (950 stale entries on this machine) and - `~/.cmux-session-ledger.jsonl` (29 MB and growing). Only the session cmux - marks active on a surface counts as running there, so a session left behind - by `/clear` in the same process no longer looks live. Output fields keep their - names, and `registry_live_pid` and `session_start_registry` keep their - values. Some values now come from different sources: - - **First recorded** comes from cmux's `started_at`. A session restored into - another workspace shows its original start, not when it arrived there. - - A session's `cwd` is its launch directory, and `ts` is cmux's latest hook - update instead of the last SessionStart. - - `transcript-tail.py --closed-cwd` picks the most recently active session. - - Sessions of agents the fleet doesn't run, such as Copilot, are left out. - - Claude sessions that cmux has no record for can no longer be verified. This - happens when `cmux-auto-resume.py` failed for a session started outside - cmux's wrapper; one live session on this machine is affected. -- Keep `hooks/cmux-auto-resume.py`. cmux's Claude wrapper injects cmux's hooks, - but agents started without it get none. That includes an absolute +- Read agent sessions from cmux first, with mukul-memory's registry as a + fallback. cmux 0.64.25 records agent sessions itself, and + `cmux sessions list --json --all` prints those records without the cmux + socket. The new `bin/cmux_sessions.py` is the one place that runs it. It + waits up to 15 seconds, and when cmux can't answer it returns no records and + writes one stderr line. `world-scanner.py`, `session-context-watcher.py`, + `build-ws-context.py`, `transcript-tail.py --closed-cwd`, and + `cmux-restore-sessions` read it instead of `~/.cmux-session-ledger.jsonl`. + - cmux misses a session whose SessionStart never reached it. On 2026-10-19 + that was ee4ee641 in workspace:27. For such sessions every reader also + takes the row from `~/.claude/cmux-registry.json`, written by mukul-memory's + `cmux-registry.py` hook. + - `world.json` `_meta.session_record_fallback` counts the sessions that came + from that fallback, and the scanner log shows `fallback_hits=N`. Retire the + fallback after a soak period with zero hits. + - A registry row with a live pid must still be a `claude` or `droid` process. + This drops sessions like aea1111b, whose recorded pid now belongs to another + program. + - On live state the scanner verifies the same 10 sessions as before. Only + the aea1111b ghost drops. + - Only the session cmux marks active on a surface counts as running there, so + a session left behind by `/clear` in the same process no longer looks live. + - Output fields keep their names, and `registry_live_pid` and + `session_start_registry` keep their values. Some values now come from + different sources: + - **First recorded** comes from cmux's `started_at`, and from a registry + row's SessionStart time for fallback sessions. A session restored into + another workspace shows its original start, not when it arrived there. + - A session's `cwd` is its launch directory, and `ts` is the record's + latest update. + - `transcript-tail.py --closed-cwd` picks the most recently active session. + - Sessions of agents the fleet doesn't run, such as Copilot, are left out. +- Make `hooks/cmux-auto-resume.py` forward reliably. It's what makes cmux + record agents started without cmux's Claude wrapper, such as an absolute `~/.local/bin/claude` like the warm comms session, and the scheduled fleet - spawns. This hook forwards their SessionStart to cmux, so cmux still records - them. -- Log one stderr line when `cmux sessions list` can't be run or read, instead - of silently reporting no sessions. + spawns. + - The hook now starts a detached worker and returns at once. + - The worker checks each `cmux hooks session-start` exit code. + It then confirms the record with `cmux sessions list --session `. + - It retries after 0, 2, 5, 15, and 30 seconds. + - A session cmux still lacks gets one `cmux-session-forward-fail` entry in + `~/.assistant/actions-ledger.jsonl`. + - Before, one try was made, with a 5-second timeout and its errors ignored. +- `install/patch-settings.py` registers `cmux-auto-resume.py` on SessionStart in + Claude settings and Factory hooks when it's missing. A fresh machine used to + link the hook without registering it. The file is written without escaping + non-ASCII text. +- `cmux-restore-sessions` finds a session for a panel with no resume binding in + cmux's records, then in the registry. The ledger is no longer used. Native + `cmux restore --surface` needs a binding, so this CLI stays. - Measure Python under `install/` and `hooks/` in the new-code coverage gate, so changes there can pass it. ### Removed -- Remove `hooks/cmux-session-ledger.py` and `bin/cmux-restore-sessions.py`. Use - `cmux restore-session`, or `cmux restore --surface` for one pane. - `install.sh --apply` removes the links an older install made to them. - `install/patch-settings.py` now removes the ledger hook's registrations, and - `cmux-registry.py`'s, from `~/.claude/settings.json` and - `~/.factory/hooks.json` instead of adding them. It reports a file it can't - read and still cleans the others. The installer still installs cmux's own - Factory hooks. +- Retire `hooks/cmux-session-ledger.py`. For this release it's a no-op, so a + registration that outlives `git pull` still exits 0. + `install/patch-settings.py` now removes its SessionStart and SessionEnd + registrations from `~/.claude/settings.json` and `~/.factory/hooks.json` + instead of adding them. Delete the stub and its install.sh link next release. + `~/.cmux-session-ledger.jsonl` stays on disk, but nothing writes to it any + more. mukul-memory's `cmux-registry.py` registration is left alone. + +### Rollback +- Revert the merge commit, then run `install.sh --apply`. That restores the + readers on registry and ledger, `cmux-restore-sessions` on the ledger, and + the single fire-and-forget forward. The base `patch-settings.py` also + re-registers the ledger hooks on SessionStart and SessionEnd, and the ledger + file resumes from where it stopped. +- The `cmux-auto-resume.py` registration this release added on a fresh machine + stays, because the base installer never removes hooks. It's harmless: the + base hook makes the same single forward. +- Ledger events between deploy and rollback are lost, so **First recorded** + for sessions started in that window is unknown until they restart. ### Added - Add the `cmux-ws-numberer` LaunchAgent as an opt-in feature. It adds a `[N]` diff --git a/README.md b/README.md index b3cabe4..78d8786 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,9 @@ and next action back to the original session. Copying never sends it automatical Saved context must match the current workspace and session identities. Missing or unsupported session bindings stay unknown instead of borrowing another -session's notes. **First recorded** is the session's start as cmux recorded it; -a session restored into another workspace keeps that original start. Pending task +session's notes. **First recorded** is the session's start as cmux recorded it, +or its SessionStart in `~/.claude/cmux-registry.json` when cmux has no record; a +session restored into another workspace keeps its original start. Pending task dates come from their creation records. The updated `/back-off` skill saves the workspace identity so the overview can confirm **Parked**. If you run the dashboard from a separate checkout, use its displayed confirmation command for older pause diff --git a/bin/build-ws-context.py b/bin/build-ws-context.py index ee7540c..6400d13 100755 --- a/bin/build-ws-context.py +++ b/bin/build-ws-context.py @@ -47,7 +47,8 @@ status bar (`… │ #<8hex>`), then confirms the file's internal `sessionId` matches — exact, self-verifying. Next: the live cmux resume binding for the pane. Last: cmux's own session record for the agent pane's surface - UUID (`cmux sessions list`), but ONLY when its pid is still alive AND it + UUID (`cmux sessions list`), or the cmux-registry.json row for a session + cmux missed, but ONLY when its pid is still alive AND it agrees with the live screen (a record goes stale on surface reuse — the ws:12 trap). `transcript_source` records which gate passed; that last gate keeps its historical name, `registry_live_pid`. @@ -70,6 +71,7 @@ import cmux_sessions # noqa: E402 HOME = Path(os.environ["HOME"]) +CMUX_REGISTRY = HOME / ".claude/cmux-registry.json" PROTECTED_REFS = {"workspace:3", "workspace:108", "workspace:7"} CMUX_BIN = os.environ.get( "CMUX_BIN", "/Applications/cmux.app/Contents/Resources/bin/cmux") @@ -347,22 +349,30 @@ def transcript_from_session_id(sid8: str | None) -> str | None: def recorded_transcript_for_surface(surface_uuid: str, expect_sid8: str | None = None) -> dict | None: - """Transcript for a surface via cmux's own session record — SAFE variant. + """Transcript for a surface via its session record — SAFE variant. cmux records which session is active on each surface UUID, with its pid - and transcript, but that record goes stale when a surface is reused. We - therefore return it ONLY when its pid is still ALIVE (a dead pid means the - record describes a session that no longer occupies the surface — the ws:12 - trap). When `expect_sid8` is given (a live status-bar id), the record must - AGREE with it; on disagreement we trust the screen and return None here. + and transcript; for a session cmux missed, the registry's row for the + surface stands in (the newest, if several). A record goes stale when a + surface is reused, so we return one ONLY when its pid is still ALIVE (a + dead pid means the record describes a session that no longer occupies the + surface — the ws:12 trap). Two cmux records for the surface are ambiguous + and return None. When + `expect_sid8` is given (a live status-bar id), the record must AGREE with + it; on disagreement we trust the screen and return None here. Returns {transcript_path, session_id8, agent} or None. """ if not surface_uuid: return None want = surface_uuid.upper() - records = [r for r in cmux_sessions.list_sessions() - if r.active_for_surface and r.provider and (r.surface_id or "").upper() == want] + cmux = cmux_sessions.list_sessions() + records = [r for r in cmux if r.active_for_surface and r.provider + and (r.surface_id or "").upper() == want] + if not records: + rows = [r for r in cmux_sessions.registry_fallback(CMUX_REGISTRY, cmux) + if (r.surface_id or "").upper() == want] + records = [max(rows, key=lambda r: r.updated_at or 0)] if rows else [] if len(records) != 1: return None record = records[0] @@ -501,7 +511,8 @@ def resolve_workspace_screen_and_transcript(ws_ref: str) -> dict: Strongest: it's what the running agent says about itself, right now. 2. cmux_resume_binding — the live cmux resume binding for the pane. 3. registry_live_pid — cmux's session record for the agent pane's - surface UUID, but only with a LIVE pid and agreement with the screen + surface UUID (or the registry's row, for a session cmux missed), + but only with a LIVE pid and agreement with the screen and pane agent. Covers a booting agent whose status bar hasn't rendered yet. 4. None — no verified signal. The Observer falls back to screen_text. diff --git a/bin/cmux-restore-sessions.py b/bin/cmux-restore-sessions.py new file mode 100755 index 0000000..b9ad824 --- /dev/null +++ b/bin/cmux-restore-sessions.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +""" +cmux-restore-sessions — one-shot session recovery CLI. + +Run after a manual cmux restart (or at boot) to respawn Claude Code or +Factory Droid panels whose sessions did not auto-resume. + +Algorithm: +1. Read cmux state to enumerate panels with a supported agent kind. +2. For each panel that has a resumeBinding but NO live agent PID: + a. If autoResume=true, cmux handles it, so skip. + b. If the resumeBinding has a checkpointId (session_id), respawn. +3. For panels with NO resumeBinding at all, fall back to the session last + recorded for that panel: cmux's own record (`cmux sessions list`), or the + ~/.claude/cmux-registry.json row for a session cmux never recorded. Native + `cmux restore --surface` needs a binding, so this covers what it can't. +4. Respawn by sending the resume command to the panel's surface. + +Usage: + python3 ~/.claude/bin/cmux-restore-sessions.py [--dry-run] [--verbose] +""" +import argparse +import json +import os +import shlex +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import cmux_sessions # noqa: E402 + +REGISTRY = Path.home() / ".claude/cmux-registry.json" +CMUX_STATE = Path.home() / "Library/Application Support/cmux/session-com.cmuxterm.app.json" + + +def run_cmux(*args, timeout=5): + try: + r = subprocess.run( + ["/Applications/cmux.app/Contents/Resources/bin/cmux", *args], + capture_output=True, text=True, timeout=timeout + ) + return r.returncode, r.stdout.strip(), r.stderr.strip() + except Exception as e: + return 1, "", str(e) + + +def load_state(): + with open(CMUX_STATE) as f: + return json.load(f) + + +def load_recorded(): + """Returns {panel_id: entry} for the newest session recorded on each panel.""" + records = cmux_sessions.list_sessions() + records += cmux_sessions.registry_fallback(REGISTRY, records) + newest = {} + for record in records: + if not (record.active_for_surface and record.provider and record.surface_id): + continue + current = newest.get(record.surface_id) + if current is None or (record.updated_at or 0) > (current.updated_at or 0): + newest[record.surface_id] = record + return { + surface_id: { + "session_id": record.session_id, + "cwd": record.launch_cwd or record.cwd, + "provider": "factory" if record.provider == "droid" else "claude", + "launch_argv": list(record.launch_argv), + "transcript_path": record.transcript_path, + "pid": record.pid, + } + for surface_id, record in newest.items() + } + + +def is_agent_alive(pid: int) -> bool: + if not pid: + return False + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +is_claude_alive = is_agent_alive + + +def pgrep_session(session_id: str) -> bool: + """Return True if any process has this agent session id in its argv.""" + if not session_id: + return False + try: + r = subprocess.run( + ["pgrep", "-f", session_id], + capture_output=True, text=True, timeout=2, + ) + return r.returncode == 0 and r.stdout.strip() != "" + except Exception: + return False + + +def normalize_provider(value): + value = str(value or "").strip().lower() + if value in {"factory", "droid"}: + return "factory" + if value == "claude": + return "claude" + return "" + + +def infer_ledger_provider(entry): + if not entry: + return "" + for key in ("provider", "agent", "kind"): + provider = normalize_provider(entry.get(key)) + if provider: + return provider + transcript = str(entry.get("transcript_path", "")).replace("\\", "/") + if "/.factory/sessions" in transcript: + return "factory" + argv = entry.get("launch_argv") or [] + if argv and os.path.basename(str(argv[0])).lower() == "droid": + return "factory" + return "" + + +def panel_provider(panel, ledger_entry=None): + agent = panel.get("terminal", {}).get("agent", {}) + return ( + normalize_provider(agent.get("kind")) + or infer_ledger_provider(ledger_entry) + or "" + ) + + +def get_all_panels(state, ledger=None): + """Yield panels belonging to Claude/Droid, including ledger-only matches.""" + ledger = ledger or {} + windows = state.get("windows", []) + for window in windows: + tab_mgr = window.get("tabManager", {}) + for ws in tab_mgr.get("workspaces", []): + for panel in ws.get("panels", []): + terminal = panel.get("terminal", {}) + agent = terminal.get("agent", {}) + panel_id = panel.get("id", "") + ledger_entry = ( + ledger.get(panel_id) or ledger.get(terminal.get("surfaceId", "")) + ) + kind = normalize_provider(agent.get("kind")) + rb = terminal.get("resumeBinding") or {} + has_session = ( + agent.get("sessionId") + or rb.get("checkpointId") + or (ledger_entry or {}).get("session_id") + ) + if has_session and (kind or ledger_entry): + yield ws, panel + + +def _strip_session_args(args): + filtered = [] + skip_next = False + value_flags = {"--resume", "-r", "--session-id", "--fork"} + switch_flags = {"--continue", "-c"} + for arg in args: + if skip_next: + skip_next = False + continue + if arg in value_flags: + skip_next = True + continue + if arg in switch_flags: + continue + if any( + arg.startswith(prefix) + for prefix in ("--resume=", "--session-id=", "--fork=") + ): + continue + filtered.append(arg) + return filtered + + +def _replace_option(args, option, value): + result = [] + skip_next = False + for arg in args: + if skip_next: + skip_next = False + continue + if arg == option: + skip_next = True + continue + if arg.startswith(option + "="): + continue + result.append(arg) + result.extend([option, value]) + return result + + +def _strip_option(args, option): + result = [] + skip_next = False + for arg in args: + if skip_next: + skip_next = False + continue + if arg == option: + skip_next = True + continue + if arg.startswith(option + "="): + continue + result.append(arg) + return result + + +def _launch_argv(agent, ledger_entry): + ledger_argv = (ledger_entry or {}).get("launch_argv") or [] + if ledger_argv: + return [str(arg) for arg in ledger_argv] + launch = agent.get("launchCommand") or {} + return [str(arg) for arg in launch.get("arguments", [])] + + +def build_resume_cmd(panel, ledger_entry=None): + """Build a provider-correct resume command for a panel.""" + terminal = panel.get("terminal", {}) + agent = terminal.get("agent", {}) + rb = terminal.get("resumeBinding", {}) or {} + provider = panel_provider(panel, ledger_entry) + if not provider: + return None, None + + # Prefer ledger session_id (most recent) over state checkpoint + if ledger_entry and not ledger_entry.get("ended"): + session_id = ( + ledger_entry.get("session_id") + or rb.get("checkpointId") + or agent.get("sessionId", "") + ) + cwd = ( + ledger_entry.get("cwd") + or rb.get("cwd") + or agent.get("workingDirectory") + or panel.get("directory", os.getcwd()) + ) + else: + session_id = rb.get("checkpointId") or agent.get("sessionId", "") + cwd = ( + rb.get("cwd") + or agent.get("workingDirectory") + or panel.get("directory", os.getcwd()) + ) + + # A previous resume binding is the most exact command. Droid bindings from + # before the GLM migration are rebuilt so the required runtime settings are + # always present. + command = rb.get("command") + droid_configured = ( + "droid-glm-settings.json" in (command or "") + and "--append-system-prompt-file" in (command or "") + and "--auto high" in (command or "") + ) + if ( + command + and session_id + and session_id in command + and (provider == "claude" or droid_configured) + ): + return cwd, command + + if not session_id: + return None, None + + args = _launch_argv(agent, ledger_entry) + default_executable = "droid" if provider == "factory" else "claude" + executable = args[0] if args else default_executable + preserved = _strip_session_args(args[1:]) + + if provider == "factory": + settings = str(Path.home() / ".assistant" / "droid-glm-settings.json") + lessons = str(Path.home() / ".claude" / "CLAUDE.md") + preserved = _replace_option(preserved, "--settings", settings) + preserved = _replace_option(preserved, "--auto", "high") + preserved = _strip_option( + preserved, "--append-system-prompt-file") + if Path(lessons).is_file(): + preserved.extend(["--append-system-prompt-file", lessons]) + + argv = [executable, "--resume", session_id, *preserved] + cmd = f"cd {shlex.quote(str(cwd))} && {shlex.join(argv)}" + + return cwd, cmd + + +def respawn_panel(ws, panel, cmd: str, dry_run: bool, verbose: bool): + panel_id = panel.get("id", "") + surface_id = panel.get("terminal", {}).get("surfaceId", panel_id) + ws_title = ws.get("customTitle", ws.get("title", "")) + + if verbose: + print(f" Respawning: ws={ws_title!r} panel={panel_id[:8]}") + print(f" Command: {cmd[:100]}...") + + if dry_run: + print(f" [DRY-RUN] Would send: {cmd}") + return True + + # Try to respawn by sending the command to the surface + rpc_params = json.dumps({"surface_id": surface_id, "text": cmd}) + rc, out, err = run_cmux("rpc", "surface.send_text", rpc_params, timeout=5) + if rc != 0: + # Fall back to surface ref + rc, out, err = run_cmux("rpc", "surface.send_text", + json.dumps({"surface_id": panel_id, "text": cmd}), timeout=5) + if rc == 0: + # Send Enter + run_cmux("rpc", "surface.send_key", + json.dumps({"surface_id": surface_id or panel_id, "key": "enter"}), timeout=5) + return True + + if verbose: + print(f" ERROR: send_text failed: {err}") + return False + + +def main(): + parser = argparse.ArgumentParser(description="Restore dead cmux agent sessions") + parser.add_argument( + "--dry-run", action="store_true", help="Print what would be done, don't do it" + ) + parser.add_argument( + "--verbose", "-v", action="store_true", help="Verbose output" + ) + args = parser.parse_args() + + try: + state = load_state() + except Exception as e: + print(f"Error reading cmux state: {e}", file=sys.stderr) + sys.exit(1) + + ledger = load_recorded() + if args.verbose: + print(f"Recorded sessions: {len(ledger)}") + + restored = 0 + skipped = 0 + dead = 0 + + for ws, panel in get_all_panels(state, ledger): + terminal = panel.get("terminal", {}) + agent = terminal.get("agent", {}) + rb = terminal.get("resumeBinding") or {} + panel_id = panel.get("id", "") + ws_title = ws.get("customTitle", "") + + ledger_entry = ledger.get(panel_id) or ledger.get( + terminal.get("surfaceId", "") + ) + + # Check if the agent is alive. cmux state records agent.pid=0 in + # practice, so primary liveness check is pgrep on the session_id. + agent_pid = ( + agent.get("pid", 0) + or (ledger_entry or {}).get("agent_pid", 0) + or (ledger_entry or {}).get("pid", 0) + ) + sid = ( + ((ledger_entry or {}).get("session_id") + if not (ledger_entry or {}).get("ended") else "") + or rb.get("checkpointId") + or agent.get("sessionId", "") + ) + if is_agent_alive(agent_pid) or pgrep_session(sid): + if args.verbose: + print(f"ALIVE: {ws_title!r} (pid={agent_pid} sid={sid[:8]})") + skipped += 1 + continue + + # If autoResume=true, cmux handles it automatically + if rb.get("autoResume"): + if args.verbose: + print(f"AUTO-RESUME: {ws_title!r}, cmux handles this") + skipped += 1 + continue + + dead += 1 + if args.verbose: + print(f"DEAD: {ws_title!r} panel={panel_id[:8]}") + + cwd, cmd = build_resume_cmd(panel, ledger_entry) + if not cmd: + print(f" SKIP: no session_id for {ws_title!r}") + continue + + if respawn_panel(ws, panel, cmd, args.dry_run, args.verbose): + restored += 1 + print(f"RESTORED: {ws_title!r}") + else: + print(f"FAILED: {ws_title!r}") + + print(f"\nSummary: {restored} restored, {skipped} skipped, {dead} dead") + + +if __name__ == "__main__": + main() diff --git a/bin/cmux_sessions.py b/bin/cmux_sessions.py index 4e432ba..f23486d 100644 --- a/bin/cmux_sessions.py +++ b/bin/cmux_sessions.py @@ -1,7 +1,8 @@ """cmux_sessions — the agent sessions cmux records for its own workspaces. cmux's agent hooks (injected by its Claude wrapper; installed for Factory Droid -by ``cmux hooks factory install``) keep one record per agent session under +by ``cmux hooks factory install``; forwarded by hooks/cmux-auto-resume.py for +agents started without the wrapper) keep one record per agent session under ``~/.cmuxterm/-hook-sessions.json``. ``cmux sessions list --json --all`` prints every saved record without needing the cmux socket. This module is the one place that runs it, so readers get typed records instead of raw JSON. @@ -12,6 +13,12 @@ bundle's copy. Any failure to run or parse it yields ``[]`` and one line on stderr (captured in the calling daemon's launchd .err log): a missing or broken cmux must never take down a caller, but it shouldn't look like "no sessions". + +cmux misses a session whose SessionStart never reached it, so readers also +take ``registry_fallback``: rows of ``~/.claude/cmux-registry.json`` (written by +mukul-memory's cmux-registry.py SessionStart hook) for sessions cmux has no +record of. world-scanner counts how often that fallback is used; retire it +once that count stays at zero. """ from __future__ import annotations @@ -23,14 +30,19 @@ import sys from dataclasses import dataclass from datetime import datetime, timezone +from pathlib import Path from typing import Optional APP_CLI = "/Applications/cmux.app/Contents/Resources/bin/cmux" -TIMEOUT_SEC = 5 +# A busy machine has taken 4.8 s to answer; a scan that times out sees no records. +TIMEOUT_SEC = 15 +CMUX = "cmux" +REGISTRY = "registry" # cmux's agent names for the two agents the fleet drives, mapped to the # fleet's own provider names (agent_session.CLAUDE / agent_session.DROID). _PROVIDERS = {"claude": "claude", "factory": "droid", "droid": "droid"} +_AGENT_PROCESSES = {"claude", "droid"} @dataclass(frozen=True) @@ -46,6 +58,8 @@ class Session: started_at: Optional[float] updated_at: Optional[float] active_for_surface: bool + launch_argv: tuple = () + source: str = CMUX @property def provider(self) -> Optional[str]: @@ -85,6 +99,56 @@ def list_sessions() -> list[Session]: return [session for session in map(_parse, rows) if session is not None] +def registry_fallback(path: Path, records) -> list[Session]: + """Registry rows for Claude sessions that `records` (cmux's) don't include. + + The registry keeps the latest SessionStart per cmux workspace: its key is + the workspace UUID and its ``ts`` the hook time, which stands in for both + the start and the latest update. A missing or unreadable registry yields []. + """ + try: + registry = json.loads(Path(path).read_text()) + except (OSError, ValueError): + return [] + if not isinstance(registry, dict): + return [] + known = {record.session_id for record in records} + out = [] + for workspace_id, row in registry.items(): + if not isinstance(row, dict): + continue + session_id = _text(row.get("session_id")) + if not session_id or session_id in known: + continue + pid = str(row.get("claude_pid") or "") + ts = row.get("ts") + recorded = float(ts) if type(ts) in (int, float) else None + cwd = _text(row.get("cwd")) + out.append(Session( + session_id=session_id, agent="claude", + workspace_id=_text(row.get("workspace_id")) or workspace_id, + surface_id=_text(row.get("surface_id")) or _text(row.get("panel_id")), + cwd=cwd, launch_cwd=cwd, + pid=int(pid) if pid.isdigit() and int(pid) > 0 else None, + transcript_path=_text(row.get("transcript_path")), + started_at=recorded, updated_at=recorded, + active_for_surface=True, source=REGISTRY, + )) + return out + + +def agent_process(pid) -> bool: + """True when `pid` is a running claude or droid process, not a reused pid.""" + if not pid: + return False + try: + result = subprocess.run(["ps", "-p", str(pid), "-o", "comm="], + capture_output=True, text=True, timeout=3) + except (OSError, subprocess.SubprocessError): + return False + return os.path.basename(result.stdout.strip()) in _AGENT_PROCESSES + + def _unavailable(cli, reason) -> list[Session]: print(f"cmux_sessions: `{cli} sessions list` unavailable ({reason})", file=sys.stderr) return [] @@ -95,6 +159,7 @@ def _parse(row) -> Optional[Session]: return None pid = row.get("pid") updated = row.get("updated_at_unix") + argv = row.get("launch_arguments") return Session( session_id=row["session_id"], agent=str(row.get("agent") or "").lower(), @@ -107,6 +172,7 @@ def _parse(row) -> Optional[Session]: started_at=_epoch(row.get("started_at")), updated_at=float(updated) if type(updated) in (int, float) else None, active_for_surface=row.get("active_for_surface") is True, + launch_argv=tuple(str(arg) for arg in argv) if isinstance(argv, list) else (), ) diff --git a/bin/session-context-watcher.py b/bin/session-context-watcher.py index b062313..1083160 100755 --- a/bin/session-context-watcher.py +++ b/bin/session-context-watcher.py @@ -48,6 +48,7 @@ import cmux_sessions HOME = Path(os.environ["HOME"]) +CMUX_REGISTRY = HOME / ".claude/cmux-registry.json" WORLD_PATH = HOME / ".claude/cache/world.json" ORCHESTRATOR_REGISTRY = HOME / ".architect/orchestrator-registry.json" OUT_PATH = HOME / ".claude/cache/session-context.json" @@ -117,7 +118,8 @@ def pid_alive(pid): def load_live_agent_sessions(): - """Prefer verified world bindings; use cmux's session records without identity data.""" + """Prefer verified world bindings; without identity data, use cmux's session + records plus registry fallback rows for sessions cmux lacks.""" try: world = json.loads(WORLD_PATH.read_text()) except (OSError, ValueError) as exc: @@ -134,13 +136,19 @@ def load_live_agent_sessions(): for workspace in world.get("workspaces", []) or [] if isinstance(workspace, dict) for surface in workspace.get("surfaces", []) or [] ) - # Each surface's active record is what runs there now; earlier records on - # the same surface (a /clear, a finished session) may share its live pid. - recorded = [] if identity_schema else cmux_sessions.list_sessions() + recorded = [] + if not identity_schema: + recorded = cmux_sessions.list_sessions() + recorded += cmux_sessions.registry_fallback(CMUX_REGISTRY, recorded) out = {} for record in recorded: + # Each surface's active record is what runs there now; earlier records + # on the same surface (a /clear, a finished session) may share its live + # pid. A registry row's pid must still be an agent (pids get reused). if not (record.active_for_surface and record.provider and pid_alive(record.pid)): continue + if record.source == cmux_sessions.REGISTRY and not cmux_sessions.agent_process(record.pid): + continue out[record.session_id] = { "pid": record.pid, "cwd": record.launch_cwd or record.cwd, diff --git a/bin/transcript-tail.py b/bin/transcript-tail.py index 23f34f4..c8b5f8b 100755 --- a/bin/transcript-tail.py +++ b/bin/transcript-tail.py @@ -26,6 +26,7 @@ import cmux_sessions # noqa: E402 WORLD = os.path.expanduser("~/.claude/cache/world.json") +REGISTRY = os.path.expanduser("~/.claude/cmux-registry.json") def find_via_world(ws_ref): @@ -40,9 +41,11 @@ def find_via_world(ws_ref): def find_via_cmux_sessions(cwd): - """The most recently active session cmux recorded in `cwd` (current or launch directory).""" - matches = [s for s in cmux_sessions.list_sessions() - if s.transcript_path and cwd in (s.cwd, s.launch_cwd)] + """The most recently active session recorded in `cwd` (current or launch + directory): cmux's records, plus registry rows for sessions cmux lacks.""" + recorded = cmux_sessions.list_sessions() + recorded += cmux_sessions.registry_fallback(REGISTRY, recorded) + matches = [s for s in recorded if s.transcript_path and cwd in (s.cwd, s.launch_cwd)] if not matches: return None return max(matches, key=lambda s: s.updated_at or 0).transcript_path diff --git a/bin/world-scanner.py b/bin/world-scanner.py index c5895a6..ba7460c 100755 --- a/bin/world-scanner.py +++ b/bin/world-scanner.py @@ -11,6 +11,8 @@ - ps (which Claude PIDs are alive) - `cmux sessions list --json --all` (cmux's own agent session records: session_id ↔ workspace/surface UUID ↔ cwd ↔ transcript_path ↔ pid ↔ start) + - ~/.claude/cmux-registry.json (fallback only, for sessions cmux has no record + of; _meta.session_record_fallback counts the sessions it supplied) - ~/.architect/orchestrator-registry.json (workers list — for is_cron tag) - ~/.claude/cache/session-context.json (transcript turns; maintained by watcher) - ~/.claude/cache/dashboard-state.json (workspace classifications + screen hashes) @@ -27,12 +29,14 @@ Workspace and surface UUIDs identify current cmux objects. A session's identity_status is verified when a live resume binding and process agree, or -cmux's active session record for the surface matches both UUIDs and a foreground -provider process that started before the record's latest hook update. +a session record for the surface (cmux's active one, else a registry fallback +row) matches both UUIDs and a foreground provider process that started before +the record's latest update. context_status verifies the cached session/provider association, not freshness; context_built_at supplies the cache clock. Missing bindings and unsupported providers stay unknown. first_recorded_at is cmux's recorded start for the -session (started_at), which a session restored into another workspace keeps. +session (started_at), which a session restored into another workspace keeps; +for a registry fallback row it is that row's SessionStart time. Internal session keys include provider, full session ID, workspace UUID, and surface UUID, so resumed sessions retain each binding in the exported list. """ @@ -72,6 +76,7 @@ HOME = Path(os.environ["HOME"]) OUT_PATH = HOME / ".claude/cache/world.json" +CMUX_REGISTRY = HOME / ".claude/cmux-registry.json" ORCH_REGISTRY = HOME / ".architect/orchestrator-registry.json" SESSION_CTX = HOME / ".claude/cache/session-context.json" DASHBOARD_STATE = HOME / ".claude/cache/dashboard-state.json" @@ -322,11 +327,22 @@ def agent_pid_on_tty(tty, provider): def recorded_binding_for_surface(records, workspace, surface): - """Verify cmux's active record for this surface: both UUIDs, pid, and process age. + """Verify a session record for this surface: both UUIDs, pid, and process age. - A resumed session keeps its original started_at, so the age check uses the - record's latest hook update, which the current process must have written. + cmux's own record goes first; a registry fallback row only when cmux's + doesn't verify. A resumed session keeps its original started_at, so the + age check uses the record's latest update (cmux's latest hook, or the + registry's SessionStart), which the current process must have written. """ + for source in (cmux_sessions.CMUX, cmux_sessions.REGISTRY): + binding = _verified_record_binding( + [record for record in records if record.source == source], workspace, surface) + if binding: + return binding + return None + + +def _verified_record_binding(records, workspace, surface): workspace_id = workspace.get("workspace_id") surface_id = surface.get("surface_id") tty = surface.get("tty") @@ -371,17 +387,21 @@ def session_identity_key(session): def build_live_sessions(workspaces=None, records=()): - """Build the canonical live-session list from cmux's session records (one - per agent session cmux has seen) with a pid-alive filter, then bind each - terminal surface. Each entry carries session_id, pid, cwd, tty, - transcript_path, and the workspace_ref it lives in (best-effort via tty - join with cmux tree).""" + """Build the canonical live-session list from session records (cmux's, + plus registry fallback rows for sessions cmux lacks) with a pid-alive + filter, then bind each terminal surface. Each entry carries session_id, + pid, cwd, tty, transcript_path, and the workspace_ref it lives in + (best-effort via tty join with cmux tree).""" out = {} for record in records: # Only each surface's active record: earlier sessions on the surface - # (a /clear, a finished run) can share its still-live pid. + # (a /clear, a finished run) can share its still-live pid. A registry + # row's pid must still be an agent: the registry outlives its sessions + # and macOS reuses pids. if not (record.active_for_surface and record.provider and pid_alive(record.pid)): continue + if record.source == cmux_sessions.REGISTRY and not cmux_sessions.agent_process(record.pid): + continue session = { "session_id": record.session_id, "pid": record.pid, @@ -755,12 +775,20 @@ def build(): tree = cmux_tree() workspaces = build_workspace_index(tree) records = cmux_sessions.list_sessions() + records += cmux_sessions.registry_fallback(CMUX_REGISTRY, records) live_sessions = build_live_sessions(workspaces, records) join_workspaces_to_sessions(workspaces, live_sessions) tag_cron_workers(live_sessions) merge_session_context(live_sessions) merge_first_recorded(live_sessions, records, now) + fallback_ids = {r.session_id for r in records if r.source == cmux_sessions.REGISTRY} + fallback_hits = sorted( + s["session_id"] for s in live_sessions.values() + if s["session_id"] in fallback_ids + and s["identity_source"] in ("registry", "session_start_registry") + ) + # Compute per-session activity age and bucket. for sess in live_sessions.values(): age_sec, last_ts = compute_session_age(sess, now) @@ -803,6 +831,10 @@ def build(): "built_at": iso(now), "scanner_version": 1, "memory_pct": read_mem_pct(), + "session_record_fallback": { + "source": str(CMUX_REGISTRY), "hits": len(fallback_hits), + "session_ids": fallback_hits, + }, }, "counts": { "workspaces": len(workspaces), @@ -834,7 +866,8 @@ def build(): f"(human={human} cron={cron} active30m={truly_active}) " f"proposals_open={payload['counts']['proposals_open']} " f"awaiting={len(awaiting)} ledger24h={len(ledger_recent)} " - f"todo_open={payload['counts']['todo_open']} mem={payload['_meta']['memory_pct']}%" + f"todo_open={payload['counts']['todo_open']} mem={payload['_meta']['memory_pct']}% " + f"fallback_hits={len(fallback_hits)}" ) diff --git a/hooks/cmux-auto-resume.py b/hooks/cmux-auto-resume.py index a55555e..a6ed50c 100755 --- a/hooks/cmux-auto-resume.py +++ b/hooks/cmux-auto-resume.py @@ -6,14 +6,29 @@ PATH) gets none. This hook forwards SessionStart to cmux's own `cmux hooks session-start` so cmux still records that session (what `cmux sessions list` and `surface resume show` read), and sets a resume binding -when the wrapper didn't. Supports both Claude Code and Factory Droid. The hook -is deliberately silent on every failure: it must never break the agent session. +when the wrapper didn't. Supports both Claude Code and Factory Droid. + +The hook itself only starts a detached worker and returns, so SessionStart +never waits on cmux. The worker checks each forward's exit code, confirms the +record with `cmux sessions list --session `, and retries with backoff +(RETRY_DELAYS_SEC). A session cmux still lacks after the last try gets one +`cmux-session-forward-fail` entry in ~/.assistant/actions-ledger.jsonl, which +the Assistant's brief and Slack feed surface. Nothing here may break the agent +session: every failure is caught. """ import json import os import shlex import subprocess import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +# Seconds to wait before each forward attempt; about a minute in total. +RETRY_DELAYS_SEC = (0, 2, 5, 15, 30) +# cmux has taken 4.8 s to answer `sessions list` on a busy machine. +CALL_TIMEOUT_SEC = 15 def normalize_provider(value): @@ -75,9 +90,122 @@ def fallback_resume(provider, executable, sid, cwd): return f"cd {quoted_cwd} && {quoted_executable} --resume {quoted_sid}" -def main(): - surface_id = os.environ.get("CMUX_SURFACE_ID", "") - if not surface_id: +def cmux_bin(): + return ( + os.environ.get("CMUX_AGENT_HOOK_CMUX_BIN") + or os.environ.get("CMUX_FACTORY_HOOK_CMUX_BIN") + or os.environ.get("CMUX_CLAUDE_HOOK_CMUX_BIN") + or os.environ.get("CMUX_BUNDLED_CLI_PATH", "cmux") + ) + + +def forward_once(cmux, provider, payload, run=subprocess.run): + """Forward SessionStart once; return None once cmux has the record, else why not.""" + sid = payload["session_id"] + try: + started = run([cmux, "hooks", provider, "session-start"], input=json.dumps(payload), + capture_output=True, text=True, timeout=CALL_TIMEOUT_SEC) + except subprocess.TimeoutExpired: + return f"session-start timed out after {CALL_TIMEOUT_SEC}s" + except (OSError, subprocess.SubprocessError) as exc: + return f"session-start failed: {exc}" + if started.returncode != 0: + return f"session-start exit {started.returncode}: {started.stderr.strip()[:200]}" + try: + listed = run([cmux, "sessions", "list", "--session", sid, "--all", "--json"], + capture_output=True, text=True, timeout=CALL_TIMEOUT_SEC) + except subprocess.TimeoutExpired: + return f"sessions list timed out after {CALL_TIMEOUT_SEC}s" + except (OSError, subprocess.SubprocessError) as exc: + return f"sessions list failed: {exc}" + try: + sessions = json.loads(listed.stdout).get("sessions") if listed.returncode == 0 else None + except (ValueError, AttributeError): + sessions = None + if not isinstance(sessions, list): + return f"sessions list exit {listed.returncode}: unreadable output" + if not any(isinstance(row, dict) and row.get("session_id") == sid for row in sessions): + return "cmux has no record of the session after session-start" + return None + + +def forward(cmux, provider, payload, run=subprocess.run, sleep=time.sleep): + """Retry the forward with backoff; return None on success, else the last reason.""" + reason = None + for delay in RETRY_DELAYS_SEC: + sleep(delay) + reason = forward_once(cmux, provider, payload, run) + if reason is None: + return None + return reason + + +def record_failure(provider, payload, reason, now=None): + now = time.time() if now is None else now + sid = payload["session_id"] + entry = { + "ts": datetime.fromtimestamp(now, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "epoch": int(now), + "key": f"cmux-session-forward-fail:{sid}", + "kind": "cmux-session-forward-fail", + "ws_ref": "(hook)", + "outcome": "failed", + "evidence": ( + f"cmux never recorded {provider} session {sid} " + f"(workspace {os.environ.get('CMUX_WORKSPACE_ID', '?')}, " + f"surface {os.environ.get('CMUX_SURFACE_ID', '?')}) after " + f"{len(RETRY_DELAYS_SEC)} tries: {reason}" + )[:300], + } + ledger = Path.home() / ".assistant" / "actions-ledger.jsonl" + ledger.parent.mkdir(parents=True, exist_ok=True) + with open(ledger, "a") as stream: + stream.write(json.dumps(entry) + "\n") + + +def set_fallback_binding(cmux, provider, sid, cwd, run=subprocess.run): + # If the wrapper didn't set a binding, set one ourselves. We can't set + # autoResume=true via the CLI, but we set the correct resume command so + # `cmux restore --surface` can respawn this panel. + if os.environ.get("CMUX_AGENT_LAUNCH_ARGV_B64"): + return + default_executable = "droid" if provider == "factory" else "claude" + executable = os.environ.get("CMUX_AGENT_LAUNCH_EXECUTABLE", default_executable) + resume_cmd = fallback_resume(provider, executable, sid, cwd) + name = "Factory Droid" if provider == "factory" else "Claude Code" + try: + run( + [ + cmux, "surface", "resume", "set", + "--kind", provider, + "--checkpoint-id", sid, + "--cwd", cwd, + "--name", name, + "--source", "agent-hook", + "--shell", resume_cmd, + ], + capture_output=True, text=True, timeout=CALL_TIMEOUT_SEC, + ) + except (OSError, subprocess.SubprocessError): + pass + + +def worker(job, run=subprocess.run, sleep=time.sleep): + """The detached half: forward with retries, ledger a final failure, bind.""" + cmux, provider, payload = cmux_bin(), job["provider"], job["payload"] + # When CMUX_AGENT_LAUNCH_ARGV_B64 is set, session-start registers autoResume=true. + reason = forward(cmux, provider, payload, run, sleep) + set_fallback_binding(cmux, provider, payload["session_id"], payload["cwd"], run) + if reason is not None: + record_failure(provider, payload, reason) + + +def main(argv=None): + argv = sys.argv[1:] if argv is None else argv + if argv[:1] == ["--worker"]: + worker(json.loads(argv[1])) + return + if not os.environ.get("CMUX_SURFACE_ID", ""): return # Not inside cmux try: @@ -94,57 +222,16 @@ def main(): provider = infer_provider(payload) if not provider: return - cmux_bin = ( - os.environ.get("CMUX_AGENT_HOOK_CMUX_BIN") - or os.environ.get("CMUX_FACTORY_HOOK_CMUX_BIN") - or os.environ.get("CMUX_CLAUDE_HOOK_CMUX_BIN") - or os.environ.get("CMUX_BUNDLED_CLI_PATH", "cmux") - ) - - # Fast path: delegate to cmux's own session-start hook. - # When CMUX_AGENT_LAUNCH_ARGV_B64 is set, this registers autoResume=true. hook_payload = dict(payload) - hook_payload.update( - {"session_id": sid, "cwd": cwd, "transcript_path": transcript} + hook_payload.update({"session_id": sid, "cwd": cwd, "transcript_path": transcript}) + job = json.dumps({"provider": provider, "payload": hook_payload}) + # Detached (own session, no inherited pipes): Claude neither waits for it + # nor kills it at the hook timeout. + subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "--worker", job], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, ) - try: - subprocess.run( - [cmux_bin, "hooks", provider, "session-start"], - input=json.dumps(hook_payload), - capture_output=True, - text=True, - timeout=5, - ) - except Exception: - pass - - # Fallback: if wrapper didn't set a binding, set one ourselves. - # We can't set autoResume=true via the CLI, but we set the correct resume - # command so `cmux restore --surface` can respawn this panel. - if not os.environ.get("CMUX_AGENT_LAUNCH_ARGV_B64"): - default_executable = "droid" if provider == "factory" else "claude" - executable = os.environ.get( - "CMUX_AGENT_LAUNCH_EXECUTABLE", default_executable - ) - resume_cmd = fallback_resume(provider, executable, sid, cwd) - name = "Factory Droid" if provider == "factory" else "Claude Code" - try: - subprocess.run( - [ - cmux_bin, "surface", "resume", "set", - "--kind", provider, - "--checkpoint-id", sid, - "--cwd", cwd, - "--name", name, - "--source", "agent-hook", - "--shell", resume_cmd, - ], - capture_output=True, - text=True, - timeout=5, - ) - except Exception: - pass if __name__ == "__main__": diff --git a/hooks/cmux-session-ledger.py b/hooks/cmux-session-ledger.py new file mode 100755 index 0000000..a7c211f --- /dev/null +++ b/hooks/cmux-session-ledger.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Retired: cmux records agent sessions itself (`cmux sessions list`). + +This used to append SessionStart/SessionEnd events to ~/.cmux-session-ledger.jsonl. +It stays one release as a no-op so a registration that outlives the pull still +exits cleanly; `install.sh --apply` (install/patch-settings.py) removes those +registrations. Delete this file and its install.sh link in the next release. +""" +import sys + +if __name__ == "__main__": + if not sys.stdin.isatty(): + sys.stdin.read() diff --git a/install.sh b/install.sh index abe51cb..e329625 100755 --- a/install.sh +++ b/install.sh @@ -97,9 +97,10 @@ After --apply: (--with-memory), workspace-watcher (--with-crash-resume), cmux-ws-numberer (--with-ws-numberer); comms + slack-reactor copied but hand-loaded after token setup - - cmux records agent sessions itself: hooks/cmux-auto-resume.py → ~/.claude/hooks/ - for agents started outside cmux's wrapper, Factory hooks via cmux, and the - retired hand-written session hooks removed from Claude and Factory settings + - cmux session hooks: hooks/ → ~/.claude/hooks/ (symlinks), + bin/cmux-restore-sessions.py → ~/.local/bin/cmux-restore-sessions, + and Claude + Factory SessionStart patched to run cmux-auto-resume.py + (the retired ledger hook's registrations removed) - launchd: kickstart -k each agent (load if not loaded) Skills are symlinked (not copied), so the repo is the single source of truth: @@ -109,7 +110,7 @@ real directory at the target is backed up to ~/.claude/skills-backups/ before the symlink replaces it; recover edits from there with --pull-skills. What is NOT touched: - - ~/.claude/cache/, ~/.claude/projects/ + - ~/.claude/cache/, ~/.claude/projects/, ~/.claude/cmux-registry.json - ~/.claude/assistant-todo.json, ~/.claude/assistant-dashboard.html - ~/.claude/skills/cmux/ and any other unrelated skills - ~/.architect/ @@ -775,29 +776,25 @@ else fi log "" -# --- 4. cmux session hooks ------------------------------------------------- -# cmux records agent sessions itself (`cmux sessions list --json`, read by -# bin/cmux_sessions.py) and restores them (`cmux restore-session`). Its Claude -# wrapper injects its own hooks; Factory Droid gets them from -# `cmux hooks factory install`. hooks/cmux-auto-resume.py forwards SessionStart -# to cmux for agents started WITHOUT the wrapper (absolute-path or PATH-order -# launches), so it stays, symlinked into ~/.claude/hooks/ (a MIXED dir — link -# files individually, never the directory). The ledger hook and restore CLI are -# retired: drop the links an older install made to them (only the exact links -# into this repo), and install/patch-settings.py removes their registrations. -log "[4/5] cmux session hooks (native) + retired hook cleanup" -ensure_symlink "$HOME_DIR/.claude/hooks/cmux-auto-resume.py" "$REPO_ROOT/hooks/cmux-auto-resume.py" -remove_retired_link() { - local link="$1" target="$2" - if [[ -L "$link" && "$(readlink "$link")" == "$target" ]]; then - note "REMOVE retired link $link (cmux records sessions itself)" - if [[ $APPLY -eq 1 ]]; then - rm "$link" - fi - fi -} -remove_retired_link "$HOME_DIR/.claude/hooks/cmux-session-ledger.py" "$REPO_ROOT/hooks/cmux-session-ledger.py" -remove_retired_link "$HOME_DIR/.local/bin/cmux-restore-sessions" "$REPO_ROOT/bin/cmux-restore-sessions.py" +# --- 4. cmux session hooks + restore CLI ----------------------------------- +# cmux records agent sessions itself (`cmux sessions list`, read through +# bin/cmux_sessions.py). Its Claude wrapper injects its hooks; Factory Droid +# gets them from `cmux hooks factory install`. The pieces kept here: +# hooks/cmux-auto-resume.py forwards SessionStart to cmux for agents started +# WITHOUT the wrapper, verifying it landed +# hooks/cmux-session-ledger.py retired; a no-op for one release so a +# registration that outlives the pull still exits 0 +# bin/cmux-restore-sessions.py → ~/.local/bin/cmux-restore-sessions, for +# panels with no resume binding +# settings install/patch-settings.py registers cmux-auto-resume.py and drops +# the ledger registrations in Claude settings and Factory hooks +# ~/.claude/hooks is a MIXED dir: symlink the files individually, never the +# directory. A single `install.sh --apply` rebuilds all of this from git; +# claude.tgz-style home backups only capture ~/.claude and drop ~/.local/bin. +log "[4/5] Wiring cmux session hooks (hooks + CLI + settings)" +ensure_symlink "$HOME_DIR/.claude/hooks/cmux-auto-resume.py" "$REPO_ROOT/hooks/cmux-auto-resume.py" +ensure_symlink "$HOME_DIR/.claude/hooks/cmux-session-ledger.py" "$REPO_ROOT/hooks/cmux-session-ledger.py" +ensure_symlink "$HOME_DIR/.local/bin/cmux-restore-sessions" "$REPO_ROOT/bin/cmux-restore-sessions.py" if [[ $APPLY -eq 1 ]]; then CMUX_CLI="/Applications/cmux.app/Contents/Resources/bin/cmux" if [[ -x "$CMUX_CLI" ]]; then @@ -812,9 +809,9 @@ if [[ $APPLY -eq 1 ]]; then python3 "$REPO_ROOT/install/patch-settings.py" \ "$HOME_DIR/.claude/settings.json" "$HOME_DIR/.factory/hooks.json" \ 2>&1 | sed 's/^/ /' \ - || warn "patch-settings failed (continuing — retired session hooks may still be registered)" + || warn "patch-settings failed (continuing — cmux session hooks may be incomplete)" else - note "would install cmux Factory hooks and patch Claude + Factory lifecycle hooks" + note "would install cmux Factory hooks and patch Claude + Factory session hooks" fi log "" diff --git a/install/patch-settings.py b/install/patch-settings.py index 314739f..5acee55 100755 --- a/install/patch-settings.py +++ b/install/patch-settings.py @@ -1,21 +1,21 @@ #!/usr/bin/env python3 -"""Idempotent removal of retired cmux session hooks from agent hook settings. - -cmux records agent sessions itself (`cmux sessions list --json`), so two -hand-written hooks that kept their own copy of that mapping are retired: - - - cmux-session-ledger.py appended start/end events to a JSONL ledger - - cmux-registry.py mapped each cmux workspace to its latest session - -hooks/cmux-auto-resume.py stays: it is what makes cmux record agents started -without cmux's Claude wrapper. - -This drops every hook whose command runs a retired script, under every event, -plus any matcher block or event it leaves empty. Accepts one or more paths, -including ``~/.claude/settings.json`` and ``~/.factory/hooks.json``. A file is -rewritten only when something was removed, and is backed up first. A missing, -malformed, or unreadable file is left alone; an unreadable one fails the run -after the remaining paths are handled. +"""Idempotent cmux session hook patcher for Claude settings and Factory hooks. + +Ensures: + - SessionStart runs hooks/cmux-auto-resume.py (linked into ~/.claude/hooks/). + It forwards SessionStart to cmux for agents started without cmux's Claude + wrapper, so cmux still records them. Added when no SessionStart hook runs + it; an existing registration is left as it is. + - No event runs cmux-session-ledger.py. cmux records sessions itself, so + the ledger is retired; its registrations are removed, along with any + matcher block or event that leaves empty. + +Hooks other installers own, such as mukul-memory's cmux-registry.py, are never +touched. Accepts one or more paths, including ``~/.claude/settings.json`` and +``~/.factory/hooks.json``. A file is written only when it changes; an existing +one is backed up first, a newly created one is not. A malformed file is backed +up and rebuilt. A file that can't be read fails the run after the remaining +paths are handled. """ import json import os @@ -24,16 +24,20 @@ import time from pathlib import Path -RETIRED_HOOK_SCRIPTS = ( - "cmux-session-ledger.py", - "cmux-registry.py", -) +AUTO_RESUME_SCRIPT = "cmux-auto-resume.py" +AUTO_RESUME_CMD = f"python3 $HOME/.claude/hooks/{AUTO_RESUME_SCRIPT}" +AUTO_RESUME_TIMEOUT = 10 +RETIRED_HOOK_SCRIPTS = ("cmux-session-ledger.py",) -def is_retired(entry): +def runs(entry, scripts): command = entry.get("command") if isinstance(entry, dict) else None - return any(os.path.basename(word) in RETIRED_HOOK_SCRIPTS - for word in str(command or "").split()) + return any(os.path.basename(word) in scripts for word in str(command or "").split()) + + +def entries(blocks): + return [entry for block in blocks if isinstance(block, dict) + for entry in (block.get("hooks") if isinstance(block.get("hooks"), list) else [])] def remove_retired(hooks): @@ -44,10 +48,11 @@ def remove_retired(hooks): continue kept_blocks = [] for block in blocks: - entries = block.get("hooks") if isinstance(block, dict) else None - if isinstance(entries, list) and any(map(is_retired, entries)): - removed += [f"{event}: {entry['command']}" for entry in entries if is_retired(entry)] - block["hooks"] = [entry for entry in entries if not is_retired(entry)] + found = block.get("hooks") if isinstance(block, dict) else None + if isinstance(found, list) and any(runs(e, RETIRED_HOOK_SCRIPTS) for e in found): + removed += [f"{event}: {e['command']}" for e in found + if runs(e, RETIRED_HOOK_SCRIPTS)] + block["hooks"] = [e for e in found if not runs(e, RETIRED_HOOK_SCRIPTS)] if not block["hooks"]: continue kept_blocks.append(block) @@ -58,39 +63,67 @@ def remove_retired(hooks): return removed +def ensure_auto_resume(hooks): + """Register the auto-resume hook on SessionStart unless it already runs there.""" + start = hooks.get("SessionStart") + if not isinstance(start, list): + if start is not None: + return False + start = hooks["SessionStart"] = [] + if any(runs(entry, (AUTO_RESUME_SCRIPT,)) for entry in entries(start)): + return False + start.append({"matcher": "", "hooks": [ + {"type": "command", "command": AUTO_RESUME_CMD, "timeout": AUTO_RESUME_TIMEOUT}]}) + return True + + def patch_path(path): path = Path(path).expanduser() # Atomic replace on a SYMLINK would sever it (swap the link for a regular - # file), disconnecting an operator/machine-config-managed target, so - # resolve to the real file and write THROUGH the symlink instead. + # file), disconnecting an operator/machine-config-managed target. These are + # functional session hooks that must still apply, so resolve to the real + # file and write THROUGH the symlink instead of replacing it. write_target = Path(os.path.realpath(path)) if path.is_symlink() else path - if not path.exists(): - print(f" {path} does not exist — nothing to remove") - return False - try: - settings = json.loads(path.read_text()) - except json.JSONDecodeError: - print(f" {path} is malformed JSON — left untouched") - return False - hooks = settings.get("hooks") if isinstance(settings, dict) else None + existed = path.exists() + settings = {} + if existed: + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + settings = loaded if isinstance(loaded, dict) else {} + except json.JSONDecodeError: + # A corrupt settings file must be recovered (backed up + rebuilt), + # not raise — that would abort the installer under `set -e`. + corrupt = path.with_suffix(path.suffix + f".corrupt-{int(time.time())}") + shutil.copy2(path, corrupt) + print(f" {path} was malformed JSON — backed up to {corrupt}, rebuilding") + settings = {} + hooks = settings.setdefault("hooks", {}) if not isinstance(hooks, dict): - print(" no changes needed") + print(f" {path} has a non-object \"hooks\" — left untouched") return False + removed = remove_retired(hooks) - if not removed: - print(" no changes needed") - return False for line in removed: print(f" - {line}") + added = ensure_auto_resume(hooks) + if added: + print(f" + SessionStart: {AUTO_RESUME_CMD}") + else: + print(f" = SessionStart already runs {AUTO_RESUME_SCRIPT}") + if not (removed or added): + print(" no changes needed") + return False - bak = write_target.with_suffix(write_target.suffix + f".bak-{int(time.time())}") - shutil.copy2(write_target, bak) - print(f" backed up to {bak}") + if write_target.exists(): + bak = write_target.with_suffix(write_target.suffix + f".bak-{int(time.time())}") + shutil.copy2(write_target, bak) + print(f" backed up to {bak}") + write_target.parent.mkdir(parents=True, exist_ok=True) # Atomic write: a crash mid-write must not truncate settings.json (which # would brick every later `--apply`). A pid-unique tmp name avoids two # concurrent `--apply` runs racing on a shared tmp path. tmp = write_target.with_suffix(write_target.suffix + f".tmp-{os.getpid()}") - tmp.write_text(json.dumps(settings, indent=2) + "\n") + tmp.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") tmp.replace(write_target) print(f" wrote {write_target}") return True @@ -110,7 +143,7 @@ def main(): try: patch_path(raw_path) except (OSError, ValueError) as exc: - print(f" {raw_path} could not be cleaned: {exc}", file=sys.stderr) + print(f" {raw_path} could not be patched: {exc}", file=sys.stderr) failed = True raise SystemExit(1 if failed else 0) diff --git a/tests/mutation_smoke.py b/tests/mutation_smoke.py index b6b7630..7f46127 100644 --- a/tests/mutation_smoke.py +++ b/tests/mutation_smoke.py @@ -161,6 +161,13 @@ '[cli, "sessions", "list", "--json"],', "tests/test_cmux_sessions.py::test_lists_every_saved_record_with_typed_fields", ), + ( + "the registry stands in only for sessions cmux lacks", + "bin/cmux_sessions.py", + " if not session_id or session_id in known:\n", + " if not session_id:\n", + "tests/test_cmux_sessions.py::test_registry_fallback_keeps_only_sessions_cmux_lacks", + ), ( "a session cleared off its surface is not live", "bin/world-scanner.py", @@ -176,76 +183,109 @@ "tests/test_world_scanner.py::test_current_session_record_verifies_missing_native_binding", ), ( - "a dead-pid cmux record never attaches a transcript", + "a session cmux missed is verified from the registry", + "bin/world-scanner.py", + " for source in (cmux_sessions.CMUX, cmux_sessions.REGISTRY):\n", + " for source in (cmux_sessions.CMUX,):\n", + "tests/test_world_scanner.py::test_registry_fallback_verifies_a_session_cmux_never_recorded", + ), + ( + "a reused registry pid is not a live session", + "bin/world-scanner.py", + " if record.source == cmux_sessions.REGISTRY and not cmux_sessions.agent_process(record.pid):\n continue\n", + "", + "tests/test_world_scanner.py::test_registry_row_with_a_live_pid_must_be_an_agent", + ), + ( + "two active records for a surface verify neither", + "bin/world-scanner.py", + " if len(matches) != 1:\n return None\n record = matches[0]", + " if not matches:\n return None\n record = matches[0]", + "tests/test_world_scanner.py::test_two_active_records_for_one_surface_verify_neither", + ), + ( + "a dead-pid record never attaches a transcript", "bin/build-ws-context.py", " if not _pid_alive(record.pid):\n return None # stale record — do NOT trust\n", "", "tests/test_build_ws_context_in_process.py::RecordedTranscriptTests::test_dead_pid_record_rejected", ), ( - "help says cmux records sessions itself", - "install.sh", - " - cmux records agent sessions itself: hooks/cmux-auto-resume.py → ~/.claude/hooks/\n" - " for agents started outside cmux's wrapper, Factory hooks via cmux, and the\n" - " retired hand-written session hooks removed from Claude and Factory settings\n", - "", - "tests/test_install_cmux_sessions.py::test_help_documents_native_cmux_sessions", + "two cmux records for a surface are ambiguous", + "bin/build-ws-context.py", + " if len(records) != 1:\n return None\n record = records[0]", + " if not records:\n return None\n record = records[0]", + "tests/test_build_ws_context_in_process.py::RecordedTranscriptTests::test_two_active_cmux_records_for_the_surface_are_ambiguous", ), ( - "help no longer lists the registry among untouched files", - "install.sh", - " - ~/.claude/cache/, ~/.claude/projects/\n", - " - ~/.claude/cache/\n", - "tests/test_install_cmux_sessions.py::test_help_documents_native_cmux_sessions", + "the forward waits for cmux to record the session", + "hooks/cmux-auto-resume.py", + ' if not any(isinstance(row, dict) and row.get("session_id") == sid for row in sessions):\n', + " if False:\n", + "tests/test_cmux_auto_resume.py::test_a_session_cmux_never_records_is_ledgered_after_the_last_try", ), ( - "apply removes a retired link", - "install.sh", - ' rm "$link"\n', - " :\n", - "tests/test_install_cmux_sessions.py::test_apply_removes_the_retired_links_and_registrations", + "a failed forward is retried", + "hooks/cmux-auto-resume.py", + " if reason is None:\n return None\n return reason\n", + " return reason\n return reason\n", + "tests/test_cmux_auto_resume.py::test_a_failed_forward_is_retried_with_backoff", ), ( - "a dry run keeps retired links", - "install.sh", - ' if [[ $APPLY -eq 1 ]]; then\n rm "$link"\n', - ' if true; then\n rm "$link"\n', - "tests/test_install_cmux_sessions.py::test_dry_run_reports_without_removing", + "SessionStart never waits for the forward", + "hooks/cmux-auto-resume.py", + ' subprocess.Popen(\n [sys.executable, os.path.abspath(__file__), "--worker", job],', + ' subprocess.run(\n [sys.executable, os.path.abspath(__file__), "--worker", job],', + "tests/test_cmux_auto_resume.py::test_the_hook_returns_at_once_and_the_detached_worker_finishes", ), ( - "only links into this checkout are removed", - "install.sh", - ' if [[ -L "$link" && "$(readlink "$link")" == "$target" ]]; then\n', - ' if [[ -L "$link" ]]; then\n', - "tests/test_install_cmux_sessions.py::test_retired_link_into_another_checkout_is_kept", + "the auto-resume hook is registered on a fresh machine", + "install/patch-settings.py", + " added = ensure_auto_resume(hooks)\n", + " added = False\n", + "tests/test_install_cmux_sessions.py::test_fresh_apply_links_and_registers_the_session_hooks", + ), + ( + "the ledger registrations are removed", + "install/patch-settings.py", + " removed = remove_retired(hooks)\n", + " removed = []\n", + "tests/test_install_cmux_sessions.py::test_apply_on_the_live_layout_drops_only_the_ledger", ), ( - "the retired restore CLI link is removed", + "help documents the session hooks", "install.sh", - 'remove_retired_link "$HOME_DIR/.local/bin/cmux-restore-sessions" "$REPO_ROOT/bin/cmux-restore-sessions.py"\n', + " - cmux session hooks: hooks/ → ~/.claude/hooks/ (symlinks),\n", "", - "tests/test_install_cmux_sessions.py::test_apply_removes_the_retired_links_and_registrations", + "tests/test_install_cmux_sessions.py::test_help_documents_the_cmux_session_hooks", ), ( - "the auto-resume hook stays linked", + "help says SessionStart runs the auto-resume hook", "install.sh", - 'ensure_symlink "$HOME_DIR/.claude/hooks/cmux-auto-resume.py" "$REPO_ROOT/hooks/cmux-auto-resume.py"\n', + " and Claude + Factory SessionStart patched to run cmux-auto-resume.py\n", "", - "tests/test_install_cmux_sessions.py::test_fresh_apply_links_the_auto_resume_hook", + "tests/test_install_cmux_sessions.py::test_help_documents_the_cmux_session_hooks", ), ( - "the auto-resume hook stays registered", - "install/patch-settings.py", - 'RETIRED_HOOK_SCRIPTS = (\n "cmux-session-ledger.py",\n', - 'RETIRED_HOOK_SCRIPTS = (\n "cmux-auto-resume.py",\n "cmux-session-ledger.py",\n', - "tests/test_patch_settings.py::test_removes_every_retired_hook_and_keeps_the_rest", + "the session hook step is logged", + "install.sh", + 'log "[4/5] Wiring cmux session hooks (hooks + CLI + settings)"\n', + 'log "[4/5]"\n', + "tests/test_install_cmux_sessions.py::test_fresh_apply_links_and_registers_the_session_hooks", ), ( - "a failed settings cleanup is reported", + "a failed settings patch is reported", "install.sh", - ' || warn "patch-settings failed (continuing — retired session hooks may still be registered)"\n', + ' || warn "patch-settings failed (continuing — cmux session hooks may be incomplete)"\n', " || true\n", - "tests/test_install_cmux_sessions.py::test_failed_settings_cleanup_warns_and_the_install_continues", + "tests/test_install_cmux_sessions.py::test_failed_settings_patch_warns_and_the_install_continues", + ), + ( + "a dry run says what it would patch", + "install.sh", + ' note "would install cmux Factory hooks and patch Claude + Factory session hooks"\n', + " :\n", + "tests/test_install_cmux_sessions.py::test_dry_run_reports_without_patching", ), ) diff --git a/tests/test_build_ws_context_in_process.py b/tests/test_build_ws_context_in_process.py index ff66410..e0e34b3 100644 --- a/tests/test_build_ws_context_in_process.py +++ b/tests/test_build_ws_context_in_process.py @@ -277,6 +277,48 @@ def test_agent_the_fleet_does_not_run_is_ignored(self): full, agent="codex", pid=os.getpid(), transcript_path=self._mk_transcript(full))) self.assertIsNone(lookup("SURF-A")) + def test_two_active_cmux_records_for_the_surface_are_ambiguous(self): + # One store per agent: a claude and a factory record can both be active. + claude = "aaaa1111-1111-1111-1111-111111111111" + lookup = self._surface( + self._record(claude, pid=os.getpid(), transcript_path=self._mk_transcript(claude)), + self._record("bbbb2222-2222", agent="factory", pid=os.getpid()), + ) + self.assertIsNone(lookup("SURF-A")) + + def _write_registry(self, rows): + (self._tmp / ".claude/cmux-registry.json").write_text(json.dumps(rows)) + + def test_registry_row_stands_in_for_a_session_cmux_missed(self): + missed = "ee4ee641-8595-441a-bf3e-5e35ef75f42a" + tp = self._mk_transcript(missed) + self._write_registry({ + "WS-OLD": {"session_id": "0ld00000-x", "surface_id": "SURF-A", + "claude_pid": str(os.getpid()), "ts": 10, "transcript_path": tp}, + "WS-27": {"session_id": missed, "surface_id": "SURF-A", + "claude_pid": str(os.getpid()), "ts": 20, "transcript_path": tp}, + }) + lookup = self._surface(fake_cmux.record("other", surface_id="SURF-B", pid=os.getpid())) + self.assertEqual(lookup("surf-a"), + {"transcript_path": tp, "session_id8": "ee4ee641", "agent": "claude"}) + + def test_registry_is_ignored_while_cmux_has_the_surface(self): + live = "aaaa1111-1111-1111-1111-111111111111" + missed = "ee4ee641-8595-441a-bf3e-5e35ef75f42a" + self._write_registry({"WS": {"session_id": missed, "surface_id": "SURF-A", + "claude_pid": str(os.getpid()), "ts": 99, + "transcript_path": self._mk_transcript(missed)}}) + lookup = self._surface(self._record( + live, pid=os.getpid(), transcript_path=self._mk_transcript(live))) + self.assertEqual(lookup("SURF-A")["session_id8"], "aaaa1111") + + def test_registry_row_without_a_live_pid_is_rejected(self): + missed = "ee4ee641-8595-441a-bf3e-5e35ef75f42a" + self._write_registry({"WS": {"session_id": missed, "surface_id": "SURF-A", + "claude_pid": "", "ts": 1, + "transcript_path": self._mk_transcript(missed)}}) + self.assertIsNone(self._surface()("SURF-A")) + def test_unavailable_cmux_resolves_nothing(self): # conftest points CMUX_BIN at a binary that doesn't exist. self.assertIsNone(self.mod.recorded_transcript_for_surface("SURF-A")) diff --git a/tests/test_cmux_auto_resume.py b/tests/test_cmux_auto_resume.py index bc0ef1e..127c5b8 100644 --- a/tests/test_cmux_auto_resume.py +++ b/tests/test_cmux_auto_resume.py @@ -1,105 +1,287 @@ +"""hooks/cmux-auto-resume.py forwards SessionStart to cmux, confirms the record +landed, retries with backoff, ledgers a final failure, and sets a fallback +resume binding — in a detached worker, so SessionStart never waits on cmux. + +cmux is a fake executable written into the test's tmp dir. It answers +`hooks session-start` (exit codes from session-start.rc, one per +call; records the session unless no-land exists), `sessions list --session` +(other-session makes it list only someone else's session), +and `surface resume set`, logging every call to calls.jsonl. +""" from __future__ import annotations import importlib.util import io import json import os +import subprocess +import sys +import time from pathlib import Path from unittest import mock +import pytest + REPO = Path(__file__).resolve().parent.parent +HOOK = REPO / "hooks" / "cmux-auto-resume.py" +# Lets a coverage run measure the hook's own subprocesses. +COVERAGE_ENV = {k: v for k, v in os.environ.items() if k.startswith("COVERAGE")} + +FAKE_CMUX = """#!{python} +import json, pathlib, sys, time +here = pathlib.Path(__file__).resolve().parent +args = sys.argv[1:] +stdin = sys.stdin.read() if args[:1] == ["hooks"] else "" +with open(here / "calls.jsonl", "a") as log: + log.write(json.dumps({{"args": args, "stdin": stdin}}) + "\\n") +recorded = here / "recorded.json" +sessions = json.loads(recorded.read_text()) if recorded.exists() else [] +if args[:1] == ["hooks"] and args[2:3] == ["session-start"]: + if (here / "slow").exists(): + time.sleep(float((here / "slow").read_text() or 3)) + codes = (here / "session-start.rc").read_text().split() if (here / "session-start.rc").exists() else [] + code = int(codes.pop(0)) if codes else 0 + (here / "session-start.rc").write_text(" ".join(codes)) + if code == 0 and not (here / "no-land").exists(): + sessions.append({{"session_id": json.loads(stdin)["session_id"]}}) + recorded.write_text(json.dumps(sessions)) + (here / "session-start.done").write_text("") + sys.exit(code) +if args[:2] == ["sessions", "list"]: + if (here / "junk-list").exists(): + print("not json") + sys.exit(0) + sid = args[args.index("--session") + 1] + if (here / "other-session").exists(): + sessions, sid = [{{"session_id": "someone-else"}}], "someone-else" + print(json.dumps({{"sessions": [s for s in sessions if s["session_id"] == sid]}})) + sys.exit(0) +if args[:3] == ["surface", "resume", "set"]: + sys.exit(0) +sys.exit(2) +""" def load_module(): - spec = importlib.util.spec_from_file_location( - "cmux_auto_resume_test", REPO / "hooks" / "cmux-auto-resume.py" - ) + spec = importlib.util.spec_from_file_location("cmux_auto_resume_test", HOOK) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module -def test_factory_payload_uses_factory_hook_and_droid_fallback(tmp_path): +@pytest.fixture +def cmux(tmp_path): + directory = tmp_path / "cmux-bin" + directory.mkdir() + cli = directory / "cmux" + cli.write_text(FAKE_CMUX.format(python=sys.executable)) + cli.chmod(0o755) + return directory + + +def calls(directory): + log = directory / "calls.jsonl" + return [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + + +def environment(tmp_path, cmux, **extra): + return {"HOME": str(tmp_path), "CMUX_SURFACE_ID": "surface-1", + "CMUX_WORKSPACE_ID": "workspace-1", + "CMUX_BUNDLED_CLI_PATH": str(cmux / "cmux"), **extra} + + +def ledger(tmp_path): + path = tmp_path / ".assistant" / "actions-ledger.jsonl" + return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] + + +def run_worker(module, provider, payload, sleeps): + module.worker({"provider": provider, "payload": payload}, sleep=sleeps.append) + + +def test_factory_forward_is_confirmed_then_binds_a_droid_resume(tmp_path, cmux): module = load_module() lessons = tmp_path / ".claude/CLAUDE.md" lessons.parent.mkdir(parents=True) lessons.write_text("rules") - payload = { - "session_id": "factory-session", - "cwd": "/tmp/project with space", - "transcript_path": "/Users/test/.factory/sessions/project/session.jsonl", - } - env = { - "HOME": str(tmp_path), - "CMUX_SURFACE_ID": "surface-1", - "CMUX_BUNDLED_CLI_PATH": "/bin/cmux", - "CMUX_AGENT_LAUNCH_EXECUTABLE": "/usr/local/bin/droid", - } - with ( - mock.patch.dict(os.environ, env, clear=True), - mock.patch.object(module.sys, "stdin", io.StringIO(json.dumps(payload))), - mock.patch.object(module.subprocess, "run") as run, - ): - module.main() - - assert run.call_count == 2 - assert run.call_args_list[0].args[0] == [ - "/bin/cmux", "hooks", "factory", "session-start", - ] - fallback = run.call_args_list[1].args[0] - assert fallback[fallback.index("--kind") + 1] == "factory" - assert fallback[fallback.index("--name") + 1] == "Factory Droid" - shell = fallback[fallback.index("--shell") + 1] + payload = {"session_id": "factory-session", "cwd": "/tmp/project with space", + "transcript_path": "/Users/test/.factory/sessions/project/session.jsonl"} + env = environment(tmp_path, cmux, CMUX_AGENT_LAUNCH_EXECUTABLE="/usr/local/bin/droid") + sleeps = [] + with mock.patch.dict(os.environ, env, clear=True): + run_worker(module, "factory", payload, sleeps) + + started, listed, bound = calls(cmux) + assert started["args"] == ["hooks", "factory", "session-start"] + assert json.loads(started["stdin"]) == payload + assert listed["args"] == ["sessions", "list", "--session", "factory-session", "--all", "--json"] + assert bound["args"][bound["args"].index("--kind") + 1] == "factory" + assert bound["args"][bound["args"].index("--name") + 1] == "Factory Droid" + shell = bound["args"][bound["args"].index("--shell") + 1] assert "/usr/local/bin/droid" in shell assert "--resume factory-session" in shell assert "droid-glm-settings.json" in shell assert "--auto high" in shell assert "--append-system-prompt-file" in shell + assert sleeps == [0] + assert ledger(tmp_path) == [] -def test_claude_payload_uses_claude_hook_and_binding(): +def test_claude_binding_uses_the_launch_executable(tmp_path, cmux): module = load_module() - payload = { - "sessionId": "claude-session", - "cwd": "/tmp/repo", - "provider": "claude", - } - env = { - "CMUX_SURFACE_ID": "surface-1", - "CMUX_AGENT_LAUNCH_EXECUTABLE": "claude-custom", - } - with ( - mock.patch.dict(os.environ, env, clear=True), - mock.patch.object(module.sys, "stdin", io.StringIO(json.dumps(payload))), - mock.patch.object(module.subprocess, "run") as run, - ): - module.main() - - assert run.call_args_list[0].args[0][1:4] == [ - "hooks", "claude", "session-start", - ] - fallback = run.call_args_list[1].args[0] - assert fallback[fallback.index("--kind") + 1] == "claude" - assert fallback[fallback.index("--name") + 1] == "Claude Code" - shell = fallback[fallback.index("--shell") + 1] + env = environment(tmp_path, cmux, CMUX_AGENT_LAUNCH_EXECUTABLE="claude-custom") + with mock.patch.dict(os.environ, env, clear=True): + run_worker(module, "claude", {"session_id": "claude-session", "cwd": "/tmp/repo"}, []) + bound = calls(cmux)[-1]["args"] + assert bound[bound.index("--kind") + 1] == "claude" + assert bound[bound.index("--name") + 1] == "Claude Code" + shell = bound[bound.index("--shell") + 1] assert "claude-custom --resume claude-session" in shell assert "droid-glm-settings.json" not in shell -def test_launch_kind_infers_factory_and_wrapper_avoids_fallback(): +def test_wrapper_launch_needs_no_fallback_binding(tmp_path, cmux): + module = load_module() + env = environment(tmp_path, cmux, CMUX_AGENT_LAUNCH_ARGV_B64="present") + with mock.patch.dict(os.environ, env, clear=True): + run_worker(module, "factory", {"session_id": "sid", "cwd": "/tmp/repo"}, []) + assert [c["args"][:3] for c in calls(cmux)] == [ + ["hooks", "factory", "session-start"], ["sessions", "list", "--session"]] + + +def test_a_failed_forward_is_retried_with_backoff(tmp_path, cmux): + module = load_module() + (cmux / "session-start.rc").write_text("1 75 0") + sleeps = [] + with mock.patch.dict(os.environ, environment(tmp_path, cmux), clear=True): + run_worker(module, "claude", {"session_id": "sid", "cwd": "/r"}, sleeps) + starts = [c for c in calls(cmux) if c["args"][:1] == ["hooks"]] + assert len(starts) == 3 + assert sleeps == list(module.RETRY_DELAYS_SEC[:3]) + assert ledger(tmp_path) == [] + + +@pytest.mark.parametrize("failure,reason", [ + ("no-land", "cmux has no record of the session after session-start"), + ("other-session", "cmux has no record of the session after session-start"), + ("junk-list", "sessions list exit 0: unreadable output"), + ("rc", "session-start exit 1: "), +]) +def test_a_session_cmux_never_records_is_ledgered_after_the_last_try( + tmp_path, cmux, failure, reason): module = load_module() - env = { - "CMUX_SURFACE_ID": "surface-1", - "CMUX_AGENT_LAUNCH_KIND": "droid", - "CMUX_AGENT_LAUNCH_ARGV_B64": "present", - } - payload = {"session_id": "sid", "cwd": "/tmp/repo"} - with ( - mock.patch.dict(os.environ, env, clear=True), - mock.patch.object(module.sys, "stdin", io.StringIO(json.dumps(payload))), - mock.patch.object(module.subprocess, "run") as run, - ): - module.main() - - run.assert_called_once() - assert run.call_args.args[0][1:4] == ["hooks", "factory", "session-start"] + if failure == "rc": + (cmux / "session-start.rc").write_text(" ".join("1" for _ in module.RETRY_DELAYS_SEC)) + else: + (cmux / failure).write_text("") + sleeps = [] + with mock.patch.dict(os.environ, environment(tmp_path, cmux), clear=True): + run_worker(module, "claude", {"session_id": "lost-session", "cwd": "/r"}, sleeps) + assert sleeps == list(module.RETRY_DELAYS_SEC) + starts = [c for c in calls(cmux) if c["args"][:1] == ["hooks"]] + assert len(starts) == len(module.RETRY_DELAYS_SEC) + assert calls(cmux)[-1]["args"][:3] == ["surface", "resume", "set"] + [entry] = ledger(tmp_path) + assert entry["kind"] == "cmux-session-forward-fail" + assert entry["key"] == "cmux-session-forward-fail:lost-session" + assert entry["outcome"] == "failed" + assert entry["evidence"].startswith( + "cmux never recorded claude session lost-session (workspace workspace-1, " + f"surface surface-1) after {len(module.RETRY_DELAYS_SEC)} tries: {reason}") + + +def test_a_missing_or_hung_cli_is_a_reason_not_a_crash(tmp_path, cmux, monkeypatch): + module = load_module() + env = environment(tmp_path, cmux, CMUX_BUNDLED_CLI_PATH=str(tmp_path / "no-cmux")) + with mock.patch.dict(os.environ, env, clear=True): + run_worker(module, "claude", {"session_id": "sid", "cwd": "/r"}, []) + assert "session-start failed:" in ledger(tmp_path)[0]["evidence"] + + (cmux / "slow").write_text("") + monkeypatch.setattr(module, "CALL_TIMEOUT_SEC", 0.5) + monkeypatch.setattr(module, "RETRY_DELAYS_SEC", (0,)) + with mock.patch.dict(os.environ, environment(tmp_path, cmux), clear=True): + run_worker(module, "claude", {"session_id": "slow-sid", "cwd": "/r"}, []) + assert ledger(tmp_path)[1]["evidence"].endswith("session-start timed out after 0.5s") + + def broken_list(error): + def run(args, **kwargs): + if args[1:3] == ["sessions", "list"]: + raise error + return subprocess.CompletedProcess(args, 0, "", "") + return run + + assert module.forward_once("cmux", "claude", {"session_id": "s"}, + broken_list(OSError("socket gone"))) == ( + "sessions list failed: socket gone") + assert module.forward_once("cmux", "claude", {"session_id": "s"}, + broken_list(subprocess.TimeoutExpired("cmux", 0.5))) == ( + "sessions list timed out after 0.5s") + + +def test_the_hook_returns_at_once_and_the_detached_worker_finishes(tmp_path, cmux): + # session-start takes 8 s here; SessionStart must return before it ends. + # The done marker, not a wall clock, decides, so a loaded machine can't + # fail it. + (cmux / "slow").write_text("8") + payload = {"session_id": "e2e-session", "cwd": str(tmp_path), "provider": "claude"} + result = subprocess.run([sys.executable, str(HOOK)], input=json.dumps(payload), + capture_output=True, text=True, timeout=60, + env={**environment(tmp_path, cmux), **COVERAGE_ENV, + "PATH": os.environ["PATH"]}) + assert not (cmux / "session-start.done").exists() + assert (result.returncode, result.stdout, result.stderr) == (0, "", "") + deadline = time.monotonic() + 90 + while time.monotonic() < deadline and len(calls(cmux)) < 3: + time.sleep(0.1) + assert [c["args"][:3] for c in calls(cmux)] == [ + ["hooks", "claude", "session-start"], ["sessions", "list", "--session"], + ["surface", "resume", "set"]] + assert ledger(tmp_path) == [] + + +@pytest.mark.parametrize("env,payload", [ + ({}, {"session_id": "sid", "provider": "claude"}), + ({"CMUX_SURFACE_ID": "surface-1"}, {"provider": "claude"}), + ({"CMUX_SURFACE_ID": "surface-1"}, {"session_id": "sid", "provider": "codex"}), +]) +def test_nothing_starts_outside_cmux_or_without_a_supported_session(env, payload): + module = load_module() + with mock.patch.dict(os.environ, env, clear=True), \ + mock.patch.object(module.sys, "stdin", io.StringIO(json.dumps(payload))), \ + mock.patch.object(module.subprocess, "Popen") as popen: + module.main([]) + popen.assert_not_called() + + +def test_unreadable_hook_input_starts_nothing(): + module = load_module() + with mock.patch.dict(os.environ, {"CMUX_SURFACE_ID": "surface-1"}, clear=True), \ + mock.patch.object(module.sys, "stdin", io.StringIO("{not json")), \ + mock.patch.object(module.subprocess, "Popen") as popen: + module.main([]) + popen.assert_not_called() + + +def test_the_worker_job_carries_provider_and_normalized_payload(): + module = load_module() + payload = {"sessionId": "sid", "cwd": "/r", "transcriptPath": "/t/x.jsonl", + "agent": "droid"} + with mock.patch.dict(os.environ, {"CMUX_SURFACE_ID": "surface-1"}, clear=True), \ + mock.patch.object(module.sys, "stdin", io.StringIO(json.dumps(payload))), \ + mock.patch.object(module.subprocess, "Popen") as popen: + module.main([]) + argv = popen.call_args.args[0] + assert argv[:3] == [sys.executable, str(HOOK), "--worker"] + assert json.loads(argv[3]) == {"provider": "factory", "payload": { + **payload, "session_id": "sid", "cwd": "/r", "transcript_path": "/t/x.jsonl"}} + assert popen.call_args.kwargs["start_new_session"] is True + assert popen.call_args.kwargs["stdout"] is subprocess.DEVNULL + + +def test_a_broken_job_never_raises_out_of_the_hook(tmp_path): + result = subprocess.run([sys.executable, str(HOOK), "--worker", "{not json"], + capture_output=True, text=True, timeout=30, + env={"HOME": str(tmp_path), "PATH": os.environ["PATH"], + **COVERAGE_ENV}) + assert (result.returncode, result.stderr) == (0, "") diff --git a/tests/test_cmux_restore_sessions.py b/tests/test_cmux_restore_sessions.py new file mode 100644 index 0000000..fb6b300 --- /dev/null +++ b/tests/test_cmux_restore_sessions.py @@ -0,0 +1,217 @@ +"""bin/cmux-restore-sessions.py: resume commands per panel, and the recorded +session (cmux's record, else the registry row) for panels with no resume +binding. cmux is a fake executable (tests/fake_cmux.py) behind CMUX_BIN.""" +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +import fake_cmux + +REPO = Path(__file__).resolve().parent.parent + + +def load_module(): + spec = importlib.util.spec_from_file_location( + "cmux_restore_sessions_test", + REPO / "bin" / "cmux-restore-sessions.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def panel(kind="claude", arguments=None, binding=None): + return { + "id": "panel-1", + "directory": "/tmp/repo", + "terminal": { + "surfaceId": "surface-1", + "agent": { + "kind": kind, + "sessionId": "state-session", + "workingDirectory": "/tmp/repo", + "launchCommand": {"arguments": arguments or []}, + }, + "resumeBinding": binding or {}, + }, + } + + +def test_get_all_panels_accepts_claude_factory_and_droid(): + module = load_module() + state = { + "windows": [{ + "tabManager": { + "workspaces": [{ + "panels": [ + panel("claude"), + panel("factory"), + panel("droid"), + panel("codex"), + ], + }], + }, + }], + } + found = list(module.get_all_panels(state)) + assert [p["terminal"]["agent"]["kind"] for _, p in found] == [ + "claude", "factory", "droid", + ] + + +def test_claude_reuses_exact_previous_resume_binding(): + module = load_module() + previous = "cd /tmp/repo && /custom/claude --resume state-session --model opus" + cwd, command = module.build_resume_cmd( + panel( + "claude", + arguments=["/custom/claude", "--model", "opus"], + binding={ + "checkpointId": "state-session", + "cwd": "/tmp/repo", + "command": previous, + }, + ) + ) + assert cwd == "/tmp/repo" + assert command == previous + + +def test_factory_resume_preserves_args_and_enforces_glm_configuration( + tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + lessons = tmp_path / ".claude/CLAUDE.md" + lessons.parent.mkdir(parents=True) + lessons.write_text("rules") + module = load_module() + ledger = { + "session_id": "ledger-session", + "provider": "factory", + "cwd": "/tmp/project with space", + "launch_argv": [ + "/custom/droid", + "--settings", "/old/settings.json", + "--auto", "low", + "--append-system-prompt-file", "/old/prompt.md", + "--use-spec", + ], + } + cwd, command = module.build_resume_cmd( + panel("factory", arguments=["droid", "--auto", "medium"]), + ledger, + ) + assert cwd == "/tmp/project with space" + assert command.startswith("cd '/tmp/project with space' && /custom/droid") + assert "--resume ledger-session" in command + assert "--use-spec" in command + assert "/old/settings.json" not in command + assert "/old/prompt.md" not in command + assert "droid-glm-settings.json" in command + assert "--auto high" in command + assert "--append-system-prompt-file" in command + + +def test_factory_rebuilds_unconfigured_old_binding(): + module = load_module() + old = "cd /tmp/repo && droid --resume state-session" + _, command = module.build_resume_cmd( + panel( + "droid", + arguments=["droid"], + binding={ + "checkpointId": "state-session", + "cwd": "/tmp/repo", + "command": old, + }, + ) + ) + assert command != old + assert "droid-glm-settings.json" in command + assert "--resume state-session" in command + + +def test_recorded_entry_without_provider_fails_closed(): + module = load_module() + old_panel = panel("", arguments=[]) + cwd, command = module.build_resume_cmd( + old_panel, {"session_id": "old-session", "cwd": "/tmp/repo"}) + assert cwd is None + assert command is None + + +@pytest.fixture +def recorded(tmp_path, monkeypatch): + """A fake cmux plus an empty registry; returns (module, cmux dir).""" + module = load_module() + cli_dir = tmp_path / "cmux-bin" + monkeypatch.setenv("CMUX_BIN", str(fake_cmux.install(cli_dir))) + module.REGISTRY = tmp_path / "cmux-registry.json" + return module, cli_dir + + +def test_load_recorded_takes_the_newest_session_per_panel(recorded): + module, cli_dir = recorded + fake_cmux.write(cli_dir, [ + fake_cmux.record("droid-sid", agent="factory", surface_id="PANEL-D", cwd="/d/sub", + launch_working_directory="/d", pid=55, + launch_arguments=["droid", "--auto", "low"], updated_at_unix=30, + transcript_path="/f/droid-sid.jsonl"), + fake_cmux.record("older-claude", surface_id="PANEL-D", updated_at_unix=5), + fake_cmux.record("stale-cmux", surface_id="PANEL-C", updated_at_unix=10), + fake_cmux.record("cleared", surface_id="PANEL-X", active_for_surface=False), + fake_cmux.record("codex", agent="codex", surface_id="PANEL-Y"), + ]) + module.REGISTRY.write_text(json.dumps({ + "WS-C": {"session_id": "newer-registry", "surface_id": "PANEL-C", "ts": 20, + "cwd": "/c", "claude_pid": "", "transcript_path": "/t/c.jsonl"}, + "WS-R": {"session_id": "registry-only", "surface_id": "PANEL-R", "ts": 5, "cwd": "/r"}, + })) + entries = module.load_recorded() + assert sorted(entries) == ["PANEL-C", "PANEL-D", "PANEL-R"] + assert entries["PANEL-D"] == { + "session_id": "droid-sid", "cwd": "/d", "provider": "factory", + "launch_argv": ["droid", "--auto", "low"], "transcript_path": "/f/droid-sid.jsonl", + "pid": 55, + } + assert entries["PANEL-C"]["session_id"] == "newer-registry" + assert entries["PANEL-R"]["provider"] == "claude" + + +def test_dry_run_restores_a_panel_with_no_resume_binding(recorded, tmp_path, monkeypatch, + capsys): + # ee4ee641's panel on 2026-10-19: no agent state, no resume binding, and no + # cmux record. Only the registry knows its session. + module, _ = recorded + module.CMUX_STATE = tmp_path / "state.json" + module.CMUX_STATE.write_text(json.dumps({"windows": [{"tabManager": {"workspaces": [{ + "customTitle": "Fix floodgate [27]", + "panels": [{"id": "5C0A8B6C", "terminal": {}}], + }]}}]})) + module.REGISTRY.write_text(json.dumps({"WS-27": { + "session_id": "ee4ee641-8595", "surface_id": "5C0A8B6C", "ts": 1, + "cwd": "/Users/x/dev/firefly-platform", "claude_pid": "", + }})) + monkeypatch.setattr(module, "pgrep_session", lambda sid: False) + monkeypatch.setattr(sys, "argv", ["cmux-restore-sessions.py", "--dry-run", "--verbose"]) + module.main() + out = capsys.readouterr().out + assert "Recorded sessions: 1" in out + assert ("[DRY-RUN] Would send: cd /Users/x/dev/firefly-platform && " + "claude --resume ee4ee641-8595") in out + assert "Summary: 1 restored, 0 skipped, 1 dead" in out + + +def test_ledger_transcript_can_identify_factory(): + module = load_module() + ledger = { + "session_id": "sid", + "transcript_path": "/Users/test/.factory/sessions/repo/sid.jsonl", + } + _, command = module.build_resume_cmd(panel("", arguments=[]), ledger) + assert "droid --resume sid" in command + assert "droid-glm-settings.json" in command diff --git a/tests/test_cmux_session_ledger.py b/tests/test_cmux_session_ledger.py new file mode 100644 index 0000000..2b8c069 --- /dev/null +++ b/tests/test_cmux_session_ledger.py @@ -0,0 +1,41 @@ +"""hooks/cmux-session-ledger.py is a retired no-op kept for one release: a +SessionStart/SessionEnd registration that outlives the pull must exit 0 +without writing the old ledger.""" +from __future__ import annotations + +import json +import os +import pty +import subprocess +import sys +from pathlib import Path + +import pytest + +HOOK = Path(__file__).resolve().parent.parent / "hooks" / "cmux-session-ledger.py" +# Lets a coverage run measure the stub's process. +COVERAGE_ENV = {k: v for k, v in os.environ.items() if k.startswith("COVERAGE")} + + +@pytest.mark.parametrize("event", ["start", "end"]) +def test_registered_stub_drains_stdin_and_exits_cleanly(tmp_path, event): + payload = json.dumps({"session_id": "sid", "cwd": "/r"}) * 2000 + result = subprocess.run([sys.executable, str(HOOK), event], input=payload, + capture_output=True, text=True, timeout=30, + env={"HOME": str(tmp_path), "CMUX_SURFACE_ID": "surface-1", + **COVERAGE_ENV}) + assert (result.returncode, result.stdout, result.stderr) == (0, "", "") + assert not (tmp_path / ".cmux-session-ledger.jsonl").exists() + assert [p for p in tmp_path.iterdir() if not p.name.startswith(".coverage")] == [] + + +def test_stub_run_by_hand_does_not_wait_for_input(tmp_path): + controller, terminal = pty.openpty() + try: + result = subprocess.run([sys.executable, str(HOOK), "start"], stdin=terminal, + capture_output=True, timeout=10, + env={"HOME": str(tmp_path), **COVERAGE_ENV}) + finally: + os.close(terminal) + os.close(controller) + assert result.returncode == 0 diff --git a/tests/test_cmux_sessions.py b/tests/test_cmux_sessions.py index 82be617..b7a3d56 100644 --- a/tests/test_cmux_sessions.py +++ b/tests/test_cmux_sessions.py @@ -2,7 +2,9 @@ returns typed session records, or [] whenever cmux can't answer.""" from __future__ import annotations +import json import os +import subprocess from datetime import datetime, timezone from pathlib import Path @@ -111,6 +113,17 @@ def test_hung_cli_times_out_to_no_sessions(tmp_path, monkeypatch, capsys): assert "timed out" in capsys.readouterr().err +def test_a_slow_cli_still_answers_within_the_timeout(tmp_path, monkeypatch): + # cmux took 4.8 s to answer on a busy machine; a scan must not blank. + directory = tmp_path / "slow" + fake_cmux.install(directory, [fake_cmux.record("slow-but-there")]) + slow = directory / "slow-cmux" + slow.write_text(f"#!/bin/sh\nsleep 6\nexec '{directory / 'cmux'}' \"$@\"\n") + slow.chmod(0o755) + monkeypatch.setenv("CMUX_BIN", str(slow)) + assert [s.session_id for s in cmux_sessions.list_sessions()] == ["slow-but-there"] + + def _executable(path: Path) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text("#!/bin/sh\n") @@ -146,3 +159,73 @@ def test_bundled_cli_is_what_runs_without_an_override(tmp_path, monkeypatch): monkeypatch.setenv("CMUX_BUNDLED_CLI_PATH", str(path)) monkeypatch.setenv("PATH", os.defpath) assert [s.session_id for s in cmux_sessions.list_sessions()] == ["from-bundle"] + + +def test_launch_arguments_become_a_tuple(cli): + fake_cmux.write(cli, [ + fake_cmux.record("with-argv", launch_arguments=["/x/claude", "--model", "opus"]), + fake_cmux.record("bad-argv", launch_arguments="not a list"), + ]) + sessions = {s.session_id: s for s in cmux_sessions.list_sessions()} + assert sessions["with-argv"].launch_argv == ("/x/claude", "--model", "opus") + assert sessions["bad-argv"].launch_argv == () + assert {s.source for s in sessions.values()} == {cmux_sessions.CMUX} + + +def _registry(tmp_path, rows): + path = tmp_path / "cmux-registry.json" + path.write_text(rows if isinstance(rows, str) else json.dumps(rows)) + return path + + +def test_registry_fallback_keeps_only_sessions_cmux_lacks(tmp_path): + path = _registry(tmp_path, { + "WS-MISSED": {"session_id": "missed", "cwd": "/repo", "surface_id": "SF-1", + "tab_id": "WS-MISSED", "panel_id": "SF-1", "claude_pid": "", + "ts": 1792107522.13, "transcript_path": "/t/missed.jsonl"}, + "WS-KNOWN": {"session_id": "known", "surface_id": "SF-2", "claude_pid": "4242", + "ts": 10}, + "WS-PANEL": {"session_id": "panel-only", "panel_id": "SF-3", "claude_pid": 77, + "ts": "not a time"}, + "WS-NOSID": {"surface_id": "SF-4"}, + "WS-JUNK": "not a row", + }) + known = [cmux_sessions.Session( + session_id="known", agent="claude", workspace_id=None, surface_id=None, cwd=None, + launch_cwd=None, pid=None, transcript_path=None, started_at=None, updated_at=None, + active_for_surface=True)] + rows = {s.session_id: s for s in cmux_sessions.registry_fallback(path, known)} + assert list(rows) == ["missed", "panel-only"] + assert rows["missed"] == cmux_sessions.Session( + session_id="missed", agent="claude", workspace_id="WS-MISSED", surface_id="SF-1", + cwd="/repo", launch_cwd="/repo", pid=None, transcript_path="/t/missed.jsonl", + started_at=1792107522.13, updated_at=1792107522.13, active_for_surface=True, + source=cmux_sessions.REGISTRY) + panel = rows["panel-only"] + assert (panel.surface_id, panel.pid, panel.started_at, panel.provider) == ( + "SF-3", 77, None, "claude") + + +@pytest.mark.parametrize("contents", [None, "{ not json", "[]"]) +def test_unreadable_registry_yields_no_fallback(tmp_path, contents): + path = tmp_path / "missing.json" if contents is None else _registry(tmp_path, contents) + assert cmux_sessions.registry_fallback(path, []) == [] + + +def test_agent_process_requires_a_live_claude_or_droid(tmp_path): + impostor = tmp_path / "claude" + impostor.symlink_to("/bin/sleep") + process = subprocess.Popen([str(impostor), "30"]) + try: + assert cmux_sessions.agent_process(process.pid) is True + finally: + process.kill() + process.wait() + assert cmux_sessions.agent_process(os.getpid()) is False # a reused pid: python + assert cmux_sessions.agent_process(process.pid) is False # exited + assert cmux_sessions.agent_process(None) is False + + +def test_agent_process_without_ps_is_false(monkeypatch): + monkeypatch.setenv("PATH", "/nonexistent") + assert cmux_sessions.agent_process(os.getpid()) is False diff --git a/tests/test_install_cmux_sessions.py b/tests/test_install_cmux_sessions.py index 8194d76..3e51c56 100644 --- a/tests/test_install_cmux_sessions.py +++ b/tests/test_install_cmux_sessions.py @@ -1,7 +1,6 @@ -"""install.sh section 4: cmux records sessions itself, so the installer removes -the retired hand-written session hooks instead of wiring them, and keeps -hooks/cmux-auto-resume.py, which makes cmux record agents started outside its -Claude wrapper. +"""install.sh section 4: link the cmux session hooks and restore CLI, register +cmux-auto-resume.py, and drop the retired ledger hook's registrations without +ever leaving a registered command pointing at a missing file. Runs the real installer against an isolated HOME with recording service commands (tests/installer_sandbox.py). @@ -20,13 +19,15 @@ REPO = Path(__file__).resolve().parent.parent INSTALL = REPO / "install.sh" -RETIRED = { +LINKS = { + ".claude/hooks/cmux-auto-resume.py": REPO / "hooks/cmux-auto-resume.py", ".claude/hooks/cmux-session-ledger.py": REPO / "hooks/cmux-session-ledger.py", ".local/bin/cmux-restore-sessions": REPO / "bin/cmux-restore-sessions.py", } -AUTO_RESUME_LINK = ".claude/hooks/cmux-auto-resume.py" AUTO_RESUME = "python3 $HOME/.claude/hooks/cmux-auto-resume.py" LEDGER_START = "python3 $HOME/.claude/hooks/cmux-session-ledger.py start" +LEDGER_END = "python3 $HOME/.claude/hooks/cmux-session-ledger.py end" +REGISTRY = "$HOME/.claude/hooks/cmux-registry.py" @pytest.fixture @@ -34,7 +35,7 @@ def install(tmp_path): """Run install.sh with these flags against the isolated HOME `tmp_path`.""" env = installer_env(tmp_path) - def run(*flags: str) -> subprocess.CompletedProcess: + def run(*flags: str, ok: bool = True) -> subprocess.CompletedProcess: if "--apply" not in flags: # A dry run expects a previous install's plists. plists = tmp_path / "Library/LaunchAgents" @@ -50,105 +51,84 @@ def run(*flags: str) -> subprocess.CompletedProcess: return run -def _seed_previous_install(home: Path) -> dict[str, Path]: - """Links an older install.sh made, plus links it never owned.""" - links = {} - for relative, target in RETIRED.items(): - link = home / relative - link.parent.mkdir(parents=True, exist_ok=True) - link.symlink_to(target) - links[relative] = link - kept = home / AUTO_RESUME_LINK - kept.symlink_to(REPO / "hooks/cmux-auto-resume.py") - links["auto-resume"] = kept - foreign = home / ".claude/hooks/cmux-registry.py" - foreign.symlink_to(home / "dev/mukul-memory/hooks/cmux-registry.py") - links["foreign"] = foreign - elsewhere = home / ".claude/hooks/block-hook-bypass.py" - elsewhere.symlink_to(REPO / "hooks/block-hook-bypass.py") - links["elsewhere"] = elsewhere - settings = home / ".claude/settings.json" - settings.write_text(json.dumps({"hooks": {"SessionStart": [ - {"matcher": "", "hooks": [{"type": "command", "command": LEDGER_START}]}, - {"matcher": "", "hooks": [{"type": "command", "command": AUTO_RESUME}]}, - ]}})) - factory = home / ".factory/hooks.json" - factory.parent.mkdir(parents=True) - factory.write_text(json.dumps({"hooks": {"SessionStart": [ - {"matcher": "", "hooks": [{"type": "command", "command": LEDGER_START}]}, - ]}})) - return links - - -def test_apply_removes_the_retired_links_and_registrations(tmp_path, install): - links = _seed_previous_install(tmp_path) +def hooks(path): + return {event: [entry["command"] for block in blocks for entry in block["hooks"]] + for event, blocks in json.loads(path.read_text())["hooks"].items()} + +def test_fresh_apply_links_and_registers_the_session_hooks(tmp_path, install): result = install("--apply") - for relative in RETIRED: - assert not links[relative].is_symlink(), relative - assert f"REMOVE retired link {links[relative]}" in result.stdout - assert os.readlink(links["auto-resume"]) == str(REPO / "hooks/cmux-auto-resume.py") - assert os.readlink(links["foreign"]) == str(tmp_path / "dev/mukul-memory/hooks/cmux-registry.py") - assert os.readlink(links["elsewhere"]) == str(REPO / "hooks/block-hook-bypass.py") - settings = json.loads((tmp_path / ".claude/settings.json").read_text()) - assert settings["hooks"] == {"SessionStart": [ - {"matcher": "", "hooks": [{"type": "command", "command": AUTO_RESUME}]}]} - assert json.loads((tmp_path / ".factory/hooks.json").read_text())["hooks"] == {} + assert "[4/5] Wiring cmux session hooks (hooks + CLI + settings)" in result.stdout + for relative, target in LINKS.items(): + assert os.readlink(tmp_path / relative) == str(target), relative + for settings in (".claude/settings.json", ".factory/hooks.json"): + assert hooks(tmp_path / settings) == {"SessionStart": [AUTO_RESUME]}, settings - again = install("--apply") - assert "REMOVE retired link" not in again.stdout - assert not any(links[relative].is_symlink() for relative in RETIRED) +def test_apply_on_the_live_layout_drops_only_the_ledger(tmp_path, install): + (tmp_path / ".claude/hooks").mkdir(parents=True) + foreign = tmp_path / ".claude/hooks/cmux-registry.py" + foreign.symlink_to(tmp_path / "dev/mukul-memory/hooks/cmux-registry.py") + settings = tmp_path / ".claude/settings.json" + settings.write_text(json.dumps({"hooks": { + "SessionStart": [{"matcher": "", "hooks": [{"type": "command", "command": c}]} + for c in (REGISTRY, AUTO_RESUME, LEDGER_START)], + "SessionEnd": [{"matcher": "", "hooks": [{"type": "command", "command": LEDGER_END}]}], + }})) -def test_retired_link_into_another_checkout_is_kept(tmp_path, install): - other = tmp_path / "other-checkout/hooks/cmux-session-ledger.py" - link = tmp_path / ".claude/hooks/cmux-session-ledger.py" - link.parent.mkdir(parents=True) - link.symlink_to(other) + install("--apply") - result = install("--apply") + assert hooks(settings) == {"SessionStart": [REGISTRY, AUTO_RESUME]} + assert os.readlink(foreign) == str(tmp_path / "dev/mukul-memory/hooks/cmux-registry.py") + patched = settings.read_text() + backups = sorted(settings.parent.glob("settings.json.bak-*")) - assert os.readlink(link) == str(other) - assert "REMOVE retired link" not in result.stdout + again = install("--apply") + assert settings.read_text() == patched + assert sorted(settings.parent.glob("settings.json.bak-*")) == backups + assert "no changes needed" in again.stdout -def test_fresh_apply_links_the_auto_resume_hook(tmp_path, install): +def test_a_ledger_registration_left_by_the_pull_still_exits_cleanly(tmp_path, install): + # Between `git pull` and `install.sh --apply` the old registration still + # runs through the old link; the file it reaches must not be missing. install("--apply") - assert os.readlink(tmp_path / AUTO_RESUME_LINK) == str(REPO / "hooks/cmux-auto-resume.py") + result = subprocess.run( + ["bash", "-c", LEDGER_START], input='{"session_id": "sid"}', capture_output=True, + text=True, timeout=30, env={"HOME": str(tmp_path), "PATH": os.environ["PATH"]}) + assert (result.returncode, result.stdout, result.stderr) == (0, "", "") + assert not (tmp_path / ".cmux-session-ledger.jsonl").exists() -def test_dry_run_reports_without_removing(tmp_path, install): - links = _seed_previous_install(tmp_path) - settings = (tmp_path / ".claude/settings.json").read_text() +def test_dry_run_reports_without_patching(tmp_path, install): + settings = tmp_path / ".claude/settings.json" + settings.parent.mkdir(parents=True) + settings.write_text(json.dumps({"hooks": {"SessionEnd": [ + {"matcher": "", "hooks": [{"type": "command", "command": LEDGER_END}]}]}})) result = install() - for relative in RETIRED: - assert links[relative].is_symlink(), relative - assert f"REMOVE retired link {links[relative]}" in result.stdout - assert (tmp_path / ".claude/settings.json").read_text() == settings - assert "would install cmux Factory hooks and patch Claude + Factory lifecycle hooks" in result.stdout + assert "would install cmux Factory hooks and patch Claude + Factory session hooks" in result.stdout + assert hooks(settings) == {"SessionEnd": [LEDGER_END]} -def test_failed_settings_cleanup_warns_and_the_install_continues(tmp_path, install): +def test_failed_settings_patch_warns_and_the_install_continues(tmp_path, install): (tmp_path / ".claude/settings.json").mkdir(parents=True) result = install("--apply") - assert ("patch-settings failed (continuing — retired session hooks may still be registered)" + assert ("patch-settings failed (continuing — cmux session hooks may be incomplete)" in result.stderr) + assert hooks(tmp_path / ".factory/hooks.json") == {"SessionStart": [AUTO_RESUME]} assert "[5/5] Reloading launchd agents that changed" in result.stdout -def test_help_documents_native_cmux_sessions(tmp_path): +def test_help_documents_the_cmux_session_hooks(tmp_path): result = subprocess.run(["bash", str(INSTALL), "--help"], capture_output=True, text=True, env=installer_env(tmp_path), stdin=subprocess.DEVNULL, timeout=30) assert result.returncode == 0 - assert (" - cmux records agent sessions itself: hooks/cmux-auto-resume.py → ~/.claude/hooks/\n" - " for agents started outside cmux's wrapper, Factory hooks via cmux, and the\n" - " retired hand-written session hooks removed from Claude and Factory settings\n" - in result.stdout) - assert "What is NOT touched:\n - ~/.claude/cache/, ~/.claude/projects/\n" in result.stdout - for retired in ("cmux-registry", "cmux-session-ledger", "cmux-restore-sessions"): - assert retired not in result.stdout + assert (" - cmux session hooks: hooks/ → ~/.claude/hooks/ (symlinks),\n" + " bin/cmux-restore-sessions.py → ~/.local/bin/cmux-restore-sessions,\n" + " and Claude + Factory SessionStart patched to run cmux-auto-resume.py\n" + " (the retired ledger hook's registrations removed)\n" in result.stdout) diff --git a/tests/test_patch_settings.py b/tests/test_patch_settings.py index ad88717..77716c5 100644 --- a/tests/test_patch_settings.py +++ b/tests/test_patch_settings.py @@ -1,11 +1,13 @@ -"""install/patch-settings.py removes the retired cmux session hooks. - -cmux records agent sessions itself, so the installer unregisters the -hand-written cmux-session-ledger.py and cmux-registry.py hooks from Claude -settings and Factory hooks, and never adds them back. cmux-auto-resume.py stays -registered: it is what makes cmux record agents started without its wrapper. -Direct patch_path() tests pin what is removed and what survives; CLI tests run -main() over several paths, as install.sh does. +"""install/patch-settings.py — cmux session hook patcher. + +It registers hooks/cmux-auto-resume.py on SessionStart (Claude settings and +Factory hooks) when missing, removes the retired cmux-session-ledger.py +registrations, and never touches hooks other installers own, such as +mukul-memory's cmux-registry.py. Direct patch_path() tests pin what is added, +removed, and kept; CLI tests run main() over several paths, as install.sh does. +On a fresh machine ~/.claude/settings.json does not exist: the file is created +without a backup (an unconditional backup crashed a fresh install on +2026-07-07), and a second run changes nothing. """ from __future__ import annotations @@ -22,6 +24,8 @@ LEDGER_START = "python3 $HOME/.claude/hooks/cmux-session-ledger.py start" LEDGER_END = "python3 $HOME/.claude/hooks/cmux-session-ledger.py end" REGISTRY = "$HOME/.claude/hooks/cmux-registry.py" +AUTO_RESUME_BLOCK = {"matcher": "", "hooks": [ + {"type": "command", "command": AUTO_RESUME, "timeout": 10}]} def load_module(): @@ -48,9 +52,10 @@ def commands(document, event): def live_claude_settings(): - """The shape install.sh and mukul-memory's link-hooks left on a real machine.""" + """The shape this machine had on 2026-10-19 (install.sh plus mukul-memory).""" return { "permissions": {"allow": ["Read"]}, + "statusLine": {"command": "echo — status"}, "hooks": { "SessionStart": [ block(REGISTRY), @@ -70,47 +75,77 @@ def _run(*paths: Path): capture_output=True, text=True, timeout=30) -def test_removes_every_retired_hook_and_keeps_the_rest(tmp_path): +def test_live_settings_lose_the_ledger_and_keep_everything_else(tmp_path): module = load_module() settings = tmp_path / ".claude" / "settings.json" settings.parent.mkdir() - settings.write_text(json.dumps(live_claude_settings())) + settings.write_text(json.dumps(live_claude_settings(), ensure_ascii=False)) assert module.patch_path(settings) is True document = json.loads(settings.read_text()) assert document["permissions"] == {"allow": ["Read"]} assert document["hooks"]["SessionStart"] == [ + block(REGISTRY), {"matcher": "", "hooks": [hook(AUTO_RESUME, 10)]}, block("python3 $HOME/.claude/hooks/memory-boot.py"), ] assert "SessionEnd" not in document["hooks"] assert document["hooks"]["PreToolUse"] == [ block("python3 $HOME/.claude/hooks/block-hook-bypass.py", matcher="Bash")] + assert "echo — status" in settings.read_text() # not escaped to \\u2014 backups = list(settings.parent.glob("settings.json.bak-*")) assert len(backups) == 1 assert json.loads(backups[0].read_text()) == live_claude_settings() -def test_mixed_block_keeps_its_matcher_and_other_hooks(tmp_path): +def test_auto_resume_is_registered_when_missing(tmp_path): module = load_module() path = tmp_path / ".factory" / "hooks.json" path.parent.mkdir() path.write_text(json.dumps({"custom": True, "hooks": { - "SessionStart": [block(REGISTRY, "existing-factory", LEDGER_START, matcher="startup")], + "SessionStart": [block(LEDGER_START, "existing-factory", matcher="startup")], + "Stop": [block("keep-stop")], }})) assert module.patch_path(path) is True assert json.loads(path.read_text()) == {"custom": True, "hooks": { - "SessionStart": [block("existing-factory", matcher="startup")], + "SessionStart": [block("existing-factory", matcher="startup"), AUTO_RESUME_BLOCK], + "Stop": [block("keep-stop")], }} +def test_auto_resume_under_another_path_counts_as_registered(tmp_path): + module = load_module() + path = tmp_path / "settings.json" + path.write_text(json.dumps({"hooks": {"SessionStart": [ + block("/usr/bin/python3 /custom/hooks/cmux-auto-resume.py")]}})) + assert module.patch_path(path) is False + assert not list(tmp_path.glob("settings.json.bak-*")) + + +def test_only_the_retired_script_is_removed(tmp_path): + module = load_module() + kept = [ + REGISTRY, + "python3 $HOME/bin/not-cmux-session-ledger.py", + "python3 $HOME/.claude/hooks/cmux-session-ledger.py.disabled", + "echo cmux-session-ledger", + "cmux hooks claude session-start", + ] + path = tmp_path / "settings.json" + path.write_text(json.dumps({"hooks": {"SessionStart": [block(*kept), AUTO_RESUME_BLOCK]}})) + + assert module.patch_path(path) is False + assert commands(json.loads(path.read_text()), "SessionStart") == kept + [AUTO_RESUME] + + def test_leaves_untouched_events_and_blocks_alone(tmp_path): module = load_module() path = tmp_path / "settings.json" path.write_text(json.dumps({"hooks": { + "SessionStart": [AUTO_RESUME_BLOCK], "Stop": [], "Notification": [{"matcher": "", "hooks": []}, {"matcher": "x"}], "SessionEnd": [block(LEDGER_END)], @@ -119,36 +154,20 @@ def test_leaves_untouched_events_and_blocks_alone(tmp_path): assert module.patch_path(path) is True assert json.loads(path.read_text()) == {"hooks": { + "SessionStart": [AUTO_RESUME_BLOCK], "Stop": [], "Notification": [{"matcher": "", "hooks": []}, {"matcher": "x"}], }} -def test_only_the_retired_scripts_match(tmp_path): - module = load_module() - kept = [ - AUTO_RESUME, - "python3 $HOME/bin/not-cmux-registry.py", - "python3 $HOME/.claude/hooks/cmux-registry.py.disabled", - "echo cmux-session-ledger", - "cmux hooks claude session-start", - ] - path = tmp_path / "settings.json" - path.write_text(json.dumps({"hooks": {"SessionStart": [block(*kept)]}})) - - assert module.patch_path(path) is False - assert commands(json.loads(path.read_text()), "SessionStart") == kept - assert not list(tmp_path.glob("settings.json.bak-*")) - - def test_unexpected_shapes_are_skipped_not_fatal(tmp_path): module = load_module() path = tmp_path / "settings.json" path.write_text(json.dumps({"hooks": { "Stop": {"not": "a list"}, "Notification": ["not a block", {"matcher": "", "hooks": "not a list"}], - "PreToolUse": [{"matcher": "", "hooks": ["not an entry", hook(REGISTRY)]}], - "SessionEnd": [block(LEDGER_END)], + "PreToolUse": [{"matcher": "", "hooks": ["not an entry", hook(LEDGER_START)]}], + "SessionStart": ["not a block", AUTO_RESUME_BLOCK], }})) assert module.patch_path(path) is True @@ -157,41 +176,54 @@ def test_unexpected_shapes_are_skipped_not_fatal(tmp_path): "Stop": {"not": "a list"}, "Notification": ["not a block", {"matcher": "", "hooks": "not a list"}], "PreToolUse": [{"matcher": "", "hooks": ["not an entry"]}], + "SessionStart": ["not a block", AUTO_RESUME_BLOCK], }} -def test_second_run_changes_nothing(tmp_path, capsys): +def test_a_non_list_session_start_is_left_alone(tmp_path): module = load_module() path = tmp_path / "settings.json" - path.write_text(json.dumps(live_claude_settings())) - assert module.patch_path(path) is True - first = path.read_text() - + path.write_text(json.dumps({"hooks": {"SessionStart": {"odd": True}}})) assert module.patch_path(path) is False + assert json.loads(path.read_text()) == {"hooks": {"SessionStart": {"odd": True}}} - assert path.read_text() == first - assert len(list(tmp_path.glob("settings.json.bak-*"))) == 1 - assert capsys.readouterr().out.rstrip().endswith("no changes needed") +def test_a_non_object_hooks_value_is_left_alone(tmp_path): + module = load_module() + path = tmp_path / "settings.json" + path.write_text('{"hooks": []}') + assert module.patch_path(path) is False + assert path.read_text() == '{"hooks": []}' -def test_missing_file_is_not_created(tmp_path): + +def test_fresh_files_are_created_without_a_backup_and_stay_idempotent(tmp_path, capsys): module = load_module() path = tmp_path / ".factory" / "hooks.json" + + assert module.patch_path(path) is True + first = path.read_text() + assert json.loads(first) == {"hooks": {"SessionStart": [AUTO_RESUME_BLOCK]}} + assert not list(path.parent.glob("hooks.json.bak-*")) + assert module.patch_path(path) is False - assert not path.parent.exists() + assert path.read_text() == first + assert not list(path.parent.glob("hooks.json.bak-*")) + assert capsys.readouterr().out.rstrip().endswith("no changes needed") -def test_malformed_or_unexpected_documents_are_left_untouched(tmp_path): +def test_malformed_or_non_object_files_are_backed_up_and_rebuilt(tmp_path): module = load_module() - for name, text in [("corrupt.json", "{ not json"), ("list.json", "[]"), - ("no-hooks.json", '{"model": "opus"}'), - ("bad-hooks.json", '{"hooks": []}')]: - path = tmp_path / name - path.write_text(text) - assert module.patch_path(path) is False - assert path.read_text() == text - assert sorted(p.name for p in tmp_path.iterdir()) == [ - "bad-hooks.json", "corrupt.json", "list.json", "no-hooks.json"] + corrupt = tmp_path / "corrupt.json" + corrupt.write_text("{ not json") + assert module.patch_path(corrupt) is True + assert json.loads(corrupt.read_text()) == {"hooks": {"SessionStart": [AUTO_RESUME_BLOCK]}} + [saved] = tmp_path.glob("corrupt.json.corrupt-*") + assert saved.read_text() == "{ not json" + + listed = tmp_path / "list.json" + listed.write_text("[]") + assert module.patch_path(listed) is True + assert json.loads(listed.read_text()) == {"hooks": {"SessionStart": [AUTO_RESUME_BLOCK]}} def test_symlinked_target_is_written_through_not_severed(tmp_path): @@ -212,11 +244,11 @@ def test_symlinked_target_is_written_through_not_severed(tmp_path): assert link.is_symlink() assert Path(link).resolve() == real.resolve() assert json.loads(real.read_text()) == {"existing": "keep", "hooks": { - "SessionStart": [block("keep-me")]}} + "SessionStart": [block("keep-me"), AUTO_RESUME_BLOCK]}} assert len(list(real.parent.glob("settings.json.bak-*"))) == 1 -def test_cli_cleans_claude_and_factory_files_in_one_run(tmp_path): +def test_cli_patches_claude_and_creates_factory_hooks_in_one_run(tmp_path): claude = tmp_path / ".claude" / "settings.json" factory = tmp_path / ".factory" / "hooks.json" claude.parent.mkdir() @@ -225,17 +257,18 @@ def test_cli_cleans_claude_and_factory_files_in_one_run(tmp_path): result = _run(claude, factory) assert result.returncode == 0, result.stderr - assert f" - SessionStart: {REGISTRY}" in result.stdout + assert f" - SessionStart: {LEDGER_START}" in result.stdout assert f" - SessionEnd: {LEDGER_END}" in result.stdout - assert f"{factory} does not exist" in result.stdout - assert not factory.exists() + assert " = SessionStart already runs cmux-auto-resume.py" in result.stdout + assert f" + SessionStart: {AUTO_RESUME}" in result.stdout + assert json.loads(factory.read_text()) == {"hooks": {"SessionStart": [AUTO_RESUME_BLOCK]}} remaining = json.loads(claude.read_text()) assert commands(remaining, "SessionStart") == [ - AUTO_RESUME, "python3 $HOME/.claude/hooks/memory-boot.py"] + REGISTRY, AUTO_RESUME, "python3 $HOME/.claude/hooks/memory-boot.py"] assert "SessionEnd" not in remaining["hooks"] -def test_cli_cleans_the_other_paths_when_one_is_unreadable(tmp_path): +def test_cli_patches_the_other_paths_when_one_is_unreadable(tmp_path): unreadable = tmp_path / ".claude" / "settings.json" unreadable.mkdir(parents=True) factory = tmp_path / ".factory" / "hooks.json" @@ -245,8 +278,8 @@ def test_cli_cleans_the_other_paths_when_one_is_unreadable(tmp_path): result = _run(unreadable, factory) assert result.returncode == 1 - assert f"{unreadable} could not be cleaned:" in result.stderr - assert json.loads(factory.read_text()) == {"hooks": {}} + assert f"{unreadable} could not be patched:" in result.stderr + assert json.loads(factory.read_text()) == {"hooks": {"SessionStart": [AUTO_RESUME_BLOCK]}} def test_cli_requires_a_path(): diff --git a/tests/test_session_context_watcher.py b/tests/test_session_context_watcher.py index 7071f1b..95fee60 100644 --- a/tests/test_session_context_watcher.py +++ b/tests/test_session_context_watcher.py @@ -17,6 +17,7 @@ import json import os import select +import subprocess import sys import time from datetime import datetime, timedelta, timezone @@ -61,6 +62,7 @@ def tmp_home(tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() monkeypatch.setenv("CMUX_BIN", str(fake_cmux.install(home / "cmux-bin"))) + monkeypatch.setattr(scw, "CMUX_REGISTRY", home / ".claude/cmux-registry.json") orch_reg = home / ".architect/orchestrator-registry.json" out_path = home / ".claude/cache/session-context.json" world_path = home / ".claude/cache/world.json" @@ -274,6 +276,48 @@ def test_load_live_sessions_empty_identity_world_does_not_restore_records(tmp_ho assert scw.load_live_agent_sessions() == {} +@pytest.fixture +def agent_pid(tmp_path): + """The pid of a live process named `claude` (a renamed sleep).""" + impostor = tmp_path / "claude" + impostor.symlink_to("/bin/sleep") + process = subprocess.Popen([str(impostor), "60"]) + yield process.pid + process.kill() + process.wait() + + +def _write_registry(rows): + scw.CMUX_REGISTRY.parent.mkdir(parents=True, exist_ok=True) + scw.CMUX_REGISTRY.write_text(json.dumps(rows)) + + +def test_load_live_sessions_falls_back_to_the_registry_for_sessions_cmux_lacks( + tmp_home, agent_pid): + _write_sessions(tmp_home, [fake_cmux.record("S-CMUX", pid=os.getpid())]) + _write_registry({ + "WS-MISSED": {"session_id": "S-MISSED", "claude_pid": str(agent_pid), "cwd": "/m", + "surface_id": "SF-M", "ts": 50, "transcript_path": "/t/m.jsonl"}, + "WS-CMUX": {"session_id": "S-CMUX", "claude_pid": str(agent_pid), "cwd": "/c"}, + "WS-REUSED": {"session_id": "S-REUSED", "claude_pid": str(os.getpid())}, + }) + out = scw.load_live_agent_sessions() + assert set(out) == {"S-CMUX", "S-MISSED"} + assert out["S-CMUX"]["pid"] == os.getpid() + assert out["S-MISSED"] == { + "pid": agent_pid, "cwd": "/m", "transcript_path": "/t/m.jsonl", + "provider": "claude", "ts": 50.0, "tab_id": "WS-MISSED", + } + + +def test_identity_world_never_reads_the_registry(tmp_home, agent_pid): + _write_registry({"WS": {"session_id": "S-MISSED", "claude_pid": str(agent_pid)}}) + scw.WORLD_PATH.write_text(json.dumps({"live_sessions": [ + {"session_id": "S-WORLD", "pid": os.getpid(), "identity_status": "verified", + "workspace_id": "w", "surface_id": "s"}]})) + assert set(scw.load_live_agent_sessions()) == {"S-WORLD"} + + # ─── load_cron_workers ─────────────────────────────────────────────────────── def test_load_cron_workers_missing(tmp_home): diff --git a/tests/test_world_scanner.py b/tests/test_world_scanner.py index a56af3a..81dcc46 100644 --- a/tests/test_world_scanner.py +++ b/tests/test_world_scanner.py @@ -706,7 +706,11 @@ def test_record_without_live_binding_is_not_authoritative(ws, monkeypatch): assert world["workspaces"][0]["surfaces"][0]["session_id"] is None -def _seed_current_hook(ws, monkeypatch, *, changes=None, process_change=None, native=None): +def _seed_current_hook(ws, monkeypatch, *, changes=None, process_change=None, native=None, + registry=None, cmux=True): + """One live claude on ttys001 with no resume binding. cmux's record for it + gets `changes`; `cmux=False` drops that record. `registry` (row changes) + writes a cmux-registry.json row for the same session and surface.""" records, _, cmux_records, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) registered = ws.utc_now().timestamp() - 60 cmux_records[0].update({ @@ -717,6 +721,18 @@ def _seed_current_hook(ws, monkeypatch, *, changes=None, process_change=None, na "launch_working_directory": "/work/shared", }) cmux_records[0].update(changes or {}) + if not cmux: + cmux_records.clear() + if registry is not None: + row = { + "session_id": "session-1", "cwd": "/work/registry", + "surface_id": records[0]["surface_id"].upper(), + "tab_id": records[0]["workspace_id"].upper(), "claude_pid": "", + "ts": registered, "transcript_path": "/t/session-1.jsonl", + } + row.update(registry) + key = row.pop("key", records[0]["workspace_id"].upper()) + ws.CMUX_REGISTRY.write_text(json.dumps({key: row})) process = { "pid": 101, "pgid": 101, "tpgid": 101, "started": registered - 2, "command": "/Users/fixture/.local/bin/claude", @@ -729,6 +745,8 @@ def run(args, **kwargs): calls.append(args) if args[1:4] == ["surface", "resume", "show"]: return fake_completed(json.dumps({"binding": native})) + if args[:2] == ["ps", "-p"] and args[-1] == "comm=": + return fake_completed(process["command"] + "\n") if args[:2] == ["ps", "-t"] and "lstart" in args[-1]: assert kwargs["env"]["TZ"] == "UTC" started = datetime.fromtimestamp( @@ -785,6 +803,117 @@ def test_hook_fallback_rejects_stale_or_mismatched_process(ws, monkeypatch, chan assert not any(s["identity_status"] == "verified" for s in world["live_sessions"]) +def test_registry_fallback_verifies_a_session_cmux_never_recorded(ws, monkeypatch): + # Live ee4ee641 on 2026-10-19: started without cmux's wrapper, the forward to + # cmux failed, so only mukul-memory's registry hook recorded it. + calls = _seed_current_hook(ws, monkeypatch, cmux=False, registry={}) + ws.build() + world = json.loads(ws.OUT_PATH.read_text()) + session = world["live_sessions"][0] + assert (session["session_id"], session["pid"], session["identity_status"], + session["identity_source"], session["cwd"]) == ( + "session-1", 101, "verified", "session_start_registry", "/work/registry") + assert session["first_recorded_at"] == ws.iso(datetime.fromtimestamp( + ws.utc_now().timestamp() - 60, tz=timezone.utc)) + assert world["workspaces"][0]["session_ids"] == ["session-1"] + assert world["_meta"]["session_record_fallback"] == { + "source": str(ws.CMUX_REGISTRY), "hits": 1, "session_ids": ["session-1"]} + assert any(args[1:3] == ["sessions", "list"] for args in calls) + assert "fallback_hits=1" in (ws.LOG_DIR / "world-scanner.out").read_text() + + +@pytest.mark.parametrize("row", [ + {"ts": 1}, + {"claude_pid": "999"}, + {"surface_id": "different-surface"}, + {"key": "different-workspace", "tab_id": "different-workspace"}, +]) +def test_registry_fallback_rejects_stale_or_mismatched_rows(ws, monkeypatch, row): + _seed_current_hook(ws, monkeypatch, cmux=False, registry=row) + ws.build() + world = json.loads(ws.OUT_PATH.read_text()) + assert world["workspaces"][0]["session_ids"] == [] + assert not any(s["identity_status"] == "verified" for s in world["live_sessions"]) + # A row with a live agent pid is still reported, as an unverified session. + assert world["_meta"]["session_record_fallback"]["hits"] == (1 if "claude_pid" in row else 0) + + +def test_registry_row_for_a_session_cmux_recorded_is_not_a_fallback(ws, monkeypatch): + # cmux's record is stale (written before the process started); the registry + # row for the same session must not stand in for it. + _seed_current_hook(ws, monkeypatch, changes={"updated_at_unix": 1}, registry={}) + ws.build() + world = json.loads(ws.OUT_PATH.read_text()) + assert world["workspaces"][0]["session_ids"] == [] + assert world["_meta"]["session_record_fallback"]["hits"] == 0 + + +def test_registry_fallback_replaces_a_stale_cmux_record_for_another_session(ws, monkeypatch): + _seed_current_hook(ws, monkeypatch, changes={"session_id": "earlier", "updated_at_unix": 1}, + registry={}) + ws.build() + world = json.loads(ws.OUT_PATH.read_text()) + assert world["workspaces"][0]["session_ids"] == ["session-1"] + assert world["_meta"]["session_record_fallback"]["session_ids"] == ["session-1"] + + +def test_a_resume_binding_verification_is_not_a_fallback_hit(ws, monkeypatch): + records, _, cmux_records, _ = _seed_identity_world(ws, monkeypatch, ("claude",)) + cmux_records.clear() + ws.CMUX_REGISTRY.write_text(json.dumps({records[0]["workspace_id"].upper(): { + "session_id": "session-1", "surface_id": records[0]["surface_id"], "ts": 1}})) + ws.build() + world = json.loads(ws.OUT_PATH.read_text()) + assert world["live_sessions"][0]["identity_source"] == "cmux_resume_binding" + assert world["_meta"]["session_record_fallback"]["hits"] == 0 + + +def test_cmux_record_wins_over_a_registry_row_for_the_same_surface(ws, monkeypatch): + _seed_current_hook(ws, monkeypatch, registry={"session_id": "registry-session"}) + ws.build() + world = json.loads(ws.OUT_PATH.read_text()) + assert world["workspaces"][0]["session_ids"] == ["session-1"] + assert world["_meta"]["session_record_fallback"]["hits"] == 0 + + +def test_two_active_records_for_one_surface_verify_neither(ws, monkeypatch): + # cmux keeps one store per agent, so a claude and a factory record can both + # be active for a surface. Neither may verify it. + _seed_current_hook(ws, monkeypatch) + original_run = ws.subprocess.run + + def run(args, **kwargs): + result = original_run(args, **kwargs) + if args[1:3] == ["sessions", "list"]: + rows = json.loads(result.stdout)["sessions"] + rows.append({**rows[0], "session_id": "factory-twin", "agent": "factory"}) + return fake_completed(json.dumps({"sessions": rows})) + return result + + monkeypatch.setattr(ws.subprocess, "run", run) + ws.build() + world = json.loads(ws.OUT_PATH.read_text()) + assert world["workspaces"][0]["session_ids"] == [] + assert not any(s["identity_status"] == "verified" for s in world["live_sessions"]) + + +@pytest.mark.parametrize("command,kept", [ + ("/Users/fixture/.local/bin/claude", True), + ("/usr/bin/python3", False), +]) +def test_registry_row_with_a_live_pid_must_be_an_agent(ws, monkeypatch, command, kept): + # The registry outlives its sessions and macOS reuses pids: aea1111b's + # pid 2255 belonged to gmail.py on 2026-10-19. + _seed_current_hook(ws, monkeypatch, cmux=False, process_change={"command": command}, + registry={"claude_pid": "4242", "surface_id": "elsewhere"}) + ws.build() + world = json.loads(ws.OUT_PATH.read_text()) + sessions = [s for s in world["live_sessions"] if s["pid"] == 4242] + assert [(s["identity_status"], s["identity_source"]) for s in sessions] == ( + [("unknown", "registry")] if kept else []) + assert world["_meta"]["session_record_fallback"]["hits"] == (1 if kept else 0) + + @pytest.mark.parametrize("kind", ["claude", "copilot"]) def test_native_binding_conflict_never_uses_hook_fallback(ws, monkeypatch, kind): calls = _seed_current_hook(ws, monkeypatch, native={ diff --git a/tests/test_writers_in_process.py b/tests/test_writers_in_process.py index 57dd0d3..1c4ce76 100644 --- a/tests/test_writers_in_process.py +++ b/tests/test_writers_in_process.py @@ -431,6 +431,23 @@ def test_find_via_cmux_sessions_matches_the_launch_directory(self): self.assertEqual(self.mod.find_via_cmux_sessions("/x/sub"), "/moved.jsonl") self.assertIsNone(self.mod.find_via_cmux_sessions("/x/s")) + def test_find_via_cmux_sessions_falls_back_to_registry_history(self): + # cmux keeps a few dozen records; the registry also has older sessions. + fake_cmux.write(self.cmux_dir, [ + fake_cmux.record("in-cmux", cwd="/x", transcript_path="/cmux.jsonl", + updated_at_unix=100), + ]) + Path(self.mod.REGISTRY).write_text(json.dumps({ + "WS-OLD": {"session_id": "registry-only", "cwd": "/old", + "transcript_path": "/registry.jsonl", "ts": 50}, + "WS-NEW": {"session_id": "registry-newer", "cwd": "/x", + "transcript_path": "/registry-newer.jsonl", "ts": 200}, + "WS-DUP": {"session_id": "in-cmux", "cwd": "/x", + "transcript_path": "/stale-copy.jsonl", "ts": 900}, + })) + self.assertEqual(self.mod.find_via_cmux_sessions("/old"), "/registry.jsonl") + self.assertEqual(self.mod.find_via_cmux_sessions("/x"), "/registry-newer.jsonl") + def test_find_via_cmux_sessions_returns_none_when_no_match(self): fake_cmux.write(self.cmux_dir, []) self.assertIsNone(self.mod.find_via_cmux_sessions("/no/match"))