#!/usr/bin/env python3 """Register `claude remote-control` sessions (desktop/Android) as AoE rows. Hook mode (SessionStart, stdin JSON): registers the current session if a `claude remote-control` process is an ancestor. Backfill mode (--backfill): scans ~/.claude/projects for transcripts whose cwd sits under a `.claude/worktrees/` dir -- process ancestry is gone by then, so path shape is the only signal left. Rows are created UNLAUNCHED and pinned to the conversation. Starting one runs `claude --resume `, which appends to the SAME transcript the phone is using: only take over once the remote-control child is idle/dead, else two writers interleave the jsonl. """ import json import os import re import subprocess import sys from pathlib import Path SESSIONS_JSON = Path.home() / ".config/agent-of-empires/profiles/default/sessions.json" PROJECTS_DIR = Path.home() / ".claude/projects" GROUP = "remote" # terminal -> `claude --resume` on start (clean takeover, no live view while phone drives) # structured -> transcript replay under `aoe serve` (live view, ACP takeover) ROW_MODE = os.environ.get("AOE_REMOTE_ROW_MODE", "terminal") def known_agent_session_ids(): try: rows = json.loads(SESSIONS_JSON.read_text()) except (OSError, ValueError): return set() # import-created rows carry agent_session_id; `set-session-id` writes # resume_intent.value and only becomes agent_session_id once aoe runs it ids = {r.get("agent_session_id") for r in rows} ids |= {(r.get("resume_intent") or {}).get("value") for r in rows} return {i for i in ids if i} def has_remote_control_ancestor(pid=None): pid = pid or os.getppid() for _ in range(20): try: cmdline = Path(f"/proc/{pid}/cmdline").read_bytes().decode(errors="replace") status = Path(f"/proc/{pid}/status").read_text() except OSError: return False if "remote-control" in cmdline: return True m = re.search(r"^PPid:\s*(\d+)", status, re.M) if not m or m.group(1) == "0" or m.group(1) == "1": return False pid = int(m.group(1)) return False def title_for(cwd, session_id): # remote-control worktrees are all named `bridge-cse_`, so the dir # name carries no signal -- name rows after the repo instead cwd = str(cwd) marker = "/.claude/worktrees/" name = Path(cwd.split(marker)[0]).name if marker in cwd else Path(cwd).name return f"{name}-{session_id.split('-')[0]}" def register(cwd, session_id, mode=ROW_MODE): if session_id in known_agent_session_ids(): return False if not Path(cwd).is_dir(): return False if mode == "structured": subprocess.run( ["aoe", "session", "import", "--structured", "--group", GROUP, "-y", cwd], check=False, capture_output=True, text=True, ) return True out = subprocess.run( ["aoe", "add", cwd, "--title", title_for(cwd, session_id), "--group", GROUP], check=False, capture_output=True, text=True, ).stdout m = re.search(r"^\s*ID:\s*(\w+)", out, re.M) if not m: return False subprocess.run( ["aoe", "session", "set-session-id", m.group(1), session_id], check=False, capture_output=True, text=True, ) return True def transcript_meta(path): try: with path.open() as f: for line in f: try: rec = json.loads(line) except ValueError: continue sid, cwd = rec.get("sessionId"), rec.get("cwd") if sid and cwd: return sid, cwd except OSError: pass return None, None def backfill(dry_run=False): known = known_agent_session_ids() seen, made = set(), [] for jsonl in sorted(PROJECTS_DIR.glob("*/*.jsonl"), key=lambda p: p.stat().st_mtime): sid, cwd = transcript_meta(jsonl) if not sid or sid in known or sid in seen: continue if "/.claude/worktrees/" not in cwd or not Path(cwd).is_dir(): continue seen.add(sid) if dry_run: made.append(f"would add {title_for(cwd, sid)} {cwd}") elif register(cwd, sid): made.append(f"added {title_for(cwd, sid)} {cwd}") print("\n".join(made) if made else "nothing to register") def main(): if "--backfill" in sys.argv: backfill(dry_run="--dry-run" in sys.argv) return if os.environ.get("AOE_INSTANCE_ID"): # already an AoE-launched session return try: payload = json.loads(sys.stdin.read() or "{}") except ValueError: return sid, cwd = payload.get("session_id"), payload.get("cwd") or os.getcwd() if not sid or not has_remote_control_ancestor(): return register(cwd, sid) if __name__ == "__main__": try: main() except Exception: pass # a hook must never block session start