diff --git a/bin/tmux-resume.sh b/bin/tmux-resume.sh new file mode 100755 index 00000000..b6f64d09 --- /dev/null +++ b/bin/tmux-resume.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# ── tmux-resume.sh — Cold-start Session Resume ─────────────────────────── +# Reads ~/.timmy/tmux-state.json and resumes hermes sessions. +# Run at startup to restore pane state after supervisor restart. +# ────────────────────────────────────────────────────────────────────────── + +set -euo pipefail + +MANIFEST="${HOME}/.timmy/tmux-state.json" + +if [ ! -f "$MANIFEST" ]; then + echo "[tmux-resume] No manifest found at $MANIFEST — starting fresh." + exit 0 +fi + +python3 << 'PYEOF' +import json, subprocess, os, sys +from datetime import datetime, timezone + +MANIFEST = os.path.expanduser("~/.timmy/tmux-state.json") + +def run(cmd): + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30) + return r.stdout.strip(), r.returncode + except Exception as e: + return str(e), 1 + +def session_exists(name): + out, _ = run(f"tmux has-session -t '{name}' 2>&1") + return "can't find" not in out.lower() + +with open(MANIFEST) as f: + state = json.load(f) + +ts = state.get("timestamp", "unknown") +age = "unknown" +try: + t = datetime.fromisoformat(ts.replace("Z", "+00:00")) + delta = datetime.now(timezone.utc) - t + mins = int(delta.total_seconds() / 60) + if mins < 60: + age = f"{mins}m ago" + else: + age = f"{mins//60}h {mins%60}m ago" +except: + pass + +print(f"[tmux-resume] Manifest from {age}: {state['summary']['total_sessions']} sessions, " + f"{state['summary']['hermes_panes']} hermes panes") + +restored = 0 +skipped = 0 + +for pane in state.get("panes", []): + if not pane.get("is_hermes"): + continue + + addr = pane["address"] # e.g. "BURN:2.3" + session = addr.split(":")[0] + session_id = pane.get("session_id") + profile = pane.get("profile", "default") + model = pane.get("model", "") + task = pane.get("task", "") + + # Skip if session already exists (already running) + if session_exists(session): + print(f" [skip] {addr} — session '{session}' already exists") + skipped += 1 + continue + + # Respawn hermes with session resume if we have a session ID + if session_id: + print(f" [resume] {addr} — profile={profile} model={model} session={session_id}") + cmd = f"hermes chat --resume {session_id}" + else: + print(f" [start] {addr} — profile={profile} model={model} (no session ID)") + cmd = f"hermes chat --profile {profile}" + + # Create tmux session and run hermes + run(f"tmux new-session -d -s '{session}' -n '{session}:0'") + run(f"tmux send-keys -t '{session}' '{cmd}' Enter") + restored += 1 + +# Write resume log +log = { + "resumed_at": datetime.now(timezone.utc).isoformat(), + "manifest_age": age, + "restored": restored, + "skipped": skipped, +} +log_path = os.path.expanduser("~/.timmy/tmux-resume.log") +with open(log_path, "w") as f: + json.dump(log, f, indent=2) + +print(f"[tmux-resume] Done: {restored} restored, {skipped} skipped") +PYEOF diff --git a/bin/tmux-state.sh b/bin/tmux-state.sh new file mode 100755 index 00000000..9385fe7d --- /dev/null +++ b/bin/tmux-state.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# ── tmux-state.sh — Session State Persistence Manifest ─────────────────── +# Snapshots all tmux pane state to ~/.timmy/tmux-state.json +# Run every supervisor cycle. Cold-start reads this manifest to resume. +# ────────────────────────────────────────────────────────────────────────── + +set -euo pipefail + +MANIFEST="${HOME}/.timmy/tmux-state.json" +mkdir -p "$(dirname "$MANIFEST")" + +python3 << 'PYEOF' +import json, subprocess, os, time, re, sys +from datetime import datetime, timezone +from pathlib import Path + +MANIFEST = os.path.expanduser("~/.timmy/tmux-state.json") + +def run(cmd): + """Run command, return stdout or empty string.""" + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5) + return r.stdout.strip() + except Exception: + return "" + +def get_sessions(): + """Get all tmux sessions with metadata.""" + raw = run("tmux list-sessions -F '#{session_name}|#{session_windows}|#{session_created}|#{session_attached}|#{session_group}|#{session_id}'") + sessions = [] + for line in raw.splitlines(): + if not line.strip(): + continue + parts = line.split("|") + if len(parts) < 6: + continue + sessions.append({ + "name": parts[0], + "windows": int(parts[1]), + "created_epoch": int(parts[2]), + "created": datetime.fromtimestamp(int(parts[2]), tz=timezone.utc).isoformat(), + "attached": parts[3] == "1", + "group": parts[4], + "id": parts[5], + }) + return sessions + +def get_panes(): + """Get all tmux panes with full metadata.""" + fmt = '#{session_name}|#{window_index}|#{pane_index}|#{pane_pid}|#{pane_title}|#{pane_width}x#{pane_height}|#{pane_active}|#{pane_current_command}|#{pane_start_command}|#{pane_tty}|#{pane_id}|#{window_name}|#{session_id}' + raw = run(f"tmux list-panes -a -F '{fmt}'") + panes = [] + for line in raw.splitlines(): + if not line.strip(): + continue + parts = line.split("|") + if len(parts) < 13: + continue + session, win, pane, pid, title, size, active, cmd, start_cmd, tty, pane_id, win_name, sess_id = parts[:13] + w, h = size.split("x") if "x" in size else ("0", "0") + panes.append({ + "session": session, + "window_index": int(win), + "window_name": win_name, + "pane_index": int(pane), + "pane_id": pane_id, + "pid": int(pid) if pid.isdigit() else 0, + "title": title, + "width": int(w), + "height": int(h), + "active": active == "1", + "command": cmd, + "start_command": start_cmd, + "tty": tty, + "session_id": sess_id, + }) + return panes + +def extract_hermes_state(pane): + """Try to extract hermes session info from a pane.""" + info = { + "is_hermes": False, + "profile": None, + "model": None, + "provider": None, + "session_id": None, + "task": None, + } + title = pane.get("title", "") + cmd = pane.get("command", "") + start = pane.get("start_command", "") + + # Detect hermes processes + is_hermes = any(k in (title + " " + cmd + " " + start).lower() + for k in ["hermes", "timmy", "mimo", "claude", "gpt"]) + if not is_hermes and cmd not in ("python3", "python3.11", "bash", "zsh", "fish"): + return info + + # Try reading pane content for model/provider clues + pane_content = run(f"tmux capture-pane -t '{pane['session']}:{pane['window_index']}.{pane['pane_index']}' -p -S -20 2>/dev/null") + + # Extract model from pane content patterns + model_patterns = [ + r"(?:mimo-v2-pro|claude-[\w.-]+|gpt-[\w.-]+|gemini-[\w.-]+|qwen[\w:.-]*)", + ] + for pat in model_patterns: + m = re.search(pat, pane_content, re.IGNORECASE) + if m: + info["model"] = m.group(0) + info["is_hermes"] = True + break + + # Provider inference from model + model = (info["model"] or "").lower() + if "mimo" in model: + info["provider"] = "nous" + elif "claude" in model: + info["provider"] = "anthropic" + elif "gpt" in model: + info["provider"] = "openai" + elif "gemini" in model: + info["provider"] = "google" + elif "qwen" in model: + info["provider"] = "custom" + + # Profile from session name + session = pane["session"].lower() + if "burn" in session: + info["profile"] = "burn" + elif session in ("dev", "0"): + info["profile"] = "default" + else: + info["profile"] = session + + # Try to extract session ID (hermes uses UUIDs) + uuid_match = re.findall(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', pane_content) + if uuid_match: + info["session_id"] = uuid_match[-1] # most recent + info["is_hermes"] = True + + # Last prompt — grab the last user-like line + lines = pane_content.splitlines() + for line in reversed(lines): + stripped = line.strip() + if stripped and not stripped.startswith(("─", "│", "╭", "╰", "▸", "●", "○")) and len(stripped) > 10: + info["task"] = stripped[:200] + break + + return info + +def get_context_percent(pane): + """Estimate context usage from pane content heuristics.""" + content = run(f"tmux capture-pane -t '{pane['session']}:{pane['window_index']}.{pane['pane_index']}' -p -S -5 2>/dev/null") + # Look for context indicators like "ctx 45%" or "[░░░░░░░░░░]" + ctx_match = re.search(r'ctx\s*(\d+)%', content) + if ctx_match: + return int(ctx_match.group(1)) + bar_match = re.search(r'\[(░+█*█*░*)\]', content) + if bar_match: + bar = bar_match.group(1) + filled = bar.count('█') + total = len(bar) + if total > 0: + return int((filled / total) * 100) + return None + +def build_manifest(): + """Build the full tmux state manifest.""" + now = datetime.now(timezone.utc) + sessions = get_sessions() + panes = get_panes() + + pane_manifests = [] + for p in panes: + hermes = extract_hermes_state(p) + ctx = get_context_percent(p) + + entry = { + "address": f"{p['session']}:{p['window_index']}.{p['pane_index']}", + "pane_id": p["pane_id"], + "pid": p["pid"], + "size": f"{p['width']}x{p['height']}", + "active": p["active"], + "command": p["command"], + "title": p["title"], + "profile": hermes["profile"], + "model": hermes["model"], + "provider": hermes["provider"], + "session_id": hermes["session_id"], + "task": hermes["task"], + "context_pct": ctx, + "is_hermes": hermes["is_hermes"], + } + pane_manifests.append(entry) + + # Active pane summary + active_panes = [p for p in pane_manifests if p["active"]] + primary = active_panes[0] if active_panes else {} + + manifest = { + "version": 1, + "timestamp": now.isoformat(), + "timestamp_epoch": int(now.timestamp()), + "hostname": os.uname().nodename, + "sessions": sessions, + "panes": pane_manifests, + "summary": { + "total_sessions": len(sessions), + "total_panes": len(pane_manifests), + "hermes_panes": sum(1 for p in pane_manifests if p["is_hermes"]), + "active_pane": primary.get("address"), + "active_model": primary.get("model"), + "active_provider": primary.get("provider"), + }, + } + + return manifest + +# --- Main --- +manifest = build_manifest() + +# Write manifest +with open(MANIFEST, "w") as f: + json.dump(manifest, f, indent=2) + +# Also write to ~/.hermes/tmux-state.json for compatibility +hermes_manifest = os.path.expanduser("~/.hermes/tmux-state.json") +os.makedirs(os.path.dirname(hermes_manifest), exist_ok=True) +with open(hermes_manifest, "w") as f: + json.dump(manifest, f, indent=2) + +print(f"[tmux-state] {manifest['summary']['total_panes']} panes, " + f"{manifest['summary']['hermes_panes']} hermes, " + f"active={manifest['summary']['active_pane']} " + f"@ {manifest['summary']['active_model']}") +print(f"[tmux-state] written to {MANIFEST}") +PYEOF