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//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-. 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/.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/.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/.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// 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 `.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