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
|
||||
|
||||
- **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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user