diff --git a/hooks/git-autoupdate.sh b/hooks/git-autoupdate.sh new file mode 100755 index 0000000..f5d1e69 --- /dev/null +++ b/hooks/git-autoupdate.sh @@ -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 diff --git a/hooks/tmux-attention.sh b/hooks/tmux-attention.sh new file mode 100755 index 0000000..f412be5 --- /dev/null +++ b/hooks/tmux-attention.sh @@ -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" diff --git a/hooks/tmux-reset.sh b/hooks/tmux-reset.sh new file mode 100755 index 0000000..5a4f353 --- /dev/null +++ b/hooks/tmux-reset.sh @@ -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" diff --git a/nix/home.nix b/nix/home.nix index b871c90..4d5e58f 100644 --- a/nix/home.nix +++ b/nix/home.nix @@ -42,6 +42,12 @@ in source = "${agent-skills}/hooks"; 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. # Listed one by one: recursive on ~/.claude would fight every other tool diff --git a/scripts/aoe-register-remote.py b/scripts/aoe-register-remote.py new file mode 100755 index 0000000..4593856 --- /dev/null +++ b/scripts/aoe-register-remote.py @@ -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 `, 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