Files
multi-agent-mux/.agents/skills/lib_py/agents/adapters/claude.py
T
GodopuandClaude Opus 5.5 8a4f651bdf fix(agents): claude follows CLAUDE_CONFIG_DIR (Issue #31, review)
CLAUDE_CONFIG_DIR moves claude's whole config root, sessions/ included, so a
live session started under it was not seen and a stopped one left
file-history and the rest behind. The config root is now CLAUDE_CONFIG_DIR,
else <home>/.claude; the purge and residual scans look at ctx.claude_dir and
the root's own projects/, each once. The test guard drops the variable for
every test like the other agent-home variables (test_issue55 l1 requires
every *_DIR the adapters read to be accounted for).

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

346 lines
14 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, scan_names, _plausible_id
from lib_py.verify_session import workspace_key
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 <home>/.claude/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
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)
paths = []
for pdir in _projects_dirs(ctx):
base = os.path.join(pdir, ctx.ws_key, uuid)
paths += [p for p in (base + ".jsonl", base) if os.path.lexists(p)]
paths += [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:
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 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. projects is not walked deeper; it can be very large."""
if not _plausible_id(uuid):
return []
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
job = self._owned_job(uuid, ctx)
if job:
found.add(job)
found.update(path for path, _, _ in self._session_files(uuid, ctx))
for root in _tmp_roots():
try:
tkeys = os.listdir(root)
except OSError:
continue
found.update(p for p in (os.path.join(root, k, uuid) for k in tkeys) if os.path.lexists(p))
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