- AgentHub: 13 files updated with non-engineering examples (content drafts, research, strategy) — engineering stays primary, cross-domain secondary - AgentHub: 7 slash commands, 5 Python scripts, 3 references, 1 agent, dry_run.py validation (57 checks) - Marketplace: agenthub entry added with cross-domain keywords, engineering POWERFUL updated (25→30), product (12→13), counts synced across all configs - SEO: generate-docs.py now produces keyword-rich <title> tags and meta descriptions using SKILL.md frontmatter — "Claude Code Skills" in site_name propagates to all 276 HTML pages - SEO: per-domain title suffixes (Agent Skill for Codex & OpenClaw, etc.), slug-as-title cleanup, domain label stripping from titles - Broken links: 141→0 warnings — new rewrite_skill_internal_links() converts references/, scripts/, assets/ links to GitHub source URLs; skills/index.md phantom slugs fixed (6 marketing, 7 RA/QM) - Counts synced: 204 skills, 266 tools, 382 refs, 16 agents, 17 commands, 21 plugins — consistent across CLAUDE.md, README.md, docs/index.md, marketplace.json, getting-started.md, mkdocs.yml - Platform sync: Codex 163 skills, Gemini 246 items, OpenClaw compatible Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
303 lines
9.8 KiB
Python
303 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""AgentHub session state machine and lifecycle manager.
|
|
|
|
Manages session states (init → running → evaluating → merged/archived),
|
|
lists sessions, and handles cleanup of worktrees and branches.
|
|
|
|
Usage:
|
|
python session_manager.py --list
|
|
python session_manager.py --status 20260317-143022
|
|
python session_manager.py --update 20260317-143022 --state running
|
|
python session_manager.py --cleanup 20260317-143022
|
|
python session_manager.py --demo
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
SESSIONS_PATH = ".agenthub/sessions"
|
|
|
|
VALID_STATES = ["init", "running", "evaluating", "merged", "archived"]
|
|
|
|
VALID_TRANSITIONS = {
|
|
"init": ["running"],
|
|
"running": ["evaluating"],
|
|
"evaluating": ["merged", "archived"],
|
|
"merged": [],
|
|
"archived": [],
|
|
}
|
|
|
|
|
|
def load_state(session_id):
|
|
"""Load session state.json."""
|
|
state_path = os.path.join(SESSIONS_PATH, session_id, "state.json")
|
|
if not os.path.exists(state_path):
|
|
return None
|
|
with open(state_path) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def save_state(session_id, state):
|
|
"""Save session state.json."""
|
|
state_path = os.path.join(SESSIONS_PATH, session_id, "state.json")
|
|
state["updated"] = datetime.now(timezone.utc).isoformat()
|
|
with open(state_path, "w") as f:
|
|
json.dump(state, f, indent=2)
|
|
f.write("\n")
|
|
|
|
|
|
def load_config(session_id):
|
|
"""Load session config.yaml (simple key: value parsing)."""
|
|
config_path = os.path.join(SESSIONS_PATH, session_id, "config.yaml")
|
|
if not os.path.exists(config_path):
|
|
return None
|
|
config = {}
|
|
with open(config_path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if ":" in line and not line.startswith("#"):
|
|
key, val = line.split(":", 1)
|
|
config[key.strip()] = val.strip().strip('"')
|
|
return config
|
|
|
|
|
|
def run_git(*args):
|
|
"""Run a git command and return stdout."""
|
|
try:
|
|
result = subprocess.run(
|
|
["git"] + list(args),
|
|
capture_output=True, text=True, check=True
|
|
)
|
|
return result.stdout.strip()
|
|
except subprocess.CalledProcessError:
|
|
return ""
|
|
|
|
|
|
def list_sessions(output_format="text"):
|
|
"""List all sessions with their states."""
|
|
if not os.path.isdir(SESSIONS_PATH):
|
|
print("No sessions found. Run hub_init.py first.")
|
|
return
|
|
|
|
sessions = []
|
|
for sid in sorted(os.listdir(SESSIONS_PATH)):
|
|
session_dir = os.path.join(SESSIONS_PATH, sid)
|
|
if not os.path.isdir(session_dir):
|
|
continue
|
|
state = load_state(sid)
|
|
config = load_config(sid)
|
|
if state and config:
|
|
sessions.append({
|
|
"session_id": sid,
|
|
"state": state.get("state", "unknown"),
|
|
"task": config.get("task", ""),
|
|
"agents": config.get("agent_count", "?"),
|
|
"created": state.get("created", ""),
|
|
})
|
|
|
|
if output_format == "json":
|
|
print(json.dumps({"sessions": sessions}, indent=2))
|
|
return
|
|
|
|
if not sessions:
|
|
print("No sessions found.")
|
|
return
|
|
|
|
print("AgentHub Sessions")
|
|
print()
|
|
header = f"{'SESSION ID':<20} {'STATE':<12} {'AGENTS':<8} {'TASK'}"
|
|
print(header)
|
|
print("-" * 70)
|
|
for s in sessions:
|
|
task = s["task"][:40] + "..." if len(s["task"]) > 40 else s["task"]
|
|
print(f"{s['session_id']:<20} {s['state']:<12} {s['agents']:<8} {task}")
|
|
|
|
|
|
def show_status(session_id, output_format="text"):
|
|
"""Show detailed status for a session."""
|
|
state = load_state(session_id)
|
|
config = load_config(session_id)
|
|
|
|
if not state or not config:
|
|
print(f"Error: Session {session_id} not found", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if output_format == "json":
|
|
print(json.dumps({"config": config, "state": state}, indent=2))
|
|
return
|
|
|
|
print(f"Session: {session_id}")
|
|
print(f" State: {state.get('state', 'unknown')}")
|
|
print(f" Task: {config.get('task', '')}")
|
|
print(f" Agents: {config.get('agent_count', '?')}")
|
|
print(f" Base branch: {config.get('base_branch', '?')}")
|
|
if config.get("eval_cmd"):
|
|
print(f" Eval: {config['eval_cmd']}")
|
|
if config.get("metric"):
|
|
print(f" Metric: {config['metric']} ({config.get('direction', '?')})")
|
|
print(f" Created: {state.get('created', '?')}")
|
|
print(f" Updated: {state.get('updated', '?')}")
|
|
|
|
# Show agent branches
|
|
branches = run_git("branch", "--list", f"hub/{session_id}/*",
|
|
"--format=%(refname:short)")
|
|
if branches:
|
|
print()
|
|
print(" Branches:")
|
|
for b in branches.split("\n"):
|
|
if b.strip():
|
|
print(f" {b.strip()}")
|
|
|
|
|
|
def update_state(session_id, new_state):
|
|
"""Transition session to a new state."""
|
|
state = load_state(session_id)
|
|
if not state:
|
|
print(f"Error: Session {session_id} not found", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
current = state.get("state", "unknown")
|
|
|
|
if new_state not in VALID_STATES:
|
|
print(f"Error: Invalid state '{new_state}'. "
|
|
f"Valid: {', '.join(VALID_STATES)}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
valid_next = VALID_TRANSITIONS.get(current, [])
|
|
if new_state not in valid_next:
|
|
print(f"Error: Cannot transition from '{current}' to '{new_state}'. "
|
|
f"Valid transitions: {', '.join(valid_next) or 'none (terminal)'}",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
state["state"] = new_state
|
|
save_state(session_id, state)
|
|
print(f"Session {session_id}: {current} → {new_state}")
|
|
|
|
|
|
def cleanup_session(session_id):
|
|
"""Clean up worktrees and optionally archive branches."""
|
|
config = load_config(session_id)
|
|
if not config:
|
|
print(f"Error: Session {session_id} not found", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Find and remove worktrees for this session
|
|
worktree_output = run_git("worktree", "list", "--porcelain")
|
|
removed = 0
|
|
if worktree_output:
|
|
current_path = None
|
|
for line in worktree_output.split("\n"):
|
|
if line.startswith("worktree "):
|
|
current_path = line[len("worktree "):]
|
|
elif line.startswith("branch ") and current_path:
|
|
ref = line[len("branch "):]
|
|
if f"hub/{session_id}/" in ref:
|
|
result = subprocess.run(
|
|
["git", "worktree", "remove", "--force", current_path],
|
|
capture_output=True, text=True
|
|
)
|
|
if result.returncode == 0:
|
|
removed += 1
|
|
print(f" Removed worktree: {current_path}")
|
|
current_path = None
|
|
|
|
print(f"Cleaned up {removed} worktrees for session {session_id}")
|
|
|
|
|
|
def run_demo():
|
|
"""Show demo output."""
|
|
print("=" * 60)
|
|
print("AgentHub Session Manager — Demo Mode")
|
|
print("=" * 60)
|
|
print()
|
|
|
|
print("--- Session List ---")
|
|
print("AgentHub Sessions")
|
|
print()
|
|
header = f"{'SESSION ID':<20} {'STATE':<12} {'AGENTS':<8} {'TASK'}"
|
|
print(header)
|
|
print("-" * 70)
|
|
print(f"{'20260317-143022':<20} {'merged':<12} {'3':<8} Optimize API response time below 100ms")
|
|
print(f"{'20260317-151500':<20} {'running':<12} {'2':<8} Refactor auth module for JWT support")
|
|
print(f"{'20260317-160000':<20} {'init':<12} {'4':<8} Implement caching strategy")
|
|
print()
|
|
|
|
print("--- Session Detail ---")
|
|
print("Session: 20260317-143022")
|
|
print(" State: merged")
|
|
print(" Task: Optimize API response time below 100ms")
|
|
print(" Agents: 3")
|
|
print(" Base branch: dev")
|
|
print(" Eval: pytest bench.py --json")
|
|
print(" Metric: p50_ms (lower)")
|
|
print(" Created: 2026-03-17T14:30:22Z")
|
|
print(" Updated: 2026-03-17T14:45:00Z")
|
|
print()
|
|
print(" Branches:")
|
|
print(" hub/20260317-143022/agent-1/attempt-1 (archived)")
|
|
print(" hub/20260317-143022/agent-2/attempt-1 (merged)")
|
|
print(" hub/20260317-143022/agent-3/attempt-1 (archived)")
|
|
print()
|
|
|
|
print("--- State Transitions ---")
|
|
print("Valid transitions:")
|
|
for state, transitions in VALID_TRANSITIONS.items():
|
|
arrow = " → ".join(transitions) if transitions else "(terminal)"
|
|
print(f" {state}: {arrow}")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="AgentHub session state machine and lifecycle manager"
|
|
)
|
|
parser.add_argument("--list", action="store_true",
|
|
help="List all sessions with state")
|
|
parser.add_argument("--status", type=str, metavar="SESSION_ID",
|
|
help="Show detailed session status")
|
|
parser.add_argument("--update", type=str, metavar="SESSION_ID",
|
|
help="Update session state")
|
|
parser.add_argument("--state", type=str,
|
|
help="New state for --update")
|
|
parser.add_argument("--cleanup", type=str, metavar="SESSION_ID",
|
|
help="Remove worktrees and clean up session")
|
|
parser.add_argument("--format", choices=["text", "json"], default="text",
|
|
help="Output format (default: text)")
|
|
parser.add_argument("--demo", action="store_true",
|
|
help="Show demo output")
|
|
args = parser.parse_args()
|
|
|
|
if args.demo:
|
|
run_demo()
|
|
return
|
|
|
|
if args.list:
|
|
list_sessions(args.format)
|
|
return
|
|
|
|
if args.status:
|
|
show_status(args.status, args.format)
|
|
return
|
|
|
|
if args.update:
|
|
if not args.state:
|
|
print("Error: --update requires --state", file=sys.stderr)
|
|
sys.exit(1)
|
|
update_state(args.update, args.state)
|
|
return
|
|
|
|
if args.cleanup:
|
|
cleanup_session(args.cleanup)
|
|
return
|
|
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|