fix: DPLAN-0241 rounds 2-3 — Enter IS the takeover. Phantom claude-agents-stop removed (bg close honest, never SIGTERM), bg resume = daemon stop --any (returncode-checked, blast-radius y/N confirm) + --resume in tmux with bypass, ALL interactive launches tmux-wrapped, multi-session menu shows real names + explicit pick + honest new/close, real-binary CLI contract test tier (20 tests, phantom-subcommand class unshippable). 1025 hooks tests green, all facts live-verified vs claude 2.1.208. Plus DPLAN north-star: one conversation per branch, surfaces are views, agents bind to machine not interface.

This commit is contained in:
AIOSAI
2026-07-13 23:49:57 -07:00
parent 364cefa5fd
commit 0b739ac525
5 changed files with 692 additions and 102 deletions
+20
View File
@@ -13,6 +13,26 @@ PyPI version — not the changelog header.
### Fixed
- **DPLAN-0241 rounds 2-3: Enter IS the takeover — background chats reopen as
normal terminal chats.** Live incident round two (Patrick's laptop, 23:00): the
boot menu's resume for a background chat opened the `claude agents` viewer, which
dispatched his typed message as a brand-new bg job WITHOUT bypass permissions —
and the shipped stop path called `claude agents stop`, a subcommand that does not
exist (987 mocked tests never noticed). All fixed by @hooks across two rounds,
every CLI fact live-verified against claude 2.1.208: phantom stop removed
(bg close is now honest — no per-job stop exists in the CLI; SIGTERM never used
on bg, the daemon respawns it); Enter on a live bg session now takes the chat
over — `claude daemon stop --any` (returncode-checked, blast-radius listing +
y/N confirm when other branches' bg sessions would also stop) then `--resume
<sessionId>` inside tmux with bypass; ALL interactive launches tmux-wrapped so a
closed terminal is always recoverable; multi-session menu shows real session
names, requires an explicit pick, and its new/close paths stop-first honestly;
new real-binary CLI contract test tier (20 tests probing every claude
flag/subcommand our code invokes — the phantom-subcommand class is now
structurally unshippable). 1025 hooks tests green. North-star architecture
recorded from Patrick's rulings: one conversation per branch; TG/claude.ai/
terminal are views of it; agents bind to the machine, not the interface.
- **Session management overhaul (DPLAN-0241): one brain per branch, attach-first
boot menu, honest session listings.** Born from a live incident — Patrick locked
out of a running chat for an hour. Root causes, all fixed by @hooks: the bashrc
@@ -1,28 +1,36 @@
# =================== AIPass ====================
# Name: session_boot.py
# Version: 2.0.0
# Version: 4.0.0
# Description: Boot wrapper — attach-first menu for Claude Code sessions
# Branch: hooks
# Layer: apps/handlers/lifecycle
# Created: 2026-06-30
# Modified: 2026-07-13
# Modified: 2026-07-14
# =============================================
"""Boot wrapper for Claude Code sessions.
When Patrick runs `claude` in a branch directory, this wrapper presents a menu:
Live session exists:
devpulse — live chat: PID 773292 · c624cbcd · background · 2h old
Live session (interactive):
hooks — live chat: PID 1234 · abc12345 · interactive · 2h old
[Enter] resume this chat
[n] start new chat (closes the one above first)
[c] close it and exit
Live session (background):
devpulse — live chat: PID 773292 · c624cbcd · background "chroma review" · 2h old
[Enter] resume this chat (stops bg, reopens as normal chat)
[n] start new chat (stops bg first)
[c] close it and exit (stops bg)
No live session:
devpulse — no live chat
[Enter] continue last chat
[n] new chat
All interactive launches are tmux-wrapped (closed terminal = recoverable).
Special cases:
- Already inside tmux → execs claude directly (no nesting).
- Headless (-p flag) → execs claude directly.
@@ -148,7 +156,8 @@ def _format_age(session: dict) -> str:
if hours > 0:
return f"{hours}h{minutes}m"
return f"{minutes}m"
except Exception:
except Exception as exc:
logger.info("[SESSION_BOOT] age format error: %s", exc)
return ""
@@ -158,13 +167,15 @@ def _session_short_id(session: dict) -> str:
def _session_label(session: dict, branch: str) -> str:
"""Format a session's one-line label per P6: PID · short-id · kind · age."""
"""Format a session's one-line label: PID · short-id · kind [auto-name] · age."""
pid = session.get("pid", "?")
short_id = _session_short_id(session)
kind = session.get("kind", "unknown")
auto_name = session.get("name", "")
name_str = f' "{auto_name}"' if auto_name else ""
age = _format_age(session)
age_str = f" · {age} old" if age else ""
return f"PID {pid} · {short_id} · {kind}{age_str}"
return f"PID {pid} · {short_id} · {kind}{name_str}{age_str}"
def _read_choice(prompt: str = "> ") -> str:
@@ -176,55 +187,56 @@ def _read_choice(prompt: str = "> ") -> str:
choice = tty.readline().strip().lower()
tty.close()
return choice
except OSError:
except OSError as exc:
logger.info("[SESSION_BOOT] /dev/tty not available: %s", exc)
return ""
def _stop_session(session: dict, claude_bin: str) -> None:
"""Properly stop a session — never bare kill for bg jobs."""
def _stop_session(session: dict, claude_bin: str) -> str:
"""Stop a session. Returns description of action taken.
bg sessions: no per-job stop exists in the CLI. Returns an honest
message — never SIGTERMs bg (daemon respawns it).
"""
pid = session.get("pid")
kind = session.get("kind", "unknown")
if kind == "bg":
job_id = session.get("jobId", "")
if job_id:
try:
subprocess.run(
[claude_bin, "agents", "stop", job_id],
capture_output=True,
text=True,
timeout=10,
)
logger.info("[SESSION_BOOT] Stopped bg job %s (PID %d)", job_id, pid)
return
except (OSError, subprocess.TimeoutExpired) as exc:
logger.warning("[SESSION_BOOT] Failed to stop bg job %s: %s", job_id, exc)
if kind in ("bg", "background"):
logger.info("[SESSION_BOOT] Cannot stop bg PID %s — no per-job stop in CLI", pid)
return f"PID {pid}: bg session — no per-job stop available"
tmux_session = _find_tmux_session_for_pid(pid) if pid else None
if tmux_session:
subprocess.run(["tmux", "kill-session", "-t", tmux_session], check=False)
logger.info("[SESSION_BOOT] Killed tmux session '%s' (PID %d)", tmux_session, pid)
return
return f"PID {pid}: killed tmux session '{tmux_session}'"
if pid:
try:
os.kill(pid, signal.SIGTERM)
logger.info("[SESSION_BOOT] Sent SIGTERM to PID %d", pid)
return f"PID {pid}: sent SIGTERM"
except ProcessLookupError:
pass
logger.info("[SESSION_BOOT] PID %d already dead", pid)
return f"PID {pid}: already dead"
except OSError as exc:
logger.warning("[SESSION_BOOT] SIGTERM PID %d failed: %s", pid, exc)
return f"PID {pid}: SIGTERM failed ({exc})"
return f"PID {pid}: no action"
def _resume_session(session: dict, claude_bin: str, defaults: list[str]) -> dict:
"""Resume a session — right mechanism per kind."""
def _resume_session(session: dict, branch: str, claude_bin: str, defaults: list[str]) -> dict:
"""Resume a session — right mechanism per kind.
bg: takeover (daemon stop + --resume in tmux). Never opens agents view.
tmux: attach to existing tmux session.
dead-window: --continue in a new tmux session.
"""
pid = session.get("pid")
kind = session.get("kind", "unknown")
if kind == "bg":
cwd = session.get("cwd", "")
args = [claude_bin, "agents", "--cwd", cwd] if cwd else [claude_bin, "agents"]
logger.info("[SESSION_BOOT] Opening agents view for bg session PID %d", pid)
os.execvp(claude_bin, args)
return {"exit_code": 0, "action": "agents_view"}
if kind in ("bg", "background"):
return _takeover_bg(session, branch, claude_bin, defaults)
tmux_session = _find_tmux_session_for_pid(pid) if pid else None
if tmux_session:
@@ -233,8 +245,7 @@ def _resume_session(session: dict, claude_bin: str, defaults: list[str]) -> dict
return {"exit_code": 0, "action": "attached", "tmux_session": tmux_session}
logger.info("[SESSION_BOOT] Continuing dead-window session via --continue")
os.execvp(claude_bin, [claude_bin] + defaults + ["--continue"])
return {"exit_code": 0, "action": "continued"}
return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"])
def _make_session_name(branch: str, session_id: str = "") -> str:
@@ -245,6 +256,16 @@ def _make_session_name(branch: str, session_id: str = "") -> str:
return branch
def _exec_in_tmux(branch: str, session_id: str, claude_bin: str, claude_cmd: list[str]) -> dict:
"""Exec a claude command inside a new tmux session."""
session_name = _make_session_name(branch, session_id)
if _tmux_session_exists(session_name):
subprocess.run(["tmux", "kill-session", "-t", session_name], check=False)
logger.info("[SESSION_BOOT] Launching in tmux '%s': %s", session_name, " ".join(claude_cmd))
os.execvp("tmux", ["tmux", "new-session", "-s", session_name, "--"] + claude_cmd)
return {"exit_code": 0, "action": "started", "tmux_session": session_name}
def boot(cwd: str | None = None, extra_args: list[str] | None = None) -> dict:
"""Boot Claude Code — present menu when sessions exist.
@@ -288,6 +309,102 @@ def boot(cwd: str | None = None, extra_args: list[str] | None = None) -> dict:
return _menu_no_live(branch, claude_bin, defaults, extra_args)
def _has_bg(sessions: list[dict]) -> bool:
"""Check if any session is a background session."""
return any(s.get("kind") in ("bg", "background") for s in sessions)
def _get_collateral_bg(branch: str) -> list[dict]:
"""Find live bg sessions outside the given branch (blast-radius check)."""
import importlib
cc_sessions = importlib.import_module("aipass.hooks.apps.modules.cc_sessions")
collateral = []
for s in cc_sessions.read_all_sessions():
if s.get("kind") not in ("bg", "background"):
continue
s_branch = Path(s.get("cwd", "")).name
if s_branch != branch and s.get("pid") and cc_sessions._is_pid_alive(s["pid"]):
collateral.append(s)
return collateral
def _daemon_stop(claude_bin: str, branch: str, pid: int | None) -> dict:
"""Run daemon stop --any with blast-radius confirmation.
Returns {"ok": True} on success, {"ok": False, "error": "..."} on failure.
"""
collateral = _get_collateral_bg(branch)
if collateral:
sys.stderr.write(" Other branches have live bg sessions that will also stop:\n")
for s in collateral:
coll_branch = Path(s.get("cwd", "")).name
sys.stderr.write(f" PID {s.get('pid')} · {coll_branch} · {_session_short_id(s)}\n")
sys.stderr.write(" Continue? [y/N] ")
confirm = _read_choice("")
if confirm != "y":
return {"ok": False, "error": "cancelled by user"}
sys.stderr.write(" Stopping background sessions (daemon stop --any)...\n")
try:
result = subprocess.run(
[claude_bin, "daemon", "stop", "--any"],
capture_output=True,
text=True,
timeout=15,
)
if result.returncode != 0:
stderr_msg = result.stderr.strip()
logger.warning("[SESSION_BOOT] daemon stop exit %d: %s", result.returncode, stderr_msg)
sys.stderr.write(f" daemon stop failed (exit {result.returncode}): {stderr_msg}\n")
return {"ok": False, "error": f"daemon stop exit {result.returncode}: {stderr_msg}"}
except (OSError, subprocess.TimeoutExpired) as exc:
logger.warning("[SESSION_BOOT] daemon stop failed: %s", exc)
sys.stderr.write(f" daemon stop failed: {exc}\n")
return {"ok": False, "error": f"daemon stop failed: {exc}"}
import time
for _ in range(10):
time.sleep(1)
if not _is_session_file_present(pid):
break
else:
logger.warning("[SESSION_BOOT] Session file for PID %s did not clear after daemon stop", pid)
return {"ok": True}
def _takeover_bg(session: dict, branch: str, claude_bin: str, defaults: list[str]) -> dict:
"""Take over a bg session: daemon stop --any, poll, then --resume in tmux.
Checks blast radius first (other branches' bg sessions). On daemon stop
failure, aborts honestly. Resumes inside a tmux session so a closed
terminal is always recoverable.
"""
session_id = session.get("sessionId", "")
pid = session.get("pid")
stop_result = _daemon_stop(claude_bin, branch, pid)
if not stop_result["ok"]:
return {"exit_code": 1, "error": stop_result["error"]}
if session_id:
logger.info("[SESSION_BOOT] Resuming session %s after takeover", session_id[:8])
return _exec_in_tmux(branch, session_id, claude_bin, [claude_bin] + defaults + ["--resume", session_id])
logger.info("[SESSION_BOOT] No sessionId for takeover — continuing last")
return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"])
def _is_session_file_present(pid: int | None) -> bool:
"""Check if a CC session file exists for the given PID."""
if pid is None:
return False
session_file = Path.home() / ".claude" / "sessions" / f"{pid}.json"
return session_file.exists()
def _menu_live(
live: list[dict],
branch: str,
@@ -299,21 +416,38 @@ def _menu_live(
if len(live) == 1:
session = live[0]
label = _session_label(session, branch)
is_bg = session.get("kind") in ("bg", "background")
sys.stderr.write(f"\n{branch} — live chat: {label}\n")
sys.stderr.write(" [Enter] resume this chat\n")
sys.stderr.write(" [n] start new chat (closes the one above first)\n")
sys.stderr.write(" [c] close it and exit\n\n")
if is_bg:
sys.stderr.write(" [Enter] resume this chat (stops bg, reopens as normal chat)\n")
sys.stderr.write(" [n] start new chat (stops bg first)\n")
sys.stderr.write(" [c] close it and exit (stops bg)\n\n")
else:
sys.stderr.write(" [Enter] resume this chat\n")
sys.stderr.write(" [n] start new chat (closes the one above first)\n")
sys.stderr.write(" [c] close it and exit\n\n")
choice = _read_choice()
if choice in ("", "r"):
return _resume_session(session, claude_bin, defaults)
return _resume_session(session, branch, claude_bin, defaults)
elif choice == "n":
_stop_session(session, claude_bin)
if is_bg:
stop = _daemon_stop(claude_bin, branch, session.get("pid"))
if not stop["ok"]:
return {"exit_code": 1, "error": stop["error"]}
else:
_stop_session(session, claude_bin)
return _start_fresh(branch, claude_bin, defaults, extra_args)
elif choice == "c":
_stop_session(session, claude_bin)
sys.stderr.write(f" Closed {branch} session.\n")
if is_bg:
stop = _daemon_stop(claude_bin, branch, session.get("pid"))
if not stop["ok"]:
return {"exit_code": 1, "error": stop["error"]}
sys.stderr.write(f" Stopped bg session PID {session.get('pid')}.\n")
else:
result = _stop_session(session, claude_bin)
sys.stderr.write(f" {result}\n")
return {"exit_code": 0, "action": "closed"}
else:
sys.stderr.write(" Unknown choice. Exiting.\n")
@@ -323,30 +457,65 @@ def _menu_live(
for i, session in enumerate(live, 1):
label = _session_label(session, branch)
sys.stderr.write(f" [{i}] {label}\n")
sys.stderr.write(" [n] start new chat\n")
sys.stderr.write(" [c] close all and exit\n\n")
choice = _read_choice()
if choice == "c":
for session in live:
_stop_session(session, claude_bin)
sys.stderr.write(f" Closed all {branch} sessions.\n")
return {"exit_code": 0, "action": "closed_all"}
return _close_all(live, branch, claude_bin)
if choice == "n":
return _new_over_all(live, branch, claude_bin, defaults, extra_args)
try:
idx = int(choice) - 1
if 0 <= idx < len(live):
return _resume_session(live[idx], claude_bin, defaults)
return _resume_session(live[idx], branch, claude_bin, defaults)
except (ValueError, IndexError):
pass
logger.info("[SESSION_BOOT] Invalid menu choice: %r", choice)
if choice == "" and live:
return _resume_session(live[0], claude_bin, defaults)
sys.stderr.write(" Unknown choice. Exiting.\n")
sys.stderr.write(" Pick a number, 'n', or 'c'. Exiting.\n")
return {"exit_code": 1, "error": "unknown choice"}
def _close_all(live: list[dict], branch: str, claude_bin: str) -> dict:
"""Close all sessions — stop what's stoppable, honest about bg."""
non_bg = [s for s in live if s.get("kind") not in ("bg", "background")]
bg = [s for s in live if s.get("kind") in ("bg", "background")]
for s in non_bg:
result = _stop_session(s, claude_bin)
sys.stderr.write(f" {result}\n")
if bg:
stop = _daemon_stop(claude_bin, branch, bg[0].get("pid"))
if stop["ok"]:
sys.stderr.write(f" Stopped {len(bg)} bg session(s) via daemon stop.\n")
else:
for s in bg:
sys.stderr.write(f" PID {s.get('pid')}: bg session remains (use Enter to take over)\n")
return {"exit_code": 0, "action": "closed_all"}
def _new_over_all(
live: list[dict],
branch: str,
claude_bin: str,
defaults: list[str],
extra_args: list[str] | None,
) -> dict:
"""Start new chat, stopping what's stoppable first."""
non_bg = [s for s in live if s.get("kind") not in ("bg", "background")]
bg = [s for s in live if s.get("kind") in ("bg", "background")]
for s in non_bg:
result = _stop_session(s, claude_bin)
sys.stderr.write(f" {result}\n")
if bg:
stop = _daemon_stop(claude_bin, branch, bg[0].get("pid"))
if not stop["ok"]:
sys.stderr.write(f" {len(bg)} bg session(s) remain — starting new chat anyway\n")
return _start_fresh(branch, claude_bin, defaults, extra_args)
def _menu_no_live(
branch: str,
claude_bin: str,
@@ -362,8 +531,7 @@ def _menu_no_live(
if choice in ("", "r"):
logger.info("[SESSION_BOOT] Continuing last chat via --continue")
os.execvp(claude_bin, [claude_bin] + defaults + ["--continue"])
return {"exit_code": 0, "action": "continued"}
return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"])
elif choice == "n":
return _start_fresh(branch, claude_bin, defaults, extra_args)
else:
+12 -28
View File
@@ -1,11 +1,11 @@
# =================== AIPass ====================
# Name: cc_sessions.py
# Version: 2.0.0
# Version: 3.0.0
# Description: CC-native session discovery, listing, and reclaim
# Branch: hooks
# Layer: apps/modules
# Created: 2026-06-30
# Modified: 2026-07-13
# Modified: 2026-07-14
# =============================================
"""Read Claude Code native session files (~/.claude/sessions/<pid>.json).
@@ -24,7 +24,6 @@ Exposed as `drone @hooks sessions` (list) and
import json
import os
import signal
import subprocess
import sys
from pathlib import Path
@@ -111,7 +110,8 @@ def _format_age(session: dict) -> str:
if hours > 0:
return f"{hours}h{minutes}m"
return f"{minutes}m"
except Exception:
except Exception as exc:
logger.info("[CC_SESSIONS] age format error: %s", exc)
return "?"
@@ -183,34 +183,18 @@ def find_occupant(cwd: str, exclude_pid: int | None = None) -> dict | None:
def _stop_session(session: dict) -> str:
"""Properly stop a session. Returns description of action taken."""
"""Stop a session. Returns description of action taken.
bg sessions: no per-job stop exists in the CLI. Returns an honest
message — never SIGTERMs bg (daemon respawns it).
"""
pid = session.get("pid")
kind = session.get("kind", "unknown")
branch = _session_branch(session)
if kind == "bg":
job_id = session.get("jobId", "")
if job_id:
try:
result = subprocess.run(
["claude", "agents", "stop", job_id],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
return f"PID {pid} ({branch}): stopped bg job {job_id}"
except (OSError, subprocess.TimeoutExpired) as exc:
logger.warning("[CC_SESSIONS] Failed to stop bg job %s: %s", job_id, exc)
if pid and _is_pid_alive(pid):
try:
os.kill(pid, signal.SIGTERM)
except OSError as exc:
logger.warning("[CC_SESSIONS] Failed to SIGTERM PID %d: %s", pid, exc)
return f"PID {pid} ({branch}): SIGTERM failed ({exc})"
return f"PID {pid} ({branch}): sent SIGTERM to bg session (no jobId for proper stop)"
return f"PID {pid} ({branch}): already dead"
if kind in ("bg", "background"):
logger.info("[CC_SESSIONS] Cannot stop bg PID %s — no per-job stop in CLI", pid)
return f"PID {pid} ({branch}): bg session — no per-job stop available"
if pid and _is_pid_alive(pid):
try:
@@ -0,0 +1,97 @@
"""CLI contract tests — verify flags/subcommands our code invokes actually exist.
Probes `claude --help` and `claude agents --help` at test time. Skips cleanly
when the binary is absent. Catches phantom subcommands (like the former
`claude agents stop`) before they ship as mocked-green.
"""
import shutil
import subprocess
import pytest
_CLAUDE = shutil.which("claude")
_SKIP = pytest.mark.skipif(_CLAUDE is None, reason="claude binary not on PATH")
def _help_text(args: list[str]) -> str:
assert _CLAUDE is not None
result = subprocess.run(
[_CLAUDE, *args, "--help"],
capture_output=True,
text=True,
timeout=10,
)
return result.stdout + result.stderr
def _get_main_help() -> str:
return _help_text([])
def _get_agents_help() -> str:
return _help_text(["agents"])
def _get_daemon_help() -> str:
return _help_text(["daemon"])
@_SKIP
class TestClaudeMainFlags:
"""Flags from `claude --help` that session_boot invokes."""
def test_permission_mode(self):
assert "--permission-mode" in _get_main_help()
def test_continue(self):
assert "--continue" in _get_main_help()
def test_resume(self):
assert "--resume" in _get_main_help()
def test_p_flag(self):
h = _get_main_help()
assert "-p" in h or "--print" in h
@_SKIP
class TestClaudeAgentsFlags:
"""Flags from `claude agents --help` that session_boot invokes."""
def test_permission_mode(self):
assert "--permission-mode" in _get_agents_help()
def test_cwd(self):
assert "--cwd" in _get_agents_help()
def test_no_stop_subcommand(self):
h = _get_agents_help()
assert "stop" not in h.lower() or "agents stop" not in h.lower()
@_SKIP
class TestClaudeDaemonFlags:
"""Flags from `claude daemon --help` that session_boot invokes."""
def test_stop_subcommand(self):
assert "stop" in _get_daemon_help()
def test_any_flag(self):
assert "--any" in _get_daemon_help()
@_SKIP
class TestAgentsStopDoesNotExist:
"""Regression: `claude agents stop <id>` must NOT be a valid command."""
def test_agents_rejects_stop_arg(self):
assert _CLAUDE is not None
result = subprocess.run(
[_CLAUDE, "agents", "stop", "test-id"],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode != 0
assert "too many arguments" in result.stderr.lower() or "error" in result.stderr.lower()
+340 -19
View File
@@ -190,7 +190,7 @@ class TestBoot:
args = mock_exec.call_args[0][1]
assert "--continue" in args
def test_live_session_resume_bg_opens_agents(self, tmp_path):
def test_live_session_resume_bg_does_takeover(self, tmp_path):
live = [{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "bg"}]
with (
patch.dict("os.environ", {}, clear=True),
@@ -198,11 +198,13 @@ class TestBoot:
patch.object(session_boot, "_find_tmux", return_value="/usr/bin/tmux"),
patch.object(session_boot, "_find_live_sessions", return_value=live),
patch.object(session_boot, "_read_choice", return_value=""),
patch(f"{_MOD}.os.execvp") as mock_exec,
patch.object(
session_boot, "_takeover_bg", return_value={"exit_code": 0, "action": "takeover"}
) as mock_take,
):
session_boot.boot(cwd=str(tmp_path))
args = mock_exec.call_args[0][1]
assert "agents" in args
result = session_boot.boot(cwd=str(tmp_path))
mock_take.assert_called_once()
assert result["action"] == "takeover"
def test_live_session_new_stops_old(self, tmp_path):
live = [{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "interactive"}]
@@ -293,14 +295,21 @@ class TestBoot:
class TestStopSession:
def test_bg_job_uses_agents_stop(self):
session = {"pid": 1234, "kind": "bg", "jobId": "job-abc"}
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run:
def test_bg_returns_honest_no_stop(self):
session = {"pid": 1234, "kind": "bg"}
result = session_boot._stop_session(session, "/usr/local/bin/claude")
assert "no per-job stop" in result
def test_bg_background_kind_also_honest(self):
session = {"pid": 1234, "kind": "background"}
result = session_boot._stop_session(session, "/usr/local/bin/claude")
assert "no per-job stop" in result
def test_bg_never_sigterms(self):
session = {"pid": 1234, "kind": "bg"}
with patch(f"{_MOD}.os.kill") as mock_kill:
session_boot._stop_session(session, "/usr/local/bin/claude")
cmd = mock_run.call_args[0][0]
assert "agents" in cmd
assert "stop" in cmd
assert "job-abc" in cmd
mock_kill.assert_not_called()
def test_tmux_session_killed(self):
session = {"pid": 1234, "kind": "interactive"}
@@ -308,9 +317,10 @@ class TestStopSession:
patch.object(session_boot, "_find_tmux_session_for_pid", return_value="hooks"),
patch(f"{_MOD}.subprocess.run") as mock_run,
):
session_boot._stop_session(session, "/usr/local/bin/claude")
result = session_boot._stop_session(session, "/usr/local/bin/claude")
kill_calls = [c for c in mock_run.call_args_list if "kill-session" in str(c)]
assert len(kill_calls) == 1
assert "tmux" in result
def test_plain_session_sigterm(self):
session = {"pid": 1234, "kind": "interactive"}
@@ -318,8 +328,18 @@ class TestStopSession:
patch.object(session_boot, "_find_tmux_session_for_pid", return_value=None),
patch(f"{_MOD}.os.kill") as mock_kill,
):
session_boot._stop_session(session, "/usr/local/bin/claude")
result = session_boot._stop_session(session, "/usr/local/bin/claude")
mock_kill.assert_called_once()
assert "SIGTERM" in result
def test_plain_session_already_dead(self):
session = {"pid": 1234, "kind": "interactive"}
with (
patch.object(session_boot, "_find_tmux_session_for_pid", return_value=None),
patch(f"{_MOD}.os.kill", side_effect=ProcessLookupError),
):
result = session_boot._stop_session(session, "/usr/local/bin/claude")
assert "already dead" in result
class TestMain:
@@ -476,13 +496,31 @@ class TestMultipleLiveSessions:
patch.object(session_boot, "_find_tmux", return_value="/usr/bin/tmux"),
patch.object(session_boot, "_find_live_sessions", return_value=live),
patch.object(session_boot, "_read_choice", return_value="c"),
patch.object(session_boot, "_stop_session") as mock_stop,
patch.object(session_boot, "_stop_session", return_value="stopped") as mock_stop,
patch.object(session_boot, "_daemon_stop", return_value={"ok": True}),
):
result = session_boot.boot(cwd=str(tmp_path))
assert result["action"] == "closed_all"
assert mock_stop.call_count == 2
mock_stop.assert_called_once()
def test_pick_by_number(self, tmp_path):
def test_pick_by_number_interactive(self, tmp_path):
live = [
{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "interactive"},
{"pid": 5678, "sessionId": "def", "cwd": str(tmp_path), "kind": "interactive"},
]
with (
patch.dict("os.environ", {}, clear=True),
patch.object(session_boot, "_resolve_claude_binary", return_value="/usr/local/bin/claude"),
patch.object(session_boot, "_find_tmux", return_value="/usr/bin/tmux"),
patch.object(session_boot, "_find_live_sessions", return_value=live),
patch.object(session_boot, "_read_choice", return_value="1"),
patch.object(session_boot, "_find_tmux_session_for_pid", return_value="hooks"),
patch(f"{_MOD}.os.execvp") as mock_exec,
):
session_boot.boot(cwd=str(tmp_path))
mock_exec.assert_called_once_with("tmux", ["tmux", "attach-session", "-t", "hooks"])
def test_pick_bg_triggers_takeover(self, tmp_path):
live = [
{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "interactive"},
{"pid": 5678, "sessionId": "def", "cwd": str(tmp_path), "kind": "bg"},
@@ -493,9 +531,292 @@ class TestMultipleLiveSessions:
patch.object(session_boot, "_find_tmux", return_value="/usr/bin/tmux"),
patch.object(session_boot, "_find_live_sessions", return_value=live),
patch.object(session_boot, "_read_choice", return_value="2"),
patch.object(session_boot, "_find_tmux_session_for_pid", return_value=None),
patch.object(
session_boot, "_takeover_bg", return_value={"exit_code": 0, "action": "takeover"}
) as mock_take,
):
result = session_boot.boot(cwd=str(tmp_path))
mock_take.assert_called_once()
assert result["action"] == "takeover"
def test_enter_without_pick_rejected(self, tmp_path):
live = [
{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "interactive"},
{"pid": 5678, "sessionId": "def", "cwd": str(tmp_path), "kind": "bg"},
]
with (
patch.dict("os.environ", {}, clear=True),
patch.object(session_boot, "_resolve_claude_binary", return_value="/usr/local/bin/claude"),
patch.object(session_boot, "_find_tmux", return_value="/usr/bin/tmux"),
patch.object(session_boot, "_find_live_sessions", return_value=live),
patch.object(session_boot, "_read_choice", return_value=""),
):
result = session_boot.boot(cwd=str(tmp_path))
assert result["exit_code"] == 1
def test_n_stops_stoppable_then_starts(self, tmp_path):
live = [
{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "interactive"},
{"pid": 5678, "sessionId": "def", "cwd": str(tmp_path), "kind": "bg"},
]
with (
patch.dict("os.environ", {}, clear=True),
patch.object(session_boot, "_resolve_claude_binary", return_value="/usr/local/bin/claude"),
patch.object(session_boot, "_find_tmux", return_value="/usr/bin/tmux"),
patch.object(session_boot, "_find_live_sessions", return_value=live),
patch.object(session_boot, "_read_choice", return_value="n"),
patch.object(session_boot, "_stop_session", return_value="stopped") as mock_stop,
patch.object(session_boot, "_daemon_stop", return_value={"ok": True}),
patch.object(session_boot, "_tmux_session_exists", return_value=False),
patch(f"{_MOD}.os.execvp") as mock_exec,
):
session_boot.boot(cwd=str(tmp_path))
mock_stop.assert_called_once()
args = mock_exec.call_args[0]
assert args[0] == "tmux"
assert "new-session" in args[1]
class TestTakeover:
def test_takeover_bg_runs_daemon_stop(self, tmp_path):
session = {
"pid": 1234,
"sessionId": "abc12345-full-uuid",
"cwd": str(tmp_path),
"kind": "bg",
}
with (
patch.object(session_boot, "_daemon_stop", return_value={"ok": True}) as mock_daemon,
patch.object(session_boot, "_tmux_session_exists", return_value=False),
patch(f"{_MOD}.os.execvp") as mock_exec,
):
session_boot._takeover_bg(
session, "hooks", "/usr/local/bin/claude", ["--permission-mode", "bypassPermissions"]
)
mock_daemon.assert_called_once()
args = mock_exec.call_args[0][1]
assert "agents" in args
assert "--resume" in args
assert "abc12345-full-uuid" in args
assert "new-session" in args
def test_takeover_bg_no_session_id_continues(self, tmp_path):
session = {"pid": 1234, "sessionId": "", "cwd": str(tmp_path), "kind": "bg"}
with (
patch.object(session_boot, "_daemon_stop", return_value={"ok": True}),
patch.object(session_boot, "_tmux_session_exists", return_value=False),
patch(f"{_MOD}.os.execvp") as mock_exec,
):
session_boot._takeover_bg(
session, "hooks", "/usr/local/bin/claude", ["--permission-mode", "bypassPermissions"]
)
args = mock_exec.call_args[0][1]
assert "--continue" in args
def test_takeover_daemon_stop_failure(self, tmp_path):
session = {"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "bg"}
with patch.object(
session_boot, "_daemon_stop", return_value={"ok": False, "error": "daemon stop failed: no claude"}
):
result = session_boot._takeover_bg(session, "hooks", "/usr/local/bin/claude", [])
assert result["exit_code"] == 1
assert "daemon stop failed" in result["error"]
def test_takeover_nonzero_returncode_aborts(self, tmp_path):
session = {"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "bg"}
with patch.object(
session_boot,
"_daemon_stop",
return_value={"ok": False, "error": "daemon stop exit 1: something failed"},
):
result = session_boot._takeover_bg(session, "hooks", "/usr/local/bin/claude", [])
assert result["exit_code"] == 1
def test_single_bg_enter_is_takeover(self, tmp_path):
live = [{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "bg"}]
with (
patch.dict("os.environ", {}, clear=True),
patch.object(session_boot, "_resolve_claude_binary", return_value="/usr/local/bin/claude"),
patch.object(session_boot, "_find_tmux", return_value="/usr/bin/tmux"),
patch.object(session_boot, "_find_live_sessions", return_value=live),
patch.object(session_boot, "_read_choice", return_value=""),
patch.object(
session_boot, "_takeover_bg", return_value={"exit_code": 0, "action": "takeover"}
) as mock_take,
):
result = session_boot.boot(cwd=str(tmp_path))
mock_take.assert_called_once()
assert result["action"] == "takeover"
def test_single_bg_n_stops_then_fresh(self, tmp_path):
live = [{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "bg"}]
with (
patch.dict("os.environ", {}, clear=True),
patch.object(session_boot, "_resolve_claude_binary", return_value="/usr/local/bin/claude"),
patch.object(session_boot, "_find_tmux", return_value="/usr/bin/tmux"),
patch.object(session_boot, "_find_live_sessions", return_value=live),
patch.object(session_boot, "_read_choice", return_value="n"),
patch.object(session_boot, "_daemon_stop", return_value={"ok": True}),
patch.object(session_boot, "_tmux_session_exists", return_value=False),
patch(f"{_MOD}.os.execvp") as mock_exec,
):
session_boot.boot(cwd=str(tmp_path))
args = mock_exec.call_args[0]
assert args[0] == "tmux"
assert "new-session" in args[1]
def test_single_bg_c_stops(self, tmp_path):
live = [{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "bg"}]
with (
patch.dict("os.environ", {}, clear=True),
patch.object(session_boot, "_resolve_claude_binary", return_value="/usr/local/bin/claude"),
patch.object(session_boot, "_find_tmux", return_value="/usr/bin/tmux"),
patch.object(session_boot, "_find_live_sessions", return_value=live),
patch.object(session_boot, "_read_choice", return_value="c"),
patch.object(session_boot, "_daemon_stop", return_value={"ok": True}),
):
result = session_boot.boot(cwd=str(tmp_path))
assert result["action"] == "closed"
class TestBgResume:
def test_bg_resume_routes_to_takeover(self, tmp_path):
session = {"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "bg"}
with patch.object(
session_boot, "_takeover_bg", return_value={"exit_code": 0, "action": "takeover"}
) as mock_take:
result = session_boot._resume_session(
session, "hooks", "/usr/local/bin/claude", ["--permission-mode", "bypassPermissions"]
)
mock_take.assert_called_once()
assert result["action"] == "takeover"
def test_bg_resume_never_opens_agents_view(self, tmp_path):
session = {"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "bg"}
with (
patch.object(session_boot, "_daemon_stop", return_value={"ok": True}),
patch.object(session_boot, "_tmux_session_exists", return_value=False),
patch(f"{_MOD}.os.execvp") as mock_exec,
):
session_boot._resume_session(
session, "hooks", "/usr/local/bin/claude", ["--permission-mode", "bypassPermissions"]
)
args = mock_exec.call_args[0][1]
assert "agents" not in args
class TestSessionLabelAutoName:
def test_bg_label_includes_auto_name(self):
session = {
"pid": 1234,
"sessionId": "abc12345",
"kind": "bg",
"name": "chroma review",
}
label = session_boot._session_label(session, "hooks")
assert '"chroma review"' in label
assert "abc12345" in label
def test_interactive_label_no_name(self):
session = {"pid": 1234, "sessionId": "abc12345", "kind": "interactive"}
label = session_boot._session_label(session, "hooks")
assert '"' not in label
def test_bg_label_no_name_field(self):
session = {"pid": 1234, "sessionId": "abc12345", "kind": "bg"}
label = session_boot._session_label(session, "hooks")
assert '"' not in label
class TestDaemonStop:
def test_success_no_collateral(self):
with (
patch.object(session_boot, "_get_collateral_bg", return_value=[]),
patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)),
patch.object(session_boot, "_is_session_file_present", return_value=False),
):
result = session_boot._daemon_stop("/usr/local/bin/claude", "hooks", 1234)
assert result["ok"] is True
def test_nonzero_returncode_fails(self):
with (
patch.object(session_boot, "_get_collateral_bg", return_value=[]),
patch(
f"{_MOD}.subprocess.run",
return_value=MagicMock(returncode=1, stderr="something broke"),
),
):
result = session_boot._daemon_stop("/usr/local/bin/claude", "hooks", 1234)
assert result["ok"] is False
assert "exit 1" in result["error"]
def test_oserror_fails(self):
with (
patch.object(session_boot, "_get_collateral_bg", return_value=[]),
patch(f"{_MOD}.subprocess.run", side_effect=OSError("no binary")),
):
result = session_boot._daemon_stop("/usr/local/bin/claude", "hooks", 1234)
assert result["ok"] is False
def test_collateral_confirmed_proceeds(self):
collateral = [{"pid": 9999, "cwd": "/tmp/other", "sessionId": "xyz"}]
with (
patch.object(session_boot, "_get_collateral_bg", return_value=collateral),
patch.object(session_boot, "_read_choice", return_value="y"),
patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)),
patch.object(session_boot, "_is_session_file_present", return_value=False),
):
result = session_boot._daemon_stop("/usr/local/bin/claude", "hooks", 1234)
assert result["ok"] is True
def test_collateral_denied_cancels(self):
collateral = [{"pid": 9999, "cwd": "/tmp/other", "sessionId": "xyz"}]
with (
patch.object(session_boot, "_get_collateral_bg", return_value=collateral),
patch.object(session_boot, "_read_choice", return_value="n"),
):
result = session_boot._daemon_stop("/usr/local/bin/claude", "hooks", 1234)
assert result["ok"] is False
assert "cancelled" in result["error"]
class TestExecInTmux:
def test_wraps_in_tmux_session(self):
with (
patch.object(session_boot, "_tmux_session_exists", return_value=False),
patch(f"{_MOD}.os.execvp") as mock_exec,
):
session_boot._exec_in_tmux(
"hooks", "abc12345", "/usr/local/bin/claude", ["/usr/local/bin/claude", "--continue"]
)
args = mock_exec.call_args[0]
assert args[0] == "tmux"
assert "new-session" in args[1]
assert "-s" in args[1]
assert "hooks-abc12345" in args[1]
assert "/usr/local/bin/claude" in args[1]
def test_kills_stale_tmux_first(self):
with (
patch.object(session_boot, "_tmux_session_exists", return_value=True),
patch(f"{_MOD}.subprocess.run") as mock_run,
patch(f"{_MOD}.os.execvp"),
):
session_boot._exec_in_tmux("hooks", "", "/usr/local/bin/claude", ["/usr/local/bin/claude"])
kill_calls = [c for c in mock_run.call_args_list if "kill-session" in str(c)]
assert len(kill_calls) == 1
class TestIsSessionFilePresent:
def test_present(self, tmp_path):
sessions_dir = tmp_path / ".claude" / "sessions"
sessions_dir.mkdir(parents=True)
(sessions_dir / "1234.json").write_text("{}")
with patch.object(session_boot.Path, "home", return_value=tmp_path):
assert session_boot._is_session_file_present(1234) is True
def test_absent(self, tmp_path):
with patch.object(session_boot.Path, "home", return_value=tmp_path):
assert session_boot._is_session_file_present(1234) is False
def test_none_pid(self):
assert session_boot._is_session_file_present(None) is False