From bd09c6a133100a05823bb38d190d29ff0419f8e2 Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 16 Oct 2026 03:19:02 +0000 Subject: [PATCH] fix(agents): a claude purge takes directories only for a canonical uuid (Issue #31, Mac review) projects//memory/ (Claude Code's auto memory) sits beside the session dirs, and `purge claude memory` removed it whole. Only an 8-4-4-4-12 hex id now reaches the session's directories, session files, jobs and tmp dirs. Any other id keeps the pre-#31 reach, the `.jsonl` file alone (test_a4 still purges "uuid-c"); its residual is that file and nothing holds it in use. The shared _plausible_id is unchanged for the other adapters. Co-Authored-By: Claude Opus 5.5 --- .../skills/lib_py/agents/adapters/claude.py | 27 +++++++++++-- tests/test_issue31_purge_residual.py | 39 ++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/.agents/skills/lib_py/agents/adapters/claude.py b/.agents/skills/lib_py/agents/adapters/claude.py index 3bec452..4fecb76 100644 --- a/.agents/skills/lib_py/agents/adapters/claude.py +++ b/.agents/skills/lib_py/agents/adapters/claude.py @@ -4,6 +4,16 @@ from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_ from lib_py.verify_session import workspace_key +_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE) + + +def _is_uuid(uuid) -> bool: + """claude names every session by a canonical uuid. Any other name in a + session's place is not one: projects//memory/ (auto memory) sits + beside the session dirs.""" + return isinstance(uuid, str) and bool(_UUID.fullmatch(uuid)) + + def _config_dir(ctx: DiscoveryContext) -> str: """claude's config root: CLAUDE_CONFIG_DIR moves all of it (sessions/, file-history/, session-env/, jobs/, projects/).""" @@ -262,14 +272,19 @@ class ClaudeAgentAdapter(BaseAgentAdapter): Shared files that merely mention it (history.jsonl, ~/.claude.json, backups, paste-cache) are not the session's and are never listed.""" # Checked before any path is built: whole directories named by the id - # are removed, so "", "." or ".." would reach file-history/ itself. + # are removed, so "", "." or ".." would reach file-history/ itself, + # and "memory" the auto memory. An id that is not a uuid keeps the + # pre-#31 reach: the `.jsonl` file alone. if not _plausible_id(uuid): return [] cfg = _config_dir(ctx) paths = [] for pdir in _projects_dirs(ctx): base = os.path.join(pdir, ctx.ws_key, uuid) - paths += [p for p in (base + ".jsonl", base) if os.path.lexists(p)] + paths += [p for p in ((base + ".jsonl", base) if _is_uuid(uuid) else (base + ".jsonl",)) + if os.path.lexists(p)] + if not _is_uuid(uuid): + return paths paths += [p for p in (os.path.join(cfg, "file-history", uuid), os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)] job = self._owned_job(uuid, ctx) @@ -293,6 +308,10 @@ class ClaudeAgentAdapter(BaseAgentAdapter): return bool(self._owned_paths(uuid, ctx)) def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool: + # No claude process holds a name that is not a uuid; searching + # command lines for one ("a/b", "memory") only finds strangers. + if not _is_uuid(uuid): + return False if conversation_in_use(uuid, [self.artifact_path(uuid, ctx)]): return True return any(_session_file_live(data, pid) for _, pid, data in self._session_files(uuid, ctx)) @@ -322,8 +341,8 @@ class ClaudeAgentAdapter(BaseAgentAdapter): roots (two levels), plus the content-matched jobs dir and session files with their (masked) key files. projects is not walked deeper; it can be very large.""" - if not _plausible_id(uuid): - return [] + if not _is_uuid(uuid): + return self._owned_paths(uuid, ctx) cfg = _config_dir(ctx) pdirs = _projects_dirs(ctx) found = set(scan_names(cfg, uuid, skip=pdirs)) diff --git a/tests/test_issue31_purge_residual.py b/tests/test_issue31_purge_residual.py index 315a74a..251fbf7 100644 --- a/tests/test_issue31_purge_residual.py +++ b/tests/test_issue31_purge_residual.py @@ -262,18 +262,55 @@ def test_claude_residual_lists_leftovers_then_nothing(home, tmp_path, conv, othe assert adapter.residual_artifacts(conv, ctx) == [str(stray)] -@pytest.mark.parametrize("bad", ["", ".", "..", "*", "../x", "a/b"]) +@pytest.mark.parametrize("bad", ["", ".", "..", "*", "../x", "a/b", "memory", " ", "notes", + "12345678-1234-1234-1234-12345678901g", "{conv} ", "x{conv}"]) def test_claude_rejects_malformed_uuid(home, tmp_path, conv, bad): + bad = bad.format(conv=conv) ws = tmp_path / "ws" ws.mkdir() t = _tree(home, ws, conv, tmp_path / "ctmp") + # Claude Code's auto memory sits beside the session dirs, under the one + # non-uuid name there. + memory = home / ".claude" / "projects" / workspace_key(str(ws)) / "memory" / "MEMORY.md" + memory.parent.mkdir() + memory.write_text("m") + named = [home / ".claude" / d / bad / "x" for d in ("file-history", "session-env") + if bad.strip(". ") and "/" not in bad and "*" not in bad] + for p in named: + p.parent.mkdir(parents=True) + p.write_text("x") adapter = get_adapter("claude") ctx = _ctx(ws, home) assert adapter.purge_artifacts(bad, ctx) == [] assert adapter.residual_artifacts(bad, ctx) == [] assert adapter.has_artifacts(bad, ctx) is False + assert adapter.conversation_in_use(bad, ctx) is False assert all(p.exists() for p in t.values()) + assert memory.exists() and all(p.exists() for p in named) + + +def test_a_non_uuid_id_takes_only_its_transcript_file(home, tmp_path): + """The pre-#31 reach, kept for ids that are not uuids (test_a4 purges + "uuid-c"): the `.jsonl` file, never a directory of that name.""" + ws = tmp_path / "ws" + ws.mkdir() + proj = home / ".claude" / "projects" / workspace_key(str(ws)) + proj.mkdir() + jsonl = proj / "uuid-c.jsonl" + jsonl.write_text("{}\n") + kept = [proj / "uuid-c" / "a", home / ".claude" / "file-history" / "uuid-c" / "b"] + for p in kept: + p.parent.mkdir(parents=True) + p.write_text("x") + adapter = get_adapter("claude") + ctx = _ctx(ws, home) + + assert adapter.has_artifacts("uuid-c", ctx) is True + assert adapter.residual_artifacts("uuid-c", ctx) == [str(jsonl)] + assert adapter.purge_artifacts("uuid-c", ctx) == [str(jsonl)] + assert not jsonl.exists() and all(p.exists() for p in kept) + assert adapter.residual_artifacts("uuid-c", ctx) == [] def test_purge_cli_resolves_claude_from_leftovers_only(home, tmp_path, conv):