From 941ed06ac3fa831b6c5f4d39d949abfb1a341eca Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:25:25 +0000 Subject: [PATCH] Match the Claude CLI's project-dir encoding in validate_resume_target The CLI names a session's history directory after the cwd with every character outside [a-zA-Z0-9] replaced by '-'. The resume check replaced only '/', so for a workspace path with '_' or '.' it looked in a directory that does not exist, and every client rebuild started a new conversation. Use the CLI's regex, per UTF-16 code unit. For a name over 200 characters the CLI cuts it and adds '-'; match on the cut name plus '-', as the CLI does when it looks up a session. Co-Authored-By: Claude Opus 5.5 --- nerve/agent/backends/claude.py | 58 +++++++++++++++--- tests/test_engine.py | 106 ++++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 12 deletions(-) diff --git a/nerve/agent/backends/claude.py b/nerve/agent/backends/claude.py index dbedefb61..24e8af73e 100644 --- a/nerve/agent/backends/claude.py +++ b/nerve/agent/backends/claude.py @@ -118,6 +118,39 @@ "fable": "ANTHROPIC_DEFAULT_FABLE_MODEL", } +# The CLI keeps a session's history in ~/.claude/projects//, where +# is the cwd with every character outside [a-zA-Z0-9] replaced by +# '-'. When that name is longer than _PROJECT_DIR_MAX_LEN, the CLI cuts it +# to that length and adds '-' plus a hash of the cwd. +_PROJECT_DIR_MAX_LEN = 200 +_PROJECT_DIR_UNSAFE = re.compile(r"[^a-zA-Z0-9]") + + +def _encode_project_dir(path: str) -> str: + """Return the CLI's project directory name for *path*, before the cut. + + The CLI's regex works on UTF-16 code units, so a character outside + the Basic Multilingual Plane becomes two '-'. + """ + return _PROJECT_DIR_UNSAFE.sub( + lambda m: "--" if ord(m.group()) > 0xFFFF else "-", path, + ) + + +def _long_project_dir_names(projects: str, encoded: str) -> list[str]: + """Return the entries of *projects* that can hold the history of a + cwd whose encoded name is longer than _PROJECT_DIR_MAX_LEN. + + The CLI matches such directories on the cut name plus '-' when it + looks up a session, so do the same and do not compute the hash. + """ + prefix = encoded[:_PROJECT_DIR_MAX_LEN] + "-" + try: + with os.scandir(projects) as entries: + return [e.name for e in entries if e.name.startswith(prefix)] + except FileNotFoundError: + return [] + # ------------------------------------------------------------------ # # SDK message → normalized events # @@ -445,11 +478,13 @@ def validate_resume_target(self, native_id: str, cwd: str) -> bool: ~/.claude/projects//.jsonl - where is the absolute cwd path with every '/' - replaced by '-'. The CLI resolves the cwd symlink before - encoding, so when the workspace is itself a symlink the history - lives under the *realpath*-encoded directory. Check the realpath - first and fall back to the unresolved path. + where is the absolute cwd path with every character + outside [a-zA-Z0-9] replaced by '-'. A name over 200 characters + gets a hash suffix (see ``_PROJECT_DIR_MAX_LEN``). The CLI + resolves the cwd symlink before encoding, so when the workspace + is itself a symlink the history lives under the + *realpath*-encoded directory. Check the realpath first and fall + back to the unresolved path. Best-effort: any unexpected error returns True so we still attempt the resume and let the CLI surface the real error. @@ -460,10 +495,15 @@ def validate_resume_target(self, native_id: str, cwd: str) -> bool: if cwd not in bases: bases.append(cwd) for base in bases: - encoded = base.replace("/", "-") - jsonl = projects + "/" + encoded + "/" + native_id + ".jsonl" - if os.path.isfile(jsonl): - return True + encoded = _encode_project_dir(base) + if len(encoded) <= _PROJECT_DIR_MAX_LEN: + names = [encoded] + else: + names = _long_project_dir_names(projects, encoded) + for name in names: + jsonl = projects + "/" + name + "/" + native_id + ".jsonl" + if os.path.isfile(jsonl): + return True return False except Exception as e: logger.debug( diff --git a/tests/test_engine.py b/tests/test_engine.py index 87613d699..61636b147 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6,6 +6,7 @@ import json import math import os +import re import time from pathlib import Path from types import SimpleNamespace @@ -774,6 +775,12 @@ def _make_backend() -> ClaudeBackend: return ClaudeBackend(SimpleNamespace(config=lambda: SimpleNamespace())) +def _cli_project_dir(path: str) -> str: + """The CLI's project directory name for an ASCII *path* of up to 200 + encoded characters.""" + return re.sub(r"[^a-zA-Z0-9]", "-", path) + + class TestValidateResumeTarget: WORKSPACE = "/root/nerve-workspace" @@ -856,7 +863,7 @@ def test_symlinked_workspace_checks_realpath(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(fake_home)) sid = "11111111-2222-3333-4444-555555555555" - encoded = os.path.realpath(str(link_ws)).replace("/", "-") + encoded = _cli_project_dir(os.path.realpath(str(link_ws))) proj_dir = fake_home / ".claude" / "projects" / encoded proj_dir.mkdir(parents=True) (proj_dir / f"{sid}.jsonl").write_text("{}") @@ -875,7 +882,7 @@ def test_symlinked_workspace_missing_returns_false(self, tmp_path, monkeypatch): fake_home = tmp_path / "home" monkeypatch.setenv("HOME", str(fake_home)) - encoded = os.path.realpath(str(link_ws)).replace("/", "-") + encoded = _cli_project_dir(os.path.realpath(str(link_ws))) (fake_home / ".claude" / "projects" / encoded).mkdir(parents=True) backend = _make_backend() @@ -894,7 +901,7 @@ def test_falls_back_to_unresolved_path(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(fake_home)) sid = "abcd" - encoded_unresolved = str(link_ws).replace("/", "-") + encoded_unresolved = _cli_project_dir(str(link_ws)) proj_dir = fake_home / ".claude" / "projects" / encoded_unresolved proj_dir.mkdir(parents=True) (proj_dir / f"{sid}.jsonl").write_text("{}") @@ -902,6 +909,99 @@ def test_falls_back_to_unresolved_path(self, tmp_path, monkeypatch): backend = _make_backend() assert backend.validate_resume_target(sid, str(link_ws)) is True + def test_path_encodes_non_alphanumerics(self): + """Every character outside [a-zA-Z0-9] becomes '-', as in the CLI.""" + backend = _make_backend() + captured: dict = {} + + def _capture(path: str) -> bool: + captured["path"] = path + return True + + with patch("nerve.agent.backends.claude.os.path.realpath", + side_effect=lambda p: p), \ + patch("nerve.agent.backends.claude.os.path.isfile", + side_effect=_capture): + backend.validate_resume_target("sid-abc", "/srv/my_agent/ws.v1") + + assert captured["path"].endswith("/-srv-my-agent-ws-v1/sid-abc.jsonl") + + def test_path_encodes_astral_character_as_two_dashes(self): + """The CLI replaces per UTF-16 code unit, so a character outside + the Basic Multilingual Plane becomes two '-'.""" + backend = _make_backend() + captured: dict = {} + + def _capture(path: str) -> bool: + captured["path"] = path + return True + + with patch("nerve.agent.backends.claude.os.path.realpath", + side_effect=lambda p: p), \ + patch("nerve.agent.backends.claude.os.path.isfile", + side_effect=_capture): + backend.validate_resume_target("sid-abc", "/srv/ws-\U0001F600") + + assert captured["path"].endswith("/-srv-ws---/sid-abc.jsonl") + + def test_workspace_with_underscore_and_dot(self, tmp_path, monkeypatch): + """A workspace path with '_' and '.' finds its history under the + directory name the CLI writes.""" + ws = tmp_path / "my_agent" / "ws.v1" + ws.mkdir(parents=True) + + fake_home = tmp_path / "home" + monkeypatch.setenv("HOME", str(fake_home)) + + sid = "11111111-2222-3333-4444-555555555555" + encoded = _cli_project_dir(os.path.realpath(str(ws))) + assert "-my-agent-ws-v1" in encoded + proj_dir = fake_home / ".claude" / "projects" / encoded + proj_dir.mkdir(parents=True) + (proj_dir / f"{sid}.jsonl").write_text("{}") + + backend = _make_backend() + assert backend.validate_resume_target(sid, str(ws)) is True + assert backend.validate_resume_target("does-not-exist", str(ws)) is False + + def test_long_path_matches_cut_name_prefix(self, tmp_path, monkeypatch): + """An encoded name over 200 characters is cut to 200 and gets a + hash suffix. The lookup matches on the cut name plus '-'.""" + ws = tmp_path / ("w" * 220) + ws.mkdir() + + fake_home = tmp_path / "home" + monkeypatch.setenv("HOME", str(fake_home)) + projects = fake_home / ".claude" / "projects" + + encoded = _cli_project_dir(os.path.realpath(str(ws))) + assert len(encoded) > 200 + sid = "11111111-2222-3333-4444-555555555555" + (projects / (encoded[:200] + "-other")).mkdir(parents=True) + proj_dir = projects / (encoded[:200] + "-1a2b3c") + proj_dir.mkdir() + (proj_dir / f"{sid}.jsonl").write_text("{}") + # Differs from the cut name in its last character: not this cwd. + decoy = projects / (encoded[:199] + "x-1a2b3c") + decoy.mkdir() + (decoy / "decoy.jsonl").write_text("{}") + + backend = _make_backend() + assert backend.validate_resume_target(sid, str(ws)) is True + assert backend.validate_resume_target("does-not-exist", str(ws)) is False + assert backend.validate_resume_target("decoy", str(ws)) is False + + def test_long_path_without_projects_dir_returns_false( + self, tmp_path, monkeypatch, + ): + """No projects directory at all means no history, not an error.""" + ws = tmp_path / ("w" * 220) + ws.mkdir() + monkeypatch.setenv("HOME", str(tmp_path / "home")) + + backend = _make_backend() + assert backend.validate_resume_target("some-session-id", str(ws)) is False + # --------------------------------------------------------------------------- # ClaudeBackend._build_hooks — background-agent permission parity