diff --git a/scripts/agent_loop.py b/scripts/agent_loop.py index 7ad2427..83c19af 100755 --- a/scripts/agent_loop.py +++ b/scripts/agent_loop.py @@ -163,9 +163,13 @@ def _start_agent(prompt: str, session_name: str) -> str: """ Start Claude Code inside a detached tmux session and return the session name. + Claude runs in interactive TUI mode (no -p flag) so the session is fully + watchable: `tmux attach -t ` shows live progress. The session + stays open after the agent finishes; the 1-hour timeout in main() cleans it up. + The session inherits the tmux server's environment (including ANTHROPIC_API_KEY and any keychain access), which is more reliable than cron's minimal env. - Output is written to both the tmux scrollback buffer and a log file via tee. + Output is also captured to a log file via pipe-pane. """ log_dir = Path.home() / ".sharedinbox-agent-logs" log_dir.mkdir(exist_ok=True) @@ -175,16 +179,24 @@ def _start_agent(prompt: str, session_name: str) -> str: # Kill any stale session with this name before creating a new one. subprocess.run(["tmux", "kill-session", "-t", session_name], capture_output=True) + # Run without -p so Claude shows its TUI in the tmux pane. The prompt is + # passed as a positional argument so Claude starts processing immediately. shell_cmd = ( f"claude --dangerously-skip-permissions" f" --name {shlex.quote(session_name)}" - f" -p {shlex.quote(prompt)}" - f" < /dev/null 2>&1 | tee {shlex.quote(str(log_file))}" + f" {shlex.quote(prompt)}" ) subprocess.run( ["tmux", "new-session", "-d", "-s", session_name, "bash", "-c", shell_cmd], check=True, ) + # Capture pane output to log without replacing the PTY — keeps the session + # fully interactive when you `tmux attach -t `. + subprocess.run( + ["tmux", "pipe-pane", "-t", session_name, + f"cat >> {shlex.quote(str(log_file))}"], + check=True, + ) print(f"[agent_loop] Started tmux session={session_name!r}, log={log_file}") print(f"[agent_loop] Watch: tmux attach -t {shlex.quote(session_name)}") print(f"[agent_loop] Resume: claude --resume {shlex.quote(session_name)}") diff --git a/scripts/test_agent_loop.py b/scripts/test_agent_loop.py new file mode 100644 index 0000000..a9fb2b3 --- /dev/null +++ b/scripts/test_agent_loop.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Tests for scripts/agent_loop.py.""" +import json +import unittest +from pathlib import Path +from unittest.mock import MagicMock, call, patch + + +class TestStartAgent(unittest.TestCase): + """Verify that _start_agent builds the correct tmux / claude command.""" + + def _run(self, prompt: str, session_name: str): + """Call _start_agent with all subprocess.run calls mocked out.""" + calls: list = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return MagicMock(returncode=0) + + with ( + patch("scripts.agent_loop.subprocess.run", side_effect=fake_run), + patch("scripts.agent_loop.Path.mkdir"), + ): + import scripts.agent_loop as al + al._start_agent(prompt, session_name) + + return calls + + def _get_shell_cmd(self, calls: list) -> str: + """Extract the shell command string from tmux new-session call.""" + for cmd in calls: + if "new-session" in cmd: + # last element is the shell_cmd passed to bash -c + return cmd[-1] + self.fail("tmux new-session call not found") + + def test_no_print_flag(self): + calls = self._run("Do something", "issue-1") + shell_cmd = self._get_shell_cmd(calls) + self.assertNotIn(" -p ", shell_cmd) + self.assertNotIn("--print", shell_cmd) + + def test_prompt_included_in_cmd(self): + calls = self._run("Fix the bug", "issue-42") + shell_cmd = self._get_shell_cmd(calls) + self.assertIn("Fix the bug", shell_cmd) + + def test_session_name_in_cmd(self): + calls = self._run("task prompt", "issue-99") + shell_cmd = self._get_shell_cmd(calls) + self.assertIn("issue-99", shell_cmd) + + def test_dangerously_skip_permissions_present(self): + calls = self._run("prompt", "ci-fix") + shell_cmd = self._get_shell_cmd(calls) + self.assertIn("--dangerously-skip-permissions", shell_cmd) + + def test_prompt_with_special_chars_is_quoted(self): + prompt = "Fix issue #42; rm -rf /" + calls = self._run(prompt, "issue-42") + shell_cmd = self._get_shell_cmd(calls) + # The dangerous part should be inside quotes, not interpreted by shell + self.assertNotIn("; rm -rf /", shell_cmd.split("'")[-1]) + + def test_tmux_new_session_is_detached(self): + calls = self._run("prompt", "issue-1") + new_session_call = next(c for c in calls if "new-session" in c) + self.assertIn("-d", new_session_call) + + def test_tmux_new_session_uses_correct_name(self): + calls = self._run("prompt", "issue-7") + new_session_call = next(c for c in calls if "new-session" in c) + self.assertIn("issue-7", new_session_call) + + def test_pipe_pane_called_for_logging(self): + calls = self._run("prompt", "issue-1") + pipe_pane_calls = [c for c in calls if "pipe-pane" in c] + self.assertEqual(len(pipe_pane_calls), 1) + + def test_returns_session_name(self): + with ( + patch("scripts.agent_loop.subprocess.run", return_value=MagicMock(returncode=0)), + patch("scripts.agent_loop.Path.mkdir"), + ): + import scripts.agent_loop as al + result = al._start_agent("prompt", "issue-55") + self.assertEqual(result, "issue-55") + + +class TestWriteAndReadState(unittest.TestCase): + def test_roundtrip(self): + import tempfile + import scripts.agent_loop as al + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: + tmp = Path(f.name) + original = al.STATE_FILE + try: + al.STATE_FILE = tmp + al._write_state("issue-10", 10, "issue") + state = al._read_state() + self.assertEqual(state["tmux_session"], "issue-10") + self.assertEqual(state["issue"], 10) + self.assertEqual(state["type"], "issue") + finally: + al.STATE_FILE = original + tmp.unlink(missing_ok=True) + + def test_read_missing_returns_none(self): + import scripts.agent_loop as al + original = al.STATE_FILE + try: + al.STATE_FILE = Path("/nonexistent/state.json") + self.assertIsNone(al._read_state()) + finally: + al.STATE_FILE = original + + +if __name__ == "__main__": + unittest.main()