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:
@@ -13,6 +13,26 @@ PyPI version — not the changelog header.
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- **Session management overhaul (DPLAN-0241): one brain per branch, attach-first
|
||||||
boot menu, honest session listings.** Born from a live incident — Patrick locked
|
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
|
out of a running chat for an hour. Root causes, all fixed by @hooks: the bashrc
|
||||||
|
|||||||
@@ -1,28 +1,36 @@
|
|||||||
# =================== AIPass ====================
|
# =================== AIPass ====================
|
||||||
# Name: session_boot.py
|
# Name: session_boot.py
|
||||||
# Version: 2.0.0
|
# Version: 4.0.0
|
||||||
# Description: Boot wrapper — attach-first menu for Claude Code sessions
|
# Description: Boot wrapper — attach-first menu for Claude Code sessions
|
||||||
# Branch: hooks
|
# Branch: hooks
|
||||||
# Layer: apps/handlers/lifecycle
|
# Layer: apps/handlers/lifecycle
|
||||||
# Created: 2026-06-30
|
# Created: 2026-06-30
|
||||||
# Modified: 2026-07-13
|
# Modified: 2026-07-14
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
"""Boot wrapper for Claude Code sessions.
|
"""Boot wrapper for Claude Code sessions.
|
||||||
|
|
||||||
When Patrick runs `claude` in a branch directory, this wrapper presents a menu:
|
When Patrick runs `claude` in a branch directory, this wrapper presents a menu:
|
||||||
|
|
||||||
Live session exists:
|
Live session (interactive):
|
||||||
devpulse — live chat: PID 773292 · c624cbcd · background · 2h old
|
hooks — live chat: PID 1234 · abc12345 · interactive · 2h old
|
||||||
[Enter] resume this chat
|
[Enter] resume this chat
|
||||||
[n] start new chat (closes the one above first)
|
[n] start new chat (closes the one above first)
|
||||||
[c] close it and exit
|
[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:
|
No live session:
|
||||||
devpulse — no live chat
|
devpulse — no live chat
|
||||||
[Enter] continue last chat
|
[Enter] continue last chat
|
||||||
[n] new chat
|
[n] new chat
|
||||||
|
|
||||||
|
All interactive launches are tmux-wrapped (closed terminal = recoverable).
|
||||||
|
|
||||||
Special cases:
|
Special cases:
|
||||||
- Already inside tmux → execs claude directly (no nesting).
|
- Already inside tmux → execs claude directly (no nesting).
|
||||||
- Headless (-p flag) → execs claude directly.
|
- Headless (-p flag) → execs claude directly.
|
||||||
@@ -148,7 +156,8 @@ def _format_age(session: dict) -> str:
|
|||||||
if hours > 0:
|
if hours > 0:
|
||||||
return f"{hours}h{minutes}m"
|
return f"{hours}h{minutes}m"
|
||||||
return f"{minutes}m"
|
return f"{minutes}m"
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.info("[SESSION_BOOT] age format error: %s", exc)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -158,13 +167,15 @@ def _session_short_id(session: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _session_label(session: dict, branch: str) -> 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", "?")
|
pid = session.get("pid", "?")
|
||||||
short_id = _session_short_id(session)
|
short_id = _session_short_id(session)
|
||||||
kind = session.get("kind", "unknown")
|
kind = session.get("kind", "unknown")
|
||||||
|
auto_name = session.get("name", "")
|
||||||
|
name_str = f' "{auto_name}"' if auto_name else ""
|
||||||
age = _format_age(session)
|
age = _format_age(session)
|
||||||
age_str = f" · {age} old" if age else ""
|
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:
|
def _read_choice(prompt: str = "> ") -> str:
|
||||||
@@ -176,55 +187,56 @@ def _read_choice(prompt: str = "> ") -> str:
|
|||||||
choice = tty.readline().strip().lower()
|
choice = tty.readline().strip().lower()
|
||||||
tty.close()
|
tty.close()
|
||||||
return choice
|
return choice
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
|
logger.info("[SESSION_BOOT] /dev/tty not available: %s", exc)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _stop_session(session: dict, claude_bin: str) -> None:
|
def _stop_session(session: dict, claude_bin: str) -> str:
|
||||||
"""Properly stop a session — never bare kill for bg jobs."""
|
"""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")
|
pid = session.get("pid")
|
||||||
kind = session.get("kind", "unknown")
|
kind = session.get("kind", "unknown")
|
||||||
|
|
||||||
if kind == "bg":
|
if kind in ("bg", "background"):
|
||||||
job_id = session.get("jobId", "")
|
logger.info("[SESSION_BOOT] Cannot stop bg PID %s — no per-job stop in CLI", pid)
|
||||||
if job_id:
|
return f"PID {pid}: bg session — no per-job stop available"
|
||||||
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)
|
|
||||||
|
|
||||||
tmux_session = _find_tmux_session_for_pid(pid) if pid else None
|
tmux_session = _find_tmux_session_for_pid(pid) if pid else None
|
||||||
if tmux_session:
|
if tmux_session:
|
||||||
subprocess.run(["tmux", "kill-session", "-t", tmux_session], check=False)
|
subprocess.run(["tmux", "kill-session", "-t", tmux_session], check=False)
|
||||||
logger.info("[SESSION_BOOT] Killed tmux session '%s' (PID %d)", tmux_session, pid)
|
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:
|
if pid:
|
||||||
try:
|
try:
|
||||||
os.kill(pid, signal.SIGTERM)
|
os.kill(pid, signal.SIGTERM)
|
||||||
logger.info("[SESSION_BOOT] Sent SIGTERM to PID %d", pid)
|
logger.info("[SESSION_BOOT] Sent SIGTERM to PID %d", pid)
|
||||||
|
return f"PID {pid}: sent SIGTERM"
|
||||||
except ProcessLookupError:
|
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:
|
def _resume_session(session: dict, branch: str, claude_bin: str, defaults: list[str]) -> dict:
|
||||||
"""Resume a session — right mechanism per kind."""
|
"""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")
|
pid = session.get("pid")
|
||||||
kind = session.get("kind", "unknown")
|
kind = session.get("kind", "unknown")
|
||||||
|
|
||||||
if kind == "bg":
|
if kind in ("bg", "background"):
|
||||||
cwd = session.get("cwd", "")
|
return _takeover_bg(session, branch, claude_bin, defaults)
|
||||||
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"}
|
|
||||||
|
|
||||||
tmux_session = _find_tmux_session_for_pid(pid) if pid else None
|
tmux_session = _find_tmux_session_for_pid(pid) if pid else None
|
||||||
if tmux_session:
|
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}
|
return {"exit_code": 0, "action": "attached", "tmux_session": tmux_session}
|
||||||
|
|
||||||
logger.info("[SESSION_BOOT] Continuing dead-window session via --continue")
|
logger.info("[SESSION_BOOT] Continuing dead-window session via --continue")
|
||||||
os.execvp(claude_bin, [claude_bin] + defaults + ["--continue"])
|
return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"])
|
||||||
return {"exit_code": 0, "action": "continued"}
|
|
||||||
|
|
||||||
|
|
||||||
def _make_session_name(branch: str, session_id: str = "") -> str:
|
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
|
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:
|
def boot(cwd: str | None = None, extra_args: list[str] | None = None) -> dict:
|
||||||
"""Boot Claude Code — present menu when sessions exist.
|
"""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)
|
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(
|
def _menu_live(
|
||||||
live: list[dict],
|
live: list[dict],
|
||||||
branch: str,
|
branch: str,
|
||||||
@@ -299,21 +416,38 @@ def _menu_live(
|
|||||||
if len(live) == 1:
|
if len(live) == 1:
|
||||||
session = live[0]
|
session = live[0]
|
||||||
label = _session_label(session, branch)
|
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(f"\n{branch} — live chat: {label}\n")
|
||||||
sys.stderr.write(" [Enter] resume this chat\n")
|
if is_bg:
|
||||||
sys.stderr.write(" [n] start new chat (closes the one above first)\n")
|
sys.stderr.write(" [Enter] resume this chat (stops bg, reopens as normal chat)\n")
|
||||||
sys.stderr.write(" [c] close it and exit\n\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()
|
choice = _read_choice()
|
||||||
|
|
||||||
if choice in ("", "r"):
|
if choice in ("", "r"):
|
||||||
return _resume_session(session, claude_bin, defaults)
|
return _resume_session(session, branch, claude_bin, defaults)
|
||||||
elif choice == "n":
|
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)
|
return _start_fresh(branch, claude_bin, defaults, extra_args)
|
||||||
elif choice == "c":
|
elif choice == "c":
|
||||||
_stop_session(session, claude_bin)
|
if is_bg:
|
||||||
sys.stderr.write(f" Closed {branch} session.\n")
|
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"}
|
return {"exit_code": 0, "action": "closed"}
|
||||||
else:
|
else:
|
||||||
sys.stderr.write(" Unknown choice. Exiting.\n")
|
sys.stderr.write(" Unknown choice. Exiting.\n")
|
||||||
@@ -323,30 +457,65 @@ def _menu_live(
|
|||||||
for i, session in enumerate(live, 1):
|
for i, session in enumerate(live, 1):
|
||||||
label = _session_label(session, branch)
|
label = _session_label(session, branch)
|
||||||
sys.stderr.write(f" [{i}] {label}\n")
|
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")
|
sys.stderr.write(" [c] close all and exit\n\n")
|
||||||
|
|
||||||
choice = _read_choice()
|
choice = _read_choice()
|
||||||
|
|
||||||
if choice == "c":
|
if choice == "c":
|
||||||
for session in live:
|
return _close_all(live, branch, claude_bin)
|
||||||
_stop_session(session, claude_bin)
|
|
||||||
sys.stderr.write(f" Closed all {branch} sessions.\n")
|
if choice == "n":
|
||||||
return {"exit_code": 0, "action": "closed_all"}
|
return _new_over_all(live, branch, claude_bin, defaults, extra_args)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
idx = int(choice) - 1
|
idx = int(choice) - 1
|
||||||
if 0 <= idx < len(live):
|
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):
|
except (ValueError, IndexError):
|
||||||
pass
|
logger.info("[SESSION_BOOT] Invalid menu choice: %r", choice)
|
||||||
|
|
||||||
if choice == "" and live:
|
sys.stderr.write(" Pick a number, 'n', or 'c'. Exiting.\n")
|
||||||
return _resume_session(live[0], claude_bin, defaults)
|
|
||||||
|
|
||||||
sys.stderr.write(" Unknown choice. Exiting.\n")
|
|
||||||
return {"exit_code": 1, "error": "unknown choice"}
|
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(
|
def _menu_no_live(
|
||||||
branch: str,
|
branch: str,
|
||||||
claude_bin: str,
|
claude_bin: str,
|
||||||
@@ -362,8 +531,7 @@ def _menu_no_live(
|
|||||||
|
|
||||||
if choice in ("", "r"):
|
if choice in ("", "r"):
|
||||||
logger.info("[SESSION_BOOT] Continuing last chat via --continue")
|
logger.info("[SESSION_BOOT] Continuing last chat via --continue")
|
||||||
os.execvp(claude_bin, [claude_bin] + defaults + ["--continue"])
|
return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"])
|
||||||
return {"exit_code": 0, "action": "continued"}
|
|
||||||
elif choice == "n":
|
elif choice == "n":
|
||||||
return _start_fresh(branch, claude_bin, defaults, extra_args)
|
return _start_fresh(branch, claude_bin, defaults, extra_args)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# =================== AIPass ====================
|
# =================== AIPass ====================
|
||||||
# Name: cc_sessions.py
|
# Name: cc_sessions.py
|
||||||
# Version: 2.0.0
|
# Version: 3.0.0
|
||||||
# Description: CC-native session discovery, listing, and reclaim
|
# Description: CC-native session discovery, listing, and reclaim
|
||||||
# Branch: hooks
|
# Branch: hooks
|
||||||
# Layer: apps/modules
|
# Layer: apps/modules
|
||||||
# Created: 2026-06-30
|
# Created: 2026-06-30
|
||||||
# Modified: 2026-07-13
|
# Modified: 2026-07-14
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
"""Read Claude Code native session files (~/.claude/sessions/<pid>.json).
|
"""Read Claude Code native session files (~/.claude/sessions/<pid>.json).
|
||||||
@@ -24,7 +24,6 @@ Exposed as `drone @hooks sessions` (list) and
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -111,7 +110,8 @@ def _format_age(session: dict) -> str:
|
|||||||
if hours > 0:
|
if hours > 0:
|
||||||
return f"{hours}h{minutes}m"
|
return f"{hours}h{minutes}m"
|
||||||
return f"{minutes}m"
|
return f"{minutes}m"
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.info("[CC_SESSIONS] age format error: %s", exc)
|
||||||
return "?"
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
@@ -183,34 +183,18 @@ def find_occupant(cwd: str, exclude_pid: int | None = None) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def _stop_session(session: dict) -> str:
|
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")
|
pid = session.get("pid")
|
||||||
kind = session.get("kind", "unknown")
|
kind = session.get("kind", "unknown")
|
||||||
branch = _session_branch(session)
|
branch = _session_branch(session)
|
||||||
|
|
||||||
if kind == "bg":
|
if kind in ("bg", "background"):
|
||||||
job_id = session.get("jobId", "")
|
logger.info("[CC_SESSIONS] Cannot stop bg PID %s — no per-job stop in CLI", pid)
|
||||||
if job_id:
|
return f"PID {pid} ({branch}): bg session — no per-job stop available"
|
||||||
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 pid and _is_pid_alive(pid):
|
if pid and _is_pid_alive(pid):
|
||||||
try:
|
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()
|
||||||
@@ -190,7 +190,7 @@ class TestBoot:
|
|||||||
args = mock_exec.call_args[0][1]
|
args = mock_exec.call_args[0][1]
|
||||||
assert "--continue" in args
|
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"}]
|
live = [{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "bg"}]
|
||||||
with (
|
with (
|
||||||
patch.dict("os.environ", {}, clear=True),
|
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_tmux", return_value="/usr/bin/tmux"),
|
||||||
patch.object(session_boot, "_find_live_sessions", return_value=live),
|
patch.object(session_boot, "_find_live_sessions", return_value=live),
|
||||||
patch.object(session_boot, "_read_choice", return_value=""),
|
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))
|
result = session_boot.boot(cwd=str(tmp_path))
|
||||||
args = mock_exec.call_args[0][1]
|
mock_take.assert_called_once()
|
||||||
assert "agents" in args
|
assert result["action"] == "takeover"
|
||||||
|
|
||||||
def test_live_session_new_stops_old(self, tmp_path):
|
def test_live_session_new_stops_old(self, tmp_path):
|
||||||
live = [{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "interactive"}]
|
live = [{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "interactive"}]
|
||||||
@@ -293,14 +295,21 @@ class TestBoot:
|
|||||||
|
|
||||||
|
|
||||||
class TestStopSession:
|
class TestStopSession:
|
||||||
def test_bg_job_uses_agents_stop(self):
|
def test_bg_returns_honest_no_stop(self):
|
||||||
session = {"pid": 1234, "kind": "bg", "jobId": "job-abc"}
|
session = {"pid": 1234, "kind": "bg"}
|
||||||
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run:
|
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")
|
session_boot._stop_session(session, "/usr/local/bin/claude")
|
||||||
cmd = mock_run.call_args[0][0]
|
mock_kill.assert_not_called()
|
||||||
assert "agents" in cmd
|
|
||||||
assert "stop" in cmd
|
|
||||||
assert "job-abc" in cmd
|
|
||||||
|
|
||||||
def test_tmux_session_killed(self):
|
def test_tmux_session_killed(self):
|
||||||
session = {"pid": 1234, "kind": "interactive"}
|
session = {"pid": 1234, "kind": "interactive"}
|
||||||
@@ -308,9 +317,10 @@ class TestStopSession:
|
|||||||
patch.object(session_boot, "_find_tmux_session_for_pid", return_value="hooks"),
|
patch.object(session_boot, "_find_tmux_session_for_pid", return_value="hooks"),
|
||||||
patch(f"{_MOD}.subprocess.run") as mock_run,
|
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)]
|
kill_calls = [c for c in mock_run.call_args_list if "kill-session" in str(c)]
|
||||||
assert len(kill_calls) == 1
|
assert len(kill_calls) == 1
|
||||||
|
assert "tmux" in result
|
||||||
|
|
||||||
def test_plain_session_sigterm(self):
|
def test_plain_session_sigterm(self):
|
||||||
session = {"pid": 1234, "kind": "interactive"}
|
session = {"pid": 1234, "kind": "interactive"}
|
||||||
@@ -318,8 +328,18 @@ class TestStopSession:
|
|||||||
patch.object(session_boot, "_find_tmux_session_for_pid", return_value=None),
|
patch.object(session_boot, "_find_tmux_session_for_pid", return_value=None),
|
||||||
patch(f"{_MOD}.os.kill") as mock_kill,
|
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()
|
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:
|
class TestMain:
|
||||||
@@ -476,13 +496,31 @@ class TestMultipleLiveSessions:
|
|||||||
patch.object(session_boot, "_find_tmux", return_value="/usr/bin/tmux"),
|
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, "_find_live_sessions", return_value=live),
|
||||||
patch.object(session_boot, "_read_choice", return_value="c"),
|
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))
|
result = session_boot.boot(cwd=str(tmp_path))
|
||||||
assert result["action"] == "closed_all"
|
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 = [
|
live = [
|
||||||
{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "interactive"},
|
{"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "interactive"},
|
||||||
{"pid": 5678, "sessionId": "def", "cwd": str(tmp_path), "kind": "bg"},
|
{"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_tmux", return_value="/usr/bin/tmux"),
|
||||||
patch.object(session_boot, "_find_live_sessions", return_value=live),
|
patch.object(session_boot, "_find_live_sessions", return_value=live),
|
||||||
patch.object(session_boot, "_read_choice", return_value="2"),
|
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,
|
patch(f"{_MOD}.os.execvp") as mock_exec,
|
||||||
):
|
):
|
||||||
session_boot.boot(cwd=str(tmp_path))
|
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]
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user