Files
multi-agent-mux/.agents/skills/lib_py/agents/adapters/claude.py
T
GodopuandClaude Opus 5.5 9ccd3c1835 fix(agents): claude purge removes every path the session owns (Issue #31)
The claude purge removed only <uuid>.jsonl. It now removes everything the
session alone owns: projects/<key>/<uuid>/, file-history/<uuid>/,
session-env/<uuid>/, jobs/<uuid[:8]>/ when its state.json names the session,
a stale sessions/<pid>.json with that pid's key file (name only, never read)
and <tmp>/claude-<uid>/*/<uuid>/. An id that is empty, all dots or holds
/, .. or * builds no path. has_artifacts finds leftovers without the transcript,
and conversation_in_use also sees a live claude through sessions/<pid>.json
(a `claude -c` names no uuid on its command line); a reused pid is told
apart by ps lstart against the file's startedAt.

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

294 lines
12 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, time, shutil, subprocess
from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, _plausible_id
from lib_py.verify_session import workspace_key
def _config_dir(ctx: DiscoveryContext) -> str:
return os.path.join(ctx.home_dir, ".claude")
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 "/tmp"
cands.append(os.path.join(tmp.rstrip("/") or "/", name))
cands.append(os.path.join("/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
# 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 _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 _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 _owned_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Every existing path that belongs to this session alone (Issue #31).
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.
if not _plausible_id(uuid):
return []
cfg = _config_dir(ctx)
jsonl = self.artifact_path(uuid, ctx)
paths = [p for p in (jsonl, jsonl[:-len(".jsonl")],
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:
paths.append(job)
for path, pid, data in self._session_files(uuid, ctx):
if _session_file_live(data, pid):
continue
paths.append(path)
stem = os.path.basename(path)[:-len(".json")]
sessions = os.path.dirname(path)
if stem.isdigit():
# The key file is matched by name only; its content is never read.
paths.extend(os.path.join(sessions, n) for n in sorted(os.listdir(sessions))
if re.fullmatch(rf"{stem}\.[0-9a-f]{{64}}\.key", n))
for root in _tmp_roots():
try:
keys = sorted(os.listdir(root))
except OSError:
continue
paths.extend(p for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p))
return paths
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:
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.
purged = []
for path in self._owned_paths(uuid, ctx):
_remove(path)
if not os.path.lexists(path) and _label(path) not in purged:
purged.append(_label(path))
return purged
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