Implement tmux-state.sh: snapshots all tmux pane state to ~/.timmy/tmux-state.json and ~/.hermes/tmux-state.json every supervisor cycle. Per-pane tracking: - address, pane_id, pid, size, active state - command, title, tty - hermes profile, model, provider - session_id (for --resume) - task (last prompt extracted from pane content) - context_pct (estimated from pane content) Also implement tmux-resume.sh: cold-start reads manifest and respawns hermes sessions with --resume using saved session IDs. Closes #512
238 lines
8.3 KiB
Bash
Executable File
238 lines
8.3 KiB
Bash
Executable File
#!/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
|