7cb03f8796
settings.json references git-autoupdate, tmux-attention, tmux-reset and scripts/aoe-register-remote.py, but they only existed as untracked files on one machine, so hooks failed everywhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
4.9 KiB
Python
Executable File
148 lines
4.9 KiB
Python
Executable File
#!/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 <id>`, 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_<task-id>`, 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
|