From c084e7a57b188f2b4bc19066271e546213fa84c0 Mon Sep 17 00:00:00 2001 From: Nosko Artem Date: Mon, 29 Jun 2026 12:22:38 +0300 Subject: [PATCH] feat(local-terminal): close orphaned native tab on session kill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local_terminal feature opened a native iTerm/Terminal/emulator tab per session that ran `tmux attach …; exec ${SHELL} -l`. The unconditional exec tail was meant to survive a manual detach (Ctrl-b d) without losing scrollback — but it also kept the tab alive as a bare login shell after a session kill, leaving a leftover tab on every /kill. Guard the tail on the per-window group session still existing: `tmux attach …; tmux has-session -t && exec …`. A manual detach leaves the group alive (shell stays, scrollback preserved); kill_window tears the group down (see _kill_grouped_session_for_window), so has-session fails, the bash -c wrapper exits cleanly, and the host terminal closes the now-orphaned tab. Applied to both the macOS and Linux command builders. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ccbot/local_terminal.py | 27 ++++++++++++++++++++------- tests/ccbot/test_local_terminal.py | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/ccbot/local_terminal.py b/src/ccbot/local_terminal.py index 271c2930..7e704daf 100644 --- a/src/ccbot/local_terminal.py +++ b/src/ccbot/local_terminal.py @@ -167,10 +167,17 @@ def _build_tmux_command(window_id: str) -> str: some macOS versions) Terminal.app's ``do script`` exec ``X`` *without* a shell wrapper, so ``\\;``, ``||``, and ``;`` lose their shell semantics. Wrapping in ``bash -c`` makes the operator - semantics explicit. The trailing ``exec ${SHELL:-bash} -l`` keeps - the window open after the user detaches. ``tmux`` is invoked by - absolute path because iTerm's bash inherits a stripped PATH on - macOS. + semantics explicit. ``tmux`` is invoked by absolute path because + iTerm's bash inherits a stripped PATH on macOS. + + The trailing ``exec ${SHELL:-bash} -l`` is *conditional*: it only + runs while the per-window group session is still alive. On a manual + detach (``Ctrl-b d``) the group survives, so the shell stays and + scrollback is preserved. On a session kill ``kill_window`` tears the + group down (see ``tmux_manager._kill_grouped_session_for_window``), + so ``has-session`` fails, the wrapper exits cleanly, and the host + terminal closes the now-orphaned tab instead of leaving it as a bare + login shell. """ session_name = config.tmux_session_name session_q = shlex.quote(session_name) @@ -179,11 +186,13 @@ def _build_tmux_command(window_id: str) -> str: group_q = shlex.quote(group) target_q = shlex.quote(f"{group}:{window_id}") # Each tmux call is independent — failure of one (e.g. group already - # exists) does not abort the chain. The trailing exec always runs. + # exists) does not abort the chain. The trailing exec runs only if the + # group still exists after attach returns (detach, not kill). inner = ( f"{tmux_bin} new-session -d -t {session_q} -s {group_q} 2>/dev/null; " f"{tmux_bin} select-window -t {target_q} 2>/dev/null; " f"{tmux_bin} attach-session -t {group_q}; " + f"{tmux_bin} has-session -t {group_q} 2>/dev/null && " f"exec ${{SHELL:-bash}} -l" ) return f"bash -c {shlex.quote(inner)}" @@ -204,7 +213,11 @@ def _build_linux_shell_cmd(window_id: str) -> str: The trailing ``exec bash -i`` keeps the window open after the user detaches from tmux (otherwise the window would snap shut and the - user would lose terminal scrollback). + user would lose terminal scrollback). It is *conditional* on the + group session still existing: a manual detach leaves it alive (keep + the shell), but a session kill tears the group down (see + ``tmux_manager._kill_grouped_session_for_window``), so the wrapper + exits and the emulator closes the orphaned tab. """ session = shlex.quote(config.tmux_session_name) group = group_session_name(window_id) @@ -214,7 +227,7 @@ def _build_linux_shell_cmd(window_id: str) -> str: f"tmux new-session -d -t {session} -s {group_q} 2>/dev/null; " f"tmux select-window -t {target_q} 2>/dev/null; " f"tmux attach-session -t {group_q}; " - "exec bash -i" + f"tmux has-session -t {group_q} 2>/dev/null && exec bash -i" ) diff --git a/tests/ccbot/test_local_terminal.py b/tests/ccbot/test_local_terminal.py index b69ad835..74fd1d91 100644 --- a/tests/ccbot/test_local_terminal.py +++ b/tests/ccbot/test_local_terminal.py @@ -70,6 +70,19 @@ def test_keeps_window_open_after_detach(self) -> None: cmd = _build_tmux_command("@1") assert "exec ${SHELL:-bash} -l" in cmd + def test_exec_guarded_by_has_session(self) -> None: + """The tail shell is conditional: it only runs while the group + session is still alive (manual detach). On a session kill the + group is gone, ``has-session`` fails, and the wrapper exits so + the host terminal closes the orphaned tab instead of leaving a + bare login shell.""" + cmd = _build_tmux_command("@5") + assert "has-session -t" in cmd + # The guard must short-circuit the exec — ``&&`` immediately + # before it. + idx = cmd.index("has-session") + assert "&& exec ${SHELL:-bash} -l" in cmd[idx:] + def test_wrapped_in_bash_c_for_shell_semantics(self) -> None: """Without `bash -c`, iTerm/Terminal.app exec the command without a shell — ``;`` loses meaning, the chain falls apart.""" @@ -124,6 +137,15 @@ def test_build_linux_shell_cmd_uses_grouped_session(self) -> None: assert "attach-session -t" in cmd assert "exec bash -i" in cmd + def test_linux_exec_guarded_by_has_session(self) -> None: + """Linux mirrors macOS: the tail shell only survives a manual + detach (group still alive). On kill the group is torn down so + ``has-session`` fails and the emulator closes the tab.""" + cmd = _build_linux_shell_cmd("@7") + assert "has-session -t" in cmd + idx = cmd.index("has-session") + assert "&& exec bash -i" in cmd[idx:] + def test_expand_linux_template_returns_argv(self) -> None: argv = _expand_linux_template("gnome-terminal -- bash -c {shell}", "@5") assert argv[0] == "gnome-terminal"