# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext import os, json, sqlite3, shutil, subprocess from typing import Optional, Dict, Any, List from lib_py.paths import resolve_home from lib_py.verify_session import verify_session_uuid, workspace_key # Session-id variables the agent CLIs export to their tool shells (the list # orc_onboard.sh's auto-detection reads). AGENT_SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID", "HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID") # Process basenames that are agent CLIs (orc_onboard.sh detect_nearest_agent). AGENT_CLI_NAMES = ("claude", "agy", "hermes", "grok", "opencode", "codex") def _is_agent_cli(command: str) -> bool: """An agent CLI process: some token before the first option has an agent basename (`claude …`, `node …/claude …`, `/bin/sh …/agy …`) - the same reading orc_onboard.sh's detect_nearest_agent applies.""" for tok in command.split(): if tok.startswith("-"): break if os.path.basename(tok) in AGENT_CLI_NAMES: return True return False def conversation_in_use(uuid: str, paths=()) -> bool: """A live process still references the conversation (Issues #10, #13). A purge must not touch a conversation that is in use, which is any of: - the caller runs inside that conversation's own agent session: one of its session-id variables (AGENT_SESSION_ENV) equals the uuid. An orchestrator purging itself from its own tool shell hits exactly this, and claude keeps no transcript open, so neither check below would see it; - a process outside this purge's own helper chain names the uuid on its command line (`claude -r `, `agy --conversation `, ...). The helper chain - this python and the shells/scripts that launched it - names the uuid too and is excluded, but only up to the nearest agent CLI ancestor: that ancestor is the session the purge runs in, and if its command line carries the uuid it is the conversation's live process; - one of `paths` is held open (lsof, where available). """ for var in AGENT_SESSION_ENV: if os.environ.get(var, "").strip() == uuid: return True try: out = subprocess.run(["ps", "-eo", "pid=,ppid=,args="], capture_output=True, text=True, timeout=10).stdout except Exception: out = "" parents: Dict[int, int] = {} commands: Dict[int, str] = {} holders: List[int] = [] for line in out.splitlines(): parts = line.split(None, 2) if len(parts) < 2 or not (parts[0].isdigit() and parts[1].isdigit()): continue pid, ppid = int(parts[0]), int(parts[1]) parents[pid] = ppid commands[pid] = parts[2] if len(parts) > 2 else "" if uuid in commands[pid]: holders.append(pid) if holders: me = os.getpid() helper_chain = set() p = me while p and p not in helper_chain: if p != me and _is_agent_cli(commands.get(p, "")): break helper_chain.add(p) p = parents.get(p, 0) if any(h not in helper_chain for h in holders): return True existing = [p for p in paths if p and os.path.exists(p)] if existing and shutil.which("lsof"): try: r = subprocess.run(["lsof", "-t"] + existing, capture_output=True, text=True, timeout=10) if r.stdout.strip(): return True except Exception: pass return False class SpawnSpec: def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None): self.agent_name = agent_name self.session_name = session_name self.command = command self.env = env or {} class DiscoveryContext: def __init__(self, workspace: str, agent_name: str = "", home_dir: Optional[str] = None, claude_dir: Optional[str] = None, epoch: int = 0, row: Optional[Dict[str, Any]] = None, mode: str = "discover"): self.workspace = workspace self.agent_name = agent_name self.home_dir = resolve_home(home_dir) self.claude_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{self.home_dir}/.claude/projects") self.epoch = epoch self.row = row or {} self.mode = mode @property def ws_key(self) -> str: return workspace_key(self.workspace) @property def cwd(self) -> str: return (self.row.get("pane", {}).get("cwd", "") if isinstance(self.row, dict) else "") or self.workspace class BaseAgentAdapter: @property def name(self) -> str: raise NotImplementedError @property def own_key(self) -> str: raise NotImplementedError @property def ready_tokens(self) -> str: raise NotImplementedError @property def exit_key(self) -> str: raise NotImplementedError @property def delegate_agent_key(self) -> str: raise NotImplementedError @property def identity_cache_fields(self) -> tuple: """agent_identities 캐시의 에이전트별 필드명. B-10(Option A)로 캐시 읽기 경로가 제거되어 현재 생산 소비자는 0건이지만, 캐시 쓰기 경로가 도입되면 즉시 필요한 유일한 스키마 기술이므로 존치한다. 임의 삭제 금지 — 삭제 시 4개 어댑터에 필드명을 다시 흩뿌려야 한다.""" raise NotImplementedError @property def input_prompt(self) -> Optional[str]: return None @property def input_placeholder(self) -> Optional[str]: return None @property def input_rule_pattern(self) -> Optional[str]: return None @property def modal_tokens(self) -> Optional[str]: return None @property def startup_dialogs(self) -> List[tuple]: """Blocking modals this CLI can show before its TUI is usable, most specific first: (pattern, keys, kind). `pattern` is an extended-regex signature matched against the pane tail, `keys` are herdr send-keys arguments sent in order, and `kind` is 'trust' (a directory-trust prompt, gated by --auto-trust) or 'notice' (anything else that blocks the start, always answered). Issue #24: without this the start-up trust prompts of agy, grok and codex burn the whole readiness timeout and onboarding is skipped. """ return [] @property def strong_ready_tokens(self) -> str: return self.ready_tokens @property def weak_ready_tokens(self) -> str: return '' def derive_session_name(self, slug: str, role: str = "creator") -> str: r = role.lower() return f"{slug}-{r}-{self.name}" def matches_session_name(self, session_name: str) -> bool: for r in ('creator', 'planner', 'reviewer'): if session_name.endswith(f"-{r}-{self.name}"): return True return session_name.endswith(f"-{self.name}") def verify_session(self, ws: str, uuid: str, row: Optional[Dict[str, Any]] = None, mode: str = "discover") -> bool: return verify_session_uuid(ws, self.name, uuid, row=row, mode=mode) def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str: raise NotImplementedError def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool: raise NotImplementedError def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list: raise NotImplementedError def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list: """Paths still carrying `uuid` after a purge. Adapters that know their on-disk tree override this; the default has nothing to scan.""" return [] def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool: """Anything of this conversation is on disk (a purge target exists even when `verify_artifact` would not vouch for it). The default only trusts an artifact path that embeds the uuid - a shared store (one DB for every conversation) exists for any uuid and proves nothing.""" path = self.artifact_path(uuid, ctx) return uuid in path and os.path.exists(path) def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool: """True while a live process still references the conversation.""" return conversation_in_use(uuid, [self.artifact_path(uuid, ctx)]) def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: raise NotImplementedError def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str: raise NotImplementedError def auth_ok(self, run_cmd: Optional[Any] = None) -> bool: raise NotImplementedError def discover(self, ctx: DiscoveryContext) -> list: raise NotImplementedError