feat: ship the remaining hooks and aoe-register-remote
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>
This commit is contained in:
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user