#!/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