From 1a1ba6abd55e72f59fb64d86d67e2fa1342be91c Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 15:34:21 -0400 Subject: [PATCH] fix(dashboard): fall back to fresh claude when --continue has no session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--continue` exits non-zero when an agent has been spun up but never typed at — there's no transcript to resume. Re-attaching to such an agent via Enter (tmux mode) was crashing the pane. Wrap the resume invocation in `sh -c ' --continue || '` so a failed `--continue` cleanly falls through to a fresh claude. The shell adds microseconds and the fallback only kicks in when --continue would have failed anyway. New `_build_resume_argv_with_fallback(bottle)` builds the shell-wrapped docker exec argv with proper shlex quoting (so paths-with-spaces in `--append-system-prompt-file` survive). Only the tmux re-attach path uses it; first-attach + foreground handoff are unchanged. 489 unit tests pass (4 new for the fallback builder). --- claude_bottle/cli/dashboard.py | 46 ++++++++++++++++++++-- tests/unit/test_dashboard_active_agents.py | 46 ++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index c82e2a7..9880c82 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -15,6 +15,7 @@ import argparse import contextlib import curses import os +import shlex import shutil import subprocess import sys @@ -773,6 +774,38 @@ def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[ return args +def _build_resume_argv_with_fallback( + bottle, *, remote_control: bool = False, +) -> list[str]: + """Build a docker-exec argv that runs `claude --continue` and + falls back to plain `claude` if no prior session exists. + + `--continue` exits non-zero when an agent has been spun up + but never typed at — there's no transcript to resume. The + shell-level `||` wrapper makes that case start a fresh + session instead of crashing the pane. The trade-off: we + invoke `sh -c` inside the container, so the command is two + `claude` invocations behind a tiny shell rather than one + direct exec. Acceptable; the shell adds microseconds and + the fallback only kicks in when --continue would have + failed anyway.""" + base_args = ["--dangerously-skip-permissions"] + if remote_control: + base_args.append("--remote-control") + base_docker = bottle.claude_docker_argv(base_args) + # Split docker-prefix from the claude-and-args tail so we + # can compose ` --continue || ` inside + # `sh -c`. The `claude` token is the marker. + claude_idx = base_docker.index("claude") + prefix = base_docker[:claude_idx] + claude_cmd = " ".join(shlex.quote(a) for a in base_docker[claude_idx:]) + return [ + *prefix, + "sh", "-c", + f"{claude_cmd} --continue || {claude_cmd}", + ] + + def _build_split_pane_argv(docker_argv: list[str]) -> list[str]: """Pure helper: wrap a docker-exec argv with `tmux split-window -h -P -F '#{pane_id}'`. The `-P -F` combo tells tmux to print @@ -1008,9 +1041,16 @@ def _attach_in_tmux( The Enter re-attach key passes this; passive paths (the auto-attach after a stop) leave it False so the operator stays in the dashboard pane.""" - docker_argv = bottle.claude_docker_argv( - _claude_runtime_args(resume=resume), - ) + if resume: + # `--continue` exits non-zero when no prior session + # exists (agent spun up but never typed at). Wrap with a + # shell-level fallback so the pane lands in a fresh + # claude instead of crashing. + docker_argv = _build_resume_argv_with_fallback(bottle) + else: + docker_argv = bottle.claude_docker_argv( + _claude_runtime_args(resume=False), + ) pane_id = _ensure_right_pane(tmux_state, docker_argv) if pane_id is None: # tmux failed (missing binary, server died, size error). diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index a202772..75154fd 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -463,6 +463,52 @@ class TestTmuxPaneArgvBuilders(unittest.TestCase): self.assertIn("%abc.123", argv) +class TestResumeArgvWithFallback(unittest.TestCase): + """The `claude --continue || claude` shell fallback for the + tmux re-attach path. Without it, an agent that's been spun + up but never typed at crashes the pane on Enter because + --continue has no session to resume.""" + + def _bottle(self, prompt_path: str | None = None): + from claude_bottle.backend.docker.bottle import DockerBottle + return DockerBottle( + container="claude-bottle-dev-abc", + teardown=lambda: None, + prompt_path_in_container=prompt_path, + ) + + def test_wraps_in_sh_c_with_or_fallback(self): + argv = dashboard._build_resume_argv_with_fallback(self._bottle()) + # Must end with `sh -c ' --continue || '`. + self.assertEqual( + ["docker", "exec", "-it", "claude-bottle-dev-abc", "sh", "-c"], + argv[:6], + ) + inner = argv[6] + self.assertIn("--continue", inner) + self.assertIn("||", inner) + # Both branches mention claude. + self.assertEqual(2, inner.count("claude")) + + def test_inner_args_quoted_safely(self): + # Paths with spaces would break naive concatenation. + bottle = self._bottle("/home/with space/.prompt") + argv = dashboard._build_resume_argv_with_fallback(bottle) + inner = argv[-1] + # shlex.quote should single-quote any token with a space. + self.assertIn("'/home/with space/.prompt'", inner) + + def test_includes_skip_permissions(self): + argv = dashboard._build_resume_argv_with_fallback(self._bottle()) + self.assertIn("--dangerously-skip-permissions", argv[-1]) + + def test_includes_prompt_file_flag_when_set(self): + bottle = self._bottle("/home/node/.claude-bottle-prompt.txt") + argv = dashboard._build_resume_argv_with_fallback(bottle) + self.assertIn("--append-system-prompt-file", argv[-1]) + self.assertIn("/home/node/.claude-bottle-prompt.txt", argv[-1]) + + class TestClaudeRuntimeArgs(unittest.TestCase): """The argv passed to `bottle.claude_docker_argv` on each attach. Locked here so the tmux + foreground paths build