diff --git a/scripts/agent_loop.py b/scripts/agent_loop.py index 1d17304..4236d28 100755 --- a/scripts/agent_loop.py +++ b/scripts/agent_loop.py @@ -22,9 +22,10 @@ State file: ~/.sharedinbox-agent-state.json "started_at": "2026-05-15T12:00:00+00:00", "type": "issue" } Output is written to ~/.sharedinbox-agent-logs/-.log. -Resume the Claude conversation afterward with: +To resume the Claude conversation, look up the session UUID first: - claude --resume issue-91 + scripts/agent_loop.py list # shows NAME and UUID columns + claude --resume # use the UUID, NOT the session name """ import argparse @@ -225,6 +226,30 @@ def _clear_state() -> None: STATE_FILE.unlink(missing_ok=True) +def _find_session_uuid(session_name: str) -> str | None: + """Return the Claude session UUID for *session_name*, or None if not found. + + Claude stores session metadata in JSONL files; the first entry with + type=="agent-name" contains both the human-readable name and the UUID + needed for ``claude --resume ``. + """ + if not CLAUDE_PROJECTS_DIR.exists(): + return None + for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"): + try: + with jsonl.open() as fh: + for line in fh: + line = line.strip() + if not line: + continue + d = json.loads(line) + if d.get("type") == "agent-name" and d.get("agentName") == session_name: + return d.get("sessionId") + except Exception: + continue + return None + + # ── agent launcher ──────────────────────────────────────────────────────────── @@ -255,7 +280,7 @@ def _start_agent(prompt: str, session_name: str) -> int: proc.stdin.close() print(f"Started agent pid={proc.pid}, log={log_file}") - print(f" Resume: claude --resume {shlex.quote(session_name)}") + print(f" Resume: run 'scripts/agent_loop.py list' to get the UUID-based resume command") return proc.pid @@ -399,7 +424,13 @@ def _run_loop() -> int: return 1 session_name = state.get("session_name") - resume_cmd = f"claude --resume {shlex.quote(session_name)}" if session_name else "" + uuid = _find_session_uuid(session_name) if session_name else None + if uuid: + resume_cmd = f"claude --resume {shlex.quote(uuid)}" + elif session_name: + resume_cmd = f"claude --resume # run: scripts/agent_loop.py list" + else: + resume_cmd = "" git_info = _git_summary() parts = [ f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.", diff --git a/scripts/test_agent_loop.py b/scripts/test_agent_loop.py index b1cd8c8..531bd60 100644 --- a/scripts/test_agent_loop.py +++ b/scripts/test_agent_loop.py @@ -489,5 +489,138 @@ class TestLatestCiRunForBranch(unittest.TestCase): self.assertEqual(result["id"], 10) +class TestFindSessionUuid(unittest.TestCase): + """Tests for _find_session_uuid().""" + + def _write_jsonl(self, directory: Path, filename: str, entries: list) -> Path: + path = directory / filename + with path.open("w") as fh: + for entry in entries: + fh.write(json.dumps(entry) + "\n") + return path + + def test_returns_uuid_for_matching_session_name(self): + with tempfile.TemporaryDirectory() as tmpdir: + projects_dir = Path(tmpdir) + self._write_jsonl(projects_dir, "abc123.jsonl", [ + {"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-abc-123"}, + ]) + orig = agent_loop.CLAUDE_PROJECTS_DIR + agent_loop.CLAUDE_PROJECTS_DIR = projects_dir + try: + result = agent_loop._find_session_uuid("issue-91") + finally: + agent_loop.CLAUDE_PROJECTS_DIR = orig + self.assertEqual(result, "uuid-abc-123") + + def test_returns_none_when_name_does_not_match(self): + with tempfile.TemporaryDirectory() as tmpdir: + projects_dir = Path(tmpdir) + self._write_jsonl(projects_dir, "abc123.jsonl", [ + {"type": "agent-name", "agentName": "issue-99", "sessionId": "uuid-abc-123"}, + ]) + orig = agent_loop.CLAUDE_PROJECTS_DIR + agent_loop.CLAUDE_PROJECTS_DIR = projects_dir + try: + result = agent_loop._find_session_uuid("issue-91") + finally: + agent_loop.CLAUDE_PROJECTS_DIR = orig + self.assertIsNone(result) + + def test_returns_none_when_directory_missing(self): + orig = agent_loop.CLAUDE_PROJECTS_DIR + agent_loop.CLAUDE_PROJECTS_DIR = Path("/nonexistent/path/that/does/not/exist") + try: + result = agent_loop._find_session_uuid("issue-91") + finally: + agent_loop.CLAUDE_PROJECTS_DIR = orig + self.assertIsNone(result) + + def test_returns_none_when_no_agent_name_entry(self): + with tempfile.TemporaryDirectory() as tmpdir: + projects_dir = Path(tmpdir) + self._write_jsonl(projects_dir, "abc123.jsonl", [ + {"type": "message", "content": "hello"}, + ]) + orig = agent_loop.CLAUDE_PROJECTS_DIR + agent_loop.CLAUDE_PROJECTS_DIR = projects_dir + try: + result = agent_loop._find_session_uuid("issue-91") + finally: + agent_loop.CLAUDE_PROJECTS_DIR = orig + self.assertIsNone(result) + + def test_scans_multiple_files_to_find_match(self): + with tempfile.TemporaryDirectory() as tmpdir: + projects_dir = Path(tmpdir) + self._write_jsonl(projects_dir, "aaa.jsonl", [ + {"type": "agent-name", "agentName": "issue-10", "sessionId": "uuid-10"}, + ]) + self._write_jsonl(projects_dir, "bbb.jsonl", [ + {"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-91"}, + ]) + orig = agent_loop.CLAUDE_PROJECTS_DIR + agent_loop.CLAUDE_PROJECTS_DIR = projects_dir + try: + result = agent_loop._find_session_uuid("issue-91") + finally: + agent_loop.CLAUDE_PROJECTS_DIR = orig + self.assertEqual(result, "uuid-91") + + +class TestRunLoopResumeCommand(unittest.TestCase): + """Tests that _run_loop() shows a UUID-based resume command when agent is running.""" + + def _alive_state(self, session_name="issue-91"): + return { + "pid": os.getpid(), # own PID is always alive + "issue": 91, + "started_at": "2026-05-23T12:00:00+00:00", + "type": "issue", + "session_name": session_name, + } + + def test_resume_shows_uuid_when_found(self): + buf = io.StringIO() + fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + with patch("agent_loop._read_state", return_value=self._alive_state()), \ + patch("agent_loop._agent_alive", return_value=True), \ + patch("agent_loop._agent_age_seconds", return_value=600), \ + patch("agent_loop._find_session_uuid", return_value=fake_uuid), \ + patch("agent_loop._git_summary", return_value=""), \ + contextlib.redirect_stdout(buf): + agent_loop._run_loop() + output = buf.getvalue() + self.assertIn(f"claude --resume {fake_uuid}", output) + + def test_resume_shows_list_hint_when_uuid_not_found(self): + buf = io.StringIO() + with patch("agent_loop._read_state", return_value=self._alive_state()), \ + patch("agent_loop._agent_alive", return_value=True), \ + patch("agent_loop._agent_age_seconds", return_value=600), \ + patch("agent_loop._find_session_uuid", return_value=None), \ + patch("agent_loop._git_summary", return_value=""), \ + contextlib.redirect_stdout(buf): + agent_loop._run_loop() + output = buf.getvalue() + self.assertIn("scripts/agent_loop.py list", output) + # Must NOT show the session name as a valid resume argument. + self.assertNotIn("claude --resume issue-91", output) + + def test_resume_not_shown_when_no_session_name(self): + state = self._alive_state() + del state["session_name"] + buf = io.StringIO() + with patch("agent_loop._read_state", return_value=state), \ + patch("agent_loop._agent_alive", return_value=True), \ + patch("agent_loop._agent_age_seconds", return_value=600), \ + patch("agent_loop._find_session_uuid", return_value=None), \ + patch("agent_loop._git_summary", return_value=""), \ + contextlib.redirect_stdout(buf): + agent_loop._run_loop() + output = buf.getvalue() + self.assertNotIn("Resume:", output) + + if __name__ == "__main__": unittest.main()