A tmp-root entry <key> linking outside made `<key>/<uuid>` look like a plain directory, and rmtree removed what lay behind the link; projects/<key>, file-history/ and the other config dirs had no such check either. Each candidate now keeps the root it was found under (config, projects or tmp), and is purged only when every component between that root and the leaf is a real directory and its parent resolves inside the resolved root. A root may itself be a link (/tmp -> /private/tmp), a leaf link is still removed as the link alone, and a candidate behind a link is reported as residual instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
429 lines
17 KiB
Python
429 lines
17 KiB
Python
import os, re, json, glob, stat, time, shutil, subprocess
|
||
from typing import Optional, Any
|
||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names, _plausible_id
|
||
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/<key>/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/)."""
|
||
return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(ctx.home_dir, ".claude")
|
||
|
||
|
||
def _projects_dirs(ctx: DiscoveryContext) -> list:
|
||
"""ctx.claude_dir (CLAUDE_PROJECT_DIR, else the config root's projects/)
|
||
and the config root's own projects/, each once."""
|
||
dirs, seen = [], set()
|
||
for d in (ctx.claude_dir, os.path.join(_config_dir(ctx), "projects")):
|
||
real = os.path.realpath(d)
|
||
if real not in seen:
|
||
seen.add(real)
|
||
dirs.append(d)
|
||
return dirs
|
||
|
||
|
||
# Node's os.tmpdir() when no TMPDIR/TMP/TEMP is set; tests point it elsewhere.
|
||
_SYSTEM_TMP = "/tmp"
|
||
|
||
|
||
def _tmp_roots() -> list:
|
||
"""The per-uid temp roots claude may have used: ${CLAUDE_CODE_TMPDIR:-
|
||
os.tmpdir()}/claude-<uid>. A sandboxed tool shell sets CLAUDE_CODE_TMPDIR
|
||
to that per-uid root itself. The session that wrote the files may have
|
||
run under another env, so every candidate that exists is returned."""
|
||
name = f"claude-{os.getuid()}"
|
||
cands = []
|
||
override = os.environ.get("CLAUDE_CODE_TMPDIR", "")
|
||
if override:
|
||
cands.append(os.path.join(override, name))
|
||
if os.path.basename(override.rstrip("/")) == name:
|
||
cands.append(override)
|
||
# Node's os.tmpdir() on POSIX, not tempfile.gettempdir() (which probes
|
||
# writability and falls back to the cwd).
|
||
tmp = os.environ.get("TMPDIR") or os.environ.get("TMP") or os.environ.get("TEMP") or _SYSTEM_TMP
|
||
cands.append(os.path.join(tmp.rstrip("/") or "/", name))
|
||
cands.append(os.path.join(_SYSTEM_TMP, name))
|
||
roots, seen = [], set()
|
||
for c in cands:
|
||
real = os.path.realpath(c)
|
||
if os.path.isdir(c) and real not in seen:
|
||
seen.add(real)
|
||
roots.append(c)
|
||
return roots
|
||
|
||
|
||
def _session_file_live(data: dict, pid) -> bool:
|
||
"""The process that wrote this sessions/<pid>.json is still running.
|
||
|
||
A dead pid is stale. A live pid whose start (`ps -o lstart=`) is later
|
||
than the file's startedAt was reused by another process, so it is stale
|
||
too. Anything that cannot be told is taken as live: a purge is then
|
||
refused, never run over a live conversation."""
|
||
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
|
||
return True
|
||
try:
|
||
os.kill(pid, 0)
|
||
except ProcessLookupError:
|
||
return False
|
||
except OSError:
|
||
pass
|
||
try:
|
||
out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True,
|
||
timeout=5, env=dict(os.environ, LC_ALL="C")).stdout
|
||
lstart = time.mktime(time.strptime(" ".join(out.split()), "%a %b %d %H:%M:%S %Y"))
|
||
started = float(data["startedAt"]) / 1000.0
|
||
except Exception:
|
||
return True
|
||
if started <= 0:
|
||
return True
|
||
# startedAt is written 1.7-3.2 s after the process starts; 2 s covers
|
||
# lstart's one-second resolution.
|
||
return lstart <= started + 2
|
||
|
||
|
||
def _inside(root: str, path: str) -> bool:
|
||
"""`path` is reached from `root` through real directories only: no
|
||
component between the two is a symlink, and its parent resolves inside
|
||
the resolved root. The root may be a link itself (/tmp -> /private/tmp,
|
||
a linked ~/.claude), and so may `path`: _remove then takes the link alone."""
|
||
cur = root
|
||
for part in os.path.relpath(os.path.dirname(path), root).split(os.sep):
|
||
cur = os.path.join(cur, part)
|
||
try:
|
||
if not stat.S_ISDIR(os.lstat(cur).st_mode):
|
||
return False
|
||
except OSError:
|
||
return False
|
||
real_root = os.path.realpath(root)
|
||
return os.path.realpath(os.path.dirname(path)).startswith(real_root.rstrip(os.sep) + os.sep)
|
||
|
||
|
||
def _remove(path: str) -> None:
|
||
try:
|
||
if os.path.islink(path) or not os.path.isdir(path):
|
||
os.remove(path)
|
||
else:
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _key_files(session_file: str) -> list:
|
||
"""The key files of the pid a sessions/<pid>.json is named after,
|
||
matched by name only; their content is never read."""
|
||
d, name = os.path.split(session_file)
|
||
stem = name[:-len(".json")]
|
||
if not stem.isdigit():
|
||
return []
|
||
try:
|
||
names = sorted(os.listdir(d))
|
||
except OSError:
|
||
return []
|
||
return [os.path.join(d, n) for n in names if re.fullmatch(rf"{stem}\.[0-9a-f]{{64}}\.key", n)]
|
||
|
||
|
||
def _remove_job(job: str) -> None:
|
||
"""state.json goes last: it is the only proof that the dir is this
|
||
session's, so a dir that could not be emptied keeps it."""
|
||
if os.path.islink(job):
|
||
_remove(job)
|
||
return
|
||
try:
|
||
names = os.listdir(job)
|
||
except OSError:
|
||
return
|
||
for n in names:
|
||
if n != "state.json":
|
||
_remove(os.path.join(job, n))
|
||
try:
|
||
left = os.listdir(job)
|
||
except OSError:
|
||
return
|
||
if left in ([], ["state.json"]):
|
||
_remove(job)
|
||
|
||
|
||
def _label(path: str) -> str:
|
||
"""How a path is printed: a session key file is named by pid only, its
|
||
hex part is not shown."""
|
||
d, name = os.path.split(path)
|
||
m = re.fullmatch(r"(\d+)\.[0-9a-f]{64}\.key", name)
|
||
return os.path.join(d, f"{m.group(1)}.*.key") if m else path
|
||
|
||
|
||
class ClaudeAgentAdapter(BaseAgentAdapter):
|
||
@property
|
||
def name(self) -> str:
|
||
return 'claude'
|
||
|
||
@property
|
||
def own_key(self) -> str:
|
||
return 'claude_session_id_own'
|
||
|
||
@property
|
||
def ready_tokens(self) -> str:
|
||
return 'Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
|
||
|
||
@property
|
||
def exit_key(self) -> str:
|
||
return '/exit'
|
||
|
||
@property
|
||
def delegate_agent_key(self) -> str:
|
||
return 'claude-code'
|
||
|
||
@property
|
||
def identity_cache_fields(self) -> tuple:
|
||
return ('session_id', 'session_jsonl', 'session_size_bytes', 'session_lines')
|
||
|
||
@property
|
||
def input_prompt(self) -> str:
|
||
return '❯'
|
||
|
||
@property
|
||
def input_placeholder(self) -> str:
|
||
return ''
|
||
|
||
@property
|
||
def input_rule_pattern(self) -> str:
|
||
return '─{10,}'
|
||
|
||
@property
|
||
def modal_tokens(self) -> Optional[str]:
|
||
return 'Try the new fullscreen renderer\\?'
|
||
|
||
@property
|
||
def startup_dialogs(self) -> list:
|
||
# The ladder handle_startup_dialogs used to hardcode, unchanged.
|
||
return [
|
||
('Do you trust the files|Yes, I trust this folder|Quick safety check', ['Enter'], 'trust'),
|
||
# Fullscreen-renderer upsell, not the idle /tui tip: Enter would
|
||
# accept and restart the session without the permission flags.
|
||
('Yes, try it', ['Escape'], 'notice'),
|
||
('Yes, proceed', ['Down', 'Enter'], 'trust'),
|
||
('Resuming the full session', ['Enter'], 'notice'),
|
||
('Press Enter to continue', ['Enter'], 'notice'),
|
||
]
|
||
|
||
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||
return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl"
|
||
|
||
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||
path = self.artifact_path(uuid, ctx)
|
||
if not os.path.exists(path):
|
||
return False
|
||
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
||
return False
|
||
try:
|
||
valid_session = False
|
||
found_cwd = None
|
||
with open(path) as f:
|
||
for _ in range(50):
|
||
line = f.readline()
|
||
if not line:
|
||
break
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
payload = json.loads(line)
|
||
if payload.get("sessionId") == uuid:
|
||
valid_session = True
|
||
if payload.get("cwd"):
|
||
found_cwd = payload.get("cwd")
|
||
break
|
||
except Exception:
|
||
pass
|
||
if not valid_session:
|
||
return False
|
||
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
|
||
return False
|
||
except Exception:
|
||
return False
|
||
return True
|
||
|
||
def _session_files(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||
"""(path, pid, data) of each sessions/<pid>.json naming this session -
|
||
the registry of running claude processes, which a live `claude -c`
|
||
is found by even though its command line carries no uuid."""
|
||
found = []
|
||
for path in sorted(glob.glob(os.path.join(_config_dir(ctx), "sessions", "*.json"))):
|
||
try:
|
||
with open(path) as f:
|
||
data = json.load(f)
|
||
except Exception:
|
||
continue
|
||
if not isinstance(data, dict) or data.get("sessionId") != uuid:
|
||
continue
|
||
pid = data.get("pid")
|
||
if not isinstance(pid, int):
|
||
stem = os.path.basename(path)[:-5]
|
||
pid = int(stem) if stem.isdigit() else None
|
||
found.append((path, pid, data))
|
||
return found
|
||
|
||
def _owned_job(self, uuid: str, ctx: DiscoveryContext) -> Optional[str]:
|
||
"""jobs/<first 8 chars>/ when its state.json names this session; the
|
||
short name alone can be another session's."""
|
||
job = os.path.join(_config_dir(ctx), "jobs", uuid[:8])
|
||
try:
|
||
with open(os.path.join(job, "state.json")) as f:
|
||
if json.load(f).get("sessionId") == uuid:
|
||
return job
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _candidates(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||
"""(root, path) of every existing path that belongs to this session
|
||
alone (Issue #31), with the root (config, projects or tmp) it was
|
||
found under. 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,
|
||
# and "memory" the auto memory. An id that is not a uuid keeps the
|
||
# pre-#31 reach: the `<id>.jsonl` file alone.
|
||
if not _plausible_id(uuid):
|
||
return []
|
||
cfg = _config_dir(ctx)
|
||
found = []
|
||
for pdir in _projects_dirs(ctx):
|
||
base = os.path.join(pdir, ctx.ws_key, uuid)
|
||
found += [(pdir, 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 found
|
||
found += [(cfg, 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)
|
||
if job:
|
||
found.append((cfg, job))
|
||
for path, pid, data in self._session_files(uuid, ctx):
|
||
if not _session_file_live(data, pid):
|
||
# Keys before the session file: the file is what ties them to
|
||
# this session, so it must outlive them.
|
||
found += [(cfg, p) for p in _key_files(path) + [path]]
|
||
for root in _tmp_roots():
|
||
try:
|
||
keys = sorted(os.listdir(root))
|
||
except OSError:
|
||
continue
|
||
found += [(root, p) for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p)]
|
||
return found
|
||
|
||
def _owned_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||
# What lies behind a linked dir below its root is not the session's:
|
||
# it is kept, and residual_artifacts reports it.
|
||
return [p for root, p in self._candidates(uuid, ctx) if _inside(root, p)]
|
||
|
||
def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||
# Leftovers count without the transcript (a pre-#31 purge left them).
|
||
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))
|
||
|
||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||
# Best effort: never raises (stop runs this inside its registry
|
||
# transaction). What could not be removed is not listed here and
|
||
# shows up in residual_artifacts instead.
|
||
# Ownership evidence (a job's state.json, a session file) is removed
|
||
# only after what it vouches for, so a retry can still find the rest.
|
||
purged = []
|
||
job = self._owned_job(uuid, ctx)
|
||
for path in self._owned_paths(uuid, ctx):
|
||
if path == job:
|
||
_remove_job(path)
|
||
elif path.endswith(".json") and any(os.path.lexists(k) for k in _key_files(path)):
|
||
continue
|
||
else:
|
||
_remove(path)
|
||
if not os.path.lexists(path) and _label(path) not in purged:
|
||
purged.append(_label(path))
|
||
return purged
|
||
|
||
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||
"""What still carries this session: names holding the uuid under the
|
||
config root, in projects (two levels: key, then entry) and in the tmp
|
||
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 _is_uuid(uuid):
|
||
return sorted(p for _, p in self._candidates(uuid, ctx))
|
||
cfg = _config_dir(ctx)
|
||
pdirs = _projects_dirs(ctx)
|
||
found = set(scan_names(cfg, uuid, skip=pdirs))
|
||
for pdir in pdirs:
|
||
try:
|
||
keys = sorted(os.listdir(pdir))
|
||
except OSError:
|
||
keys = []
|
||
for key in keys:
|
||
kdir = os.path.join(pdir, key)
|
||
if uuid in key:
|
||
found.add(kdir)
|
||
elif os.path.isdir(kdir):
|
||
try:
|
||
found.update(os.path.join(kdir, n) for n in os.listdir(kdir) if uuid in n)
|
||
except OSError:
|
||
pass
|
||
# Every candidate, inside its root or behind a link (never purged).
|
||
found.update(_label(p) for _, p in self._candidates(uuid, ctx))
|
||
for path, _, _ in self._session_files(uuid, ctx):
|
||
found.add(path)
|
||
found.update(_label(k) for k in _key_files(path))
|
||
return sorted(found)
|
||
|
||
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||
if use_wrapper or not session_uuid:
|
||
return f"{binary} --dangerously-skip-permissions"
|
||
return f"{binary} --dangerously-skip-permissions --session-id {session_uuid}"
|
||
|
||
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||
if materialized and session_uuid:
|
||
return f"{binary} --dangerously-skip-permissions -r {session_uuid}"
|
||
elif session_uuid:
|
||
return f"{binary} --dangerously-skip-permissions --session-id {session_uuid}"
|
||
return f"{binary} --dangerously-skip-permissions"
|
||
|
||
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||
if run_cmd is not None:
|
||
try:
|
||
rc, stdout, stderr = run_cmd(['claude', 'auth', 'status'])
|
||
return rc == 0 and ('"loggedIn":true' in (stdout or '').replace(' ', ''))
|
||
except Exception:
|
||
return False
|
||
try:
|
||
res = subprocess.run(['claude', 'auth', 'status'], capture_output=True, text=True)
|
||
return res.returncode == 0 and ('"loggedIn":true' in (res.stdout or '').replace(' ', ''))
|
||
except Exception:
|
||
return False
|
||
|
||
def discover(self, ctx: DiscoveryContext) -> list:
|
||
key = workspace_key(ctx.workspace)
|
||
proj = f"{ctx.claude_dir}/{key}"
|
||
candidates = []
|
||
if os.path.isdir(proj):
|
||
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
||
cand = os.path.basename(j)[:-6]
|
||
if cand and self.verify_artifact(cand, ctx):
|
||
candidates.append(cand)
|
||
return candidates
|