diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe2a29e..bc1fb6d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ PyPI version — not the changelog header. --- +## [2026-07-14] + +### Fixed + +- **DPLAN-0241 round 4 (night shift): user flags survive every launch path, and + every session is born with an honest name.** R6 — the bug behind Patrick's + approve-everything chat: the boot menu suppressed its bypass defaults when the + user passed `--permission-mode` himself, but only the fresh-launch path threaded + the user's flags into the exec — resume, takeover, continue, and dead-window + paths all launched flagless. `extra_args` now threads through ALL launch paths + (headless `-p` included). R7 — auto-namer: every launch is stamped + `--name -` (flag live-verified on claude 2.1.209; a + user-passed `-n/--name` wins), so made-up auto-names can no longer hide which + chat is which. Plus four drill nits: new-over-all ABORTS if the daemon stop + fails (one brain even in failure paths), close-all's failure hint no longer + recommends the mechanism that just failed, `exit`/`q`/`quit` quietly leave every + menu, session rows stay rich (PID, kind, name, age). Surgical-stop probe: + `op:kill` exists in the daemon's Unix-socket control protocol (per-job bg stop, + 8-char sessionId prefix, no auth) — documented in DPLAN-0241, deliberately NOT + shipped: undocumented internal protocol. 1048 hooks tests green (102 + session_boot, 11 real-binary CLI contract). + ## [2026-07-13] ### Fixed diff --git a/src/aipass/hooks/apps/handlers/lifecycle/session_boot.py b/src/aipass/hooks/apps/handlers/lifecycle/session_boot.py index d802a8ef..fd2e492d 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/session_boot.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/session_boot.py @@ -225,7 +225,9 @@ def _stop_session(session: dict, claude_bin: str) -> str: return f"PID {pid}: no action" -def _resume_session(session: dict, branch: str, claude_bin: str, defaults: list[str]) -> dict: +def _resume_session( + session: dict, branch: str, claude_bin: str, defaults: list[str], extra_args: list[str] | None = None +) -> dict: """Resume a session — right mechanism per kind. bg: takeover (daemon stop + --resume in tmux). Never opens agents view. @@ -234,9 +236,10 @@ def _resume_session(session: dict, branch: str, claude_bin: str, defaults: list[ """ pid = session.get("pid") kind = session.get("kind", "unknown") + ea = list(extra_args or []) if kind in ("bg", "background"): - return _takeover_bg(session, branch, claude_bin, defaults) + return _takeover_bg(session, branch, claude_bin, defaults, extra_args) tmux_session = _find_tmux_session_for_pid(pid) if pid else None if tmux_session: @@ -245,7 +248,9 @@ def _resume_session(session: dict, branch: str, claude_bin: str, defaults: list[ return {"exit_code": 0, "action": "attached", "tmux_session": tmux_session} logger.info("[SESSION_BOOT] Continuing dead-window session via --continue") - return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"]) + sid = session.get("sessionId", "") + nf = _name_flag(branch, sid, extra_args) + return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"] + ea + nf) def _make_session_name(branch: str, session_id: str = "") -> str: @@ -256,6 +261,13 @@ def _make_session_name(branch: str, session_id: str = "") -> str: return branch +def _name_flag(branch: str, session_id: str = "", extra_args: list[str] | None = None) -> list[str]: + """Build --name args for session stamping, unless user already provided one.""" + if extra_args and ("-n" in extra_args or "--name" in extra_args): + return [] + return ["--name", _make_session_name(branch, session_id)] + + 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) @@ -291,7 +303,7 @@ def boot(cwd: str | None = None, extra_args: list[str] | None = None) -> dict: if os.environ.get("TMUX"): logger.info("[SESSION_BOOT] Already inside tmux — running claude directly") - claude_cmd = [claude_bin] + defaults + claude_cmd = [claude_bin] + defaults + _name_flag(branch, extra_args=extra_args) if extra_args: claude_cmd.extend(extra_args) os.execvp(claude_bin, claude_cmd) @@ -375,7 +387,9 @@ def _daemon_stop(claude_bin: str, branch: str, pid: int | None) -> dict: return {"ok": True} -def _takeover_bg(session: dict, branch: str, claude_bin: str, defaults: list[str]) -> dict: +def _takeover_bg( + session: dict, branch: str, claude_bin: str, defaults: list[str], extra_args: list[str] | None = None +) -> 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 @@ -384,6 +398,8 @@ def _takeover_bg(session: dict, branch: str, claude_bin: str, defaults: list[str """ session_id = session.get("sessionId", "") pid = session.get("pid") + ea = list(extra_args or []) + nf = _name_flag(branch, session_id, extra_args) stop_result = _daemon_stop(claude_bin, branch, pid) if not stop_result["ok"]: @@ -391,10 +407,12 @@ def _takeover_bg(session: dict, branch: str, claude_bin: str, defaults: list[str 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]) + return _exec_in_tmux( + branch, session_id, claude_bin, [claude_bin] + defaults + ["--resume", session_id] + ea + nf + ) logger.info("[SESSION_BOOT] No sessionId for takeover — continuing last") - return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"]) + return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"] + ea + nf) def _is_session_file_present(pid: int | None) -> bool: @@ -430,7 +448,7 @@ def _menu_live( choice = _read_choice() if choice in ("", "r"): - return _resume_session(session, branch, claude_bin, defaults) + return _resume_session(session, branch, claude_bin, defaults, extra_args) elif choice == "n": if is_bg: stop = _daemon_stop(claude_bin, branch, session.get("pid")) @@ -449,6 +467,8 @@ def _menu_live( result = _stop_session(session, claude_bin) sys.stderr.write(f" {result}\n") return {"exit_code": 0, "action": "closed"} + elif choice in ("exit", "q", "quit"): + return {"exit_code": 0, "action": "quit"} else: sys.stderr.write(" Unknown choice. Exiting.\n") return {"exit_code": 1, "error": "unknown choice"} @@ -468,10 +488,13 @@ def _menu_live( if choice == "n": return _new_over_all(live, branch, claude_bin, defaults, extra_args) + if choice in ("exit", "q", "quit"): + return {"exit_code": 0, "action": "quit"} + try: idx = int(choice) - 1 if 0 <= idx < len(live): - return _resume_session(live[idx], branch, claude_bin, defaults) + return _resume_session(live[idx], branch, claude_bin, defaults, extra_args) except (ValueError, IndexError): logger.info("[SESSION_BOOT] Invalid menu choice: %r", choice) @@ -492,7 +515,7 @@ def _close_all(live: list[dict], branch: str, claude_bin: str) -> dict: 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") + sys.stderr.write(f" PID {s.get('pid')}: bg session remains — daemon stop failed\n") return {"exit_code": 0, "action": "closed_all"} @@ -512,7 +535,8 @@ def _new_over_all( 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") + sys.stderr.write(" Cannot start new — bg session(s) still running.\n") + return {"exit_code": 1, "error": "daemon stop failed, aborting to preserve one-brain"} return _start_fresh(branch, claude_bin, defaults, extra_args) @@ -531,9 +555,13 @@ def _menu_no_live( if choice in ("", "r"): logger.info("[SESSION_BOOT] Continuing last chat via --continue") - return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"]) + nf = _name_flag(branch, extra_args=extra_args) + cmd = [claude_bin] + defaults + ["--continue"] + list(extra_args or []) + nf + return _exec_in_tmux(branch, "", claude_bin, cmd) elif choice == "n": return _start_fresh(branch, claude_bin, defaults, extra_args) + elif choice in ("exit", "q", "quit"): + return {"exit_code": 0, "action": "quit"} else: sys.stderr.write(" Unknown choice. Exiting.\n") return {"exit_code": 1, "error": "unknown choice"} @@ -552,7 +580,7 @@ def _start_fresh( logger.info("[SESSION_BOOT] Killing stale tmux session '%s'", session_name) subprocess.run(["tmux", "kill-session", "-t", session_name], check=False) - claude_cmd = [claude_bin] + defaults + claude_cmd = [claude_bin] + defaults + _name_flag(branch, extra_args=extra_args) if extra_args: claude_cmd.extend(extra_args) diff --git a/src/aipass/hooks/tests/test_cli_contract.py b/src/aipass/hooks/tests/test_cli_contract.py index a9fd95b2..030eb31b 100644 --- a/src/aipass/hooks/tests/test_cli_contract.py +++ b/src/aipass/hooks/tests/test_cli_contract.py @@ -54,6 +54,10 @@ class TestClaudeMainFlags: h = _get_main_help() assert "-p" in h or "--print" in h + def test_name_flag(self): + h = _get_main_help() + assert "--name" in h or "-n" in h + @_SKIP class TestClaudeAgentsFlags: diff --git a/src/aipass/hooks/tests/test_session_boot.py b/src/aipass/hooks/tests/test_session_boot.py index 8e5326b1..c28c68d2 100644 --- a/src/aipass/hooks/tests/test_session_boot.py +++ b/src/aipass/hooks/tests/test_session_boot.py @@ -118,6 +118,28 @@ class TestMakeSessionName: assert session_boot._make_session_name("hooks", "") == "hooks" +class TestNameFlag: + def test_branch_only(self): + result = session_boot._name_flag("hooks") + assert result == ["--name", "hooks"] + + def test_branch_with_session_id(self): + result = session_boot._name_flag("hooks", "abcdef1234") + assert result == ["--name", "hooks-abcdef12"] + + def test_skips_when_user_provides_name(self): + result = session_boot._name_flag("hooks", "", ["--name", "myname"]) + assert result == [] + + def test_skips_when_user_provides_n(self): + result = session_boot._name_flag("hooks", "", ["-n", "myname"]) + assert result == [] + + def test_adds_when_extra_args_no_name(self): + result = session_boot._name_flag("hooks", "", ["--verbose"]) + assert result == ["--name", "hooks"] + + class TestSessionLabel: def test_formats_label(self): session = {"pid": 1234, "sessionId": "abcdef1234", "kind": "interactive"} @@ -820,3 +842,260 @@ class TestIsSessionFilePresent: def test_none_pid(self): assert session_boot._is_session_file_present(None) is False + + +class TestExtraArgsThreading: + """R6: extra_args must reach every launch path, not just _start_fresh.""" + + def test_resume_dead_window_threads_extra_args(self, tmp_path): + session = {"pid": 1234, "sessionId": "abc", "cwd": str(tmp_path), "kind": "interactive"} + with ( + patch.object(session_boot, "_find_tmux_session_for_pid", return_value=None), + 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", "plan"]) + cmd = mock_exec.call_args[0][1] + assert "--continue" in cmd + assert "--permission-mode" in cmd + assert "plan" in cmd + + def test_takeover_bg_threads_extra_args(self, tmp_path): + session = {"pid": 1234, "sessionId": "abc-uuid", "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", "plan"]) + cmd = mock_exec.call_args[0][1] + assert "--resume" in cmd + assert "--permission-mode" in cmd + assert "plan" in cmd + + def test_takeover_bg_continue_threads_extra_args(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", "plan"]) + cmd = mock_exec.call_args[0][1] + assert "--continue" in cmd + assert "--permission-mode" in cmd + + def test_no_live_continue_threads_extra_args(self, tmp_path): + 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=[]), + patch.object(session_boot, "_read_choice", return_value=""), + patch(f"{_MOD}.os.execvp") as mock_exec, + ): + session_boot.boot(cwd=str(tmp_path), extra_args=["--permission-mode", "plan"]) + cmd = mock_exec.call_args[0][1] + assert "--continue" in cmd + assert "--permission-mode" in cmd + assert "plan" in cmd + + def test_live_bg_enter_threads_extra_args(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, "_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), extra_args=["--permission-mode", "plan"]) + cmd = mock_exec.call_args[0][1] + assert "--resume" in cmd + assert "--permission-mode" in cmd + assert "plan" in cmd + + def test_multi_session_pick_threads_extra_args(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=None), + 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), extra_args=["--permission-mode", "plan"]) + cmd = mock_exec.call_args[0][1] + assert "--continue" in cmd + assert "--permission-mode" in cmd + assert "plan" in cmd + + +class TestNewOverAllAbort: + def test_aborts_on_daemon_stop_failure(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"), + patch.object(session_boot, "_daemon_stop", return_value={"ok": False, "error": "failed"}), + patch(f"{_MOD}.os.execvp") as mock_exec, + ): + result = session_boot.boot(cwd=str(tmp_path)) + assert result["exit_code"] == 1 + assert "one-brain" in result["error"] + mock_exec.assert_not_called() + + +class TestMenuQuit: + def test_single_session_q_quits(self, tmp_path): + live = [{"pid": 1234, "sessionId": "abc", "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="q"), + ): + result = session_boot.boot(cwd=str(tmp_path)) + assert result["exit_code"] == 0 + assert result["action"] == "quit" + + def test_single_session_exit_quits(self, tmp_path): + live = [{"pid": 1234, "sessionId": "abc", "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="exit"), + ): + result = session_boot.boot(cwd=str(tmp_path)) + assert result["action"] == "quit" + + def test_multi_session_quit_quits(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="quit"), + ): + result = session_boot.boot(cwd=str(tmp_path)) + assert result["action"] == "quit" + + def test_no_live_q_quits(self, tmp_path): + 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=[]), + patch.object(session_boot, "_read_choice", return_value="q"), + ): + result = session_boot.boot(cwd=str(tmp_path)) + assert result["action"] == "quit" + + +class TestAutoNamer: + """R7: --name flag stamped on every launch for self-identifying sessions.""" + + def test_fresh_start_gets_name(self, tmp_path): + 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=[]), + patch.object(session_boot, "_read_choice", return_value="n"), + 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)) + cmd = mock_exec.call_args[0][1] + assert "--name" in cmd + branch = tmp_path.name + idx = cmd.index("--name") + assert cmd[idx + 1] == branch + + def test_takeover_gets_name_with_session_id(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}), + 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", []) + cmd = mock_exec.call_args[0][1] + assert "--name" in cmd + idx = cmd.index("--name") + assert cmd[idx + 1] == "hooks-abc12345" + + def test_in_tmux_gets_name(self, tmp_path): + with ( + patch.dict("os.environ", {"TMUX": "/tmp/tmux-1000/default,123,0"}), + patch.object(session_boot, "_resolve_claude_binary", return_value="/usr/local/bin/claude"), + patch(f"{_MOD}.os.execvp") as mock_exec, + ): + session_boot.boot(cwd=str(tmp_path)) + cmd = mock_exec.call_args[0][1] + assert "--name" in cmd + + def test_no_live_continue_gets_name(self, tmp_path): + 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=[]), + patch.object(session_boot, "_read_choice", return_value=""), + patch(f"{_MOD}.os.execvp") as mock_exec, + ): + session_boot.boot(cwd=str(tmp_path)) + cmd = mock_exec.call_args[0][1] + assert "--name" in cmd + + def test_user_name_not_overridden(self, tmp_path): + 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=[]), + patch.object(session_boot, "_read_choice", return_value="n"), + 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), extra_args=["--name", "myname"]) + cmd = mock_exec.call_args[0][1] + assert cmd.count("--name") == 1 + idx = cmd.index("--name") + assert cmd[idx + 1] == "myname" + + def test_dead_window_resume_gets_name_with_session_id(self, tmp_path): + session = {"pid": 1234, "sessionId": "abc12345-uuid", "cwd": str(tmp_path), "kind": "interactive"} + with ( + patch.object(session_boot, "_find_tmux_session_for_pid", return_value=None), + 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", []) + cmd = mock_exec.call_args[0][1] + assert "--name" in cmd + idx = cmd.index("--name") + assert cmd[idx + 1] == "hooks-abc12345"