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
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# SessionStart hook: fast-forward current branch to origin default branch.
|
||||||
|
# Non-destructive: fetch always; ff-only merge only when branch has no own
|
||||||
|
# commits (covers stale main AND fresh worktree branched off stale main).
|
||||||
|
# Feature branches with own work are left untouched (just fetched).
|
||||||
|
set -u
|
||||||
|
|
||||||
|
# Must be inside a work tree.
|
||||||
|
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
|
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
||||||
|
[ "$branch" = "HEAD" ] && { echo "git-autoupdate: detached HEAD, skip"; exit 0; }
|
||||||
|
|
||||||
|
# Dirty tree -> fetch only, never move HEAD.
|
||||||
|
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
||||||
|
git fetch --quiet --all --prune 2>/dev/null
|
||||||
|
echo "git-autoupdate: working tree dirty, fetched only (no update)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch --quiet origin --prune 2>/dev/null || { echo "git-autoupdate: fetch failed"; exit 0; }
|
||||||
|
|
||||||
|
# Resolve origin default branch (e.g. origin/main).
|
||||||
|
base=$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/||')
|
||||||
|
if [ -z "$base" ]; then
|
||||||
|
git remote set-head origin -a >/dev/null 2>&1
|
||||||
|
base=$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/||')
|
||||||
|
fi
|
||||||
|
[ -z "$base" ] && base="origin/main"
|
||||||
|
git rev-parse --verify --quiet "$base" >/dev/null 2>&1 || { echo "git-autoupdate: no $base"; exit 0; }
|
||||||
|
|
||||||
|
ahead=$(git rev-list --count "$base"..HEAD 2>/dev/null)
|
||||||
|
behind=$(git rev-list --count HEAD.."$base" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "${ahead:-0}" -gt 0 ]; then
|
||||||
|
echo "git-autoupdate: '$branch' has $ahead own commit(s); fetched, not moved (base $base fresh)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${behind:-0}" -eq 0 ]; then
|
||||||
|
echo "git-autoupdate: '$branch' already up to date with $base"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git merge --ff-only "$base" >/dev/null 2>&1; then
|
||||||
|
echo "git-autoupdate: '$branch' fast-forwarded to $base (+$behind)"
|
||||||
|
else
|
||||||
|
echo "git-autoupdate: '$branch' could not ff to $base"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Highlight tmux window when Claude Code needs attention
|
||||||
|
# Called by Claude Code's Notification hook
|
||||||
|
|
||||||
|
[ -z "$TMUX" ] && exit 0
|
||||||
|
|
||||||
|
TARGET=$(tmux display-message -p '#{session_name}:#{window_index}')
|
||||||
|
|
||||||
|
# Set window to attention style (amber/yellow)
|
||||||
|
tmux set-window-option -t "$TARGET" window-status-current-style "fg=#1a1b26,bg=#e0af68,bold"
|
||||||
|
tmux set-window-option -t "$TARGET" window-status-style "fg=#1a1b26,bg=#e0af68,bold"
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Reset tmux window style when Claude Code resumes working
|
||||||
|
# Called by Claude Code's PreToolUse hook
|
||||||
|
|
||||||
|
[ -z "$TMUX" ] && exit 0
|
||||||
|
|
||||||
|
TARGET=$(tmux display-message -p '#{session_name}:#{window_index}')
|
||||||
|
|
||||||
|
# Reset to normal theme colors
|
||||||
|
tmux set-window-option -t "$TARGET" window-status-current-style "fg=#7aa2f7,bg=#24283b,bold"
|
||||||
|
tmux set-window-option -t "$TARGET" window-status-style "fg=#565f89,bg=#1a1b26"
|
||||||
@@ -42,6 +42,12 @@ in
|
|||||||
source = "${agent-skills}/hooks";
|
source = "${agent-skills}/hooks";
|
||||||
recursive = true;
|
recursive = true;
|
||||||
};
|
};
|
||||||
|
# Referenced by hook commands in settings.json, which this module does not
|
||||||
|
# own — so these have to exist under the same path on every machine.
|
||||||
|
".claude/scripts" = {
|
||||||
|
source = "${agent-skills}/scripts";
|
||||||
|
recursive = true;
|
||||||
|
};
|
||||||
|
|
||||||
# CLAUDE.md fragments land in ~/.claude root, pulled in by `@name.md` imports.
|
# CLAUDE.md fragments land in ~/.claude root, pulled in by `@name.md` imports.
|
||||||
# Listed one by one: recursive on ~/.claude would fight every other tool
|
# Listed one by one: recursive on ~/.claude would fight every other tool
|
||||||
|
|||||||
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