Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions nerve/agent/backends/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,39 @@
"fable": "ANTHROPIC_DEFAULT_FABLE_MODEL",
}

# The CLI keeps a session's history in ~/.claude/projects/<name>/, where
# <name> 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 #
Expand Down Expand Up @@ -445,11 +478,13 @@ def validate_resume_target(self, native_id: str, cwd: str) -> bool:

~/.claude/projects/<encoded-cwd>/<sdk_session_id>.jsonl

where <encoded-cwd> 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 <encoded-cwd> 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.
Expand All @@ -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(
Expand Down
106 changes: 103 additions & 3 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import math
import os
import re
import time
from pathlib import Path
from types import SimpleNamespace
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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("{}")
Expand All @@ -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()
Expand All @@ -894,14 +901,107 @@ 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("{}")

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
Expand Down
Loading