Files
multi-agent-mux/.agents/skills/lib_py/agents/adapters/claude.py
T
GodopuandClaude Opus 5.5 dd292c12cb fix(agents): a claude leftover behind a link still selects claude for its residual (Issue #31, review)
has_artifacts counted only the paths a purge may remove. When all that was
left lay behind a linked dir, `purge claude <uuid>` (or auto) found no
adapter and exited 5 with an empty residual, hiding the path the purge
had refused to follow. has_artifacts now counts every candidate; _inside
still decides what is removed, so the CLI exits 0, removes nothing and
lists the linked path as residual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:07:49 +00:00

455 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os, re, json, glob, math, stat, time, shutil, calendar, 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 _canonical(uuid) -> Optional[str]:
"""The id claude itself writes, in lowercase, for any spelling of a
canonical uuid; None for anything else. Any other name in a session's
place is not one: projects/<key>/memory/ (auto memory) sits beside the
session dirs. Another spelling must go through the same checks as
claude's own: on macOS's default case-insensitive APFS it names the
same files, while the ids in session files and on command lines are
compared exactly."""
return uuid.lower() if isinstance(uuid, str) and _UUID.fullmatch(uuid) else None
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
# startedAt is epoch ms: a finite positive number. A bool, a string, NaN
# (every comparison with it is false), an infinity or an int too large
# for a float is no time at all.
started = data.get("startedAt")
if isinstance(started, bool) or not isinstance(started, (int, float)):
return True
try:
started = float(started) / 1000.0
except OverflowError:
return True
if not math.isfinite(started) or started <= 0:
return True
# lstart in UTC: a local time is ambiguous in the hour a DST change repeats.
try:
out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True,
timeout=5, env=dict(os.environ, LC_ALL="C", TZ="UTC0")).stdout
lstart = calendar.timegm(time.strptime(" ".join(out.split()), "%a %b %d %H:%M:%S %Y"))
except Exception:
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 []
canon = _canonical(uuid)
cfg = _config_dir(ctx)
found = []
for pdir in _projects_dirs(ctx):
base = os.path.join(pdir, ctx.ws_key, canon or uuid)
found += [(pdir, p) for p in ((base + ".jsonl", base) if canon else (base + ".jsonl",))
if os.path.lexists(p)]
if not canon:
return found
uuid = canon
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),
# and so does one behind a link: the purge keeps it, but picking
# claude is what gets it reported as residual.
return bool(self._candidates(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.
canon = _canonical(uuid)
if not canon:
return False
# claude's own spelling and the one asked about, on a command line.
if any(conversation_in_use(u, [self.artifact_path(canon, ctx)]) for u in {canon, uuid}):
return True
return any(_session_file_live(data, pid) for _, pid, data in self._session_files(canon, ctx))
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
return [f"a claude process whose session file under {os.path.join(_config_dir(ctx), 'sessions')}/ names it"] \
+ super().in_use_hints(_canonical(uuid) or 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.
uuid = _canonical(uuid) or uuid
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 _canonical(uuid):
return sorted(p for _, p in self._candidates(uuid, ctx))
uuid = _canonical(uuid)
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