initial: canary multi-agent skills with tmux isolation support
- lib.sh: TMUX_SERVER_NAME env var, _tmux helper, shim externalized to TMPDIR with recursive guard, resolve_tmux_server helper for YAML-driven server routing - multi-agent-create: --tmux-server opt-in flag, YAML tmux_server field for orphan prevention - multi-agent-delete/resume/status/agent-sessions-monitor: use resolve_tmux_server to auto-route to correct isolated server - SKILL.md × 4: documented isolation server workflow - Verified by claude review (R1+re-run) + agy R2 patches (orphan prevention + shim location fix)
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
# 1회성 작업 자료 (agy/claude 워커에게 보낸 프롬프트)
|
||||
_agy_prompt_*.md
|
||||
_claude_prompt_*.md
|
||||
|
||||
# 임시 검증용 산출물
|
||||
test-sessions.yaml
|
||||
test-sessions.yaml.bak
|
||||
test-sessions.yaml.lock
|
||||
|
||||
# 자체 git repo 임베드 (별도 관리)
|
||||
delegate-job-skill/
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
name: agent-sessions-monitor
|
||||
description: "Run a long-lived Kanban worker that polls ~/PuKi/lab/agent_sessions/agent-sessions.yaml against the actual tmux/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Designed to be dispatched as a Kanban goal_mode task (--goal) so it keeps running until the user stops it."
|
||||
version: 1.0.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [kanban, terminal, tmux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [agent, tmux, claude, antigravity, agy, monitor, kanban, observation, reconciliation]
|
||||
related_skills: [multi-agent-create, multi-agent-resume, multi-agent-delete, kanban-orchestrator]
|
||||
prereq_skills: [kanban-worker, multi-agent-create]
|
||||
---
|
||||
|
||||
# Agent Sessions Monitor — Live Reconciliation via Kanban Worker
|
||||
|
||||
> **Companion skills**: `multi-agent-create` / `multi-agent-resume` / `multi-agent-delete` (mutators); this skill is the **observer**.
|
||||
> **Single source of truth**: `~/PuKi/lab/agent_sessions/agent-sessions.yaml`.
|
||||
|
||||
## What this skill does
|
||||
|
||||
Dispatch a **Kanban worker** (in `goal_mode`) that:
|
||||
|
||||
1. Every ~30s polls the actual state of:
|
||||
- `tmux ls` (which sessions are alive)
|
||||
- `tmux list-panes -t <session> ...` (pane cmd, cwd, pid)
|
||||
- `~/.claude/projects/<workspace-key>/*.jsonl` mtime + first-line sessionId
|
||||
- `~/.gemini/antigravity-cli/cache/last_conversations.json` (agy workspace → conversation mapping)
|
||||
- `~/.gemini/antigravity-cli/conversations/<uuid>.db` mtime (agy)
|
||||
2. Compares the live state to `agent-sessions.yaml`
|
||||
3. Detects 4 classes of drift:
|
||||
- **yaml-only terminated**: tmux dead, YAML says `terminated` → OK
|
||||
- **yaml-only running, tmux dead**: YAML says `running`, tmux is gone → mark `terminated` with timestamp
|
||||
- **tmux-only running, not in YAML**: tmux session exists with `<workspace>-creator-*` naming but YAML doesn't know about it → register as a new entry
|
||||
- **stale UUID**: YAML has a UUID, but the on-disk artifact is gone → flag in comment
|
||||
4. Writes a Kanban `kanban_comment` on every drift event with diff details
|
||||
5. Heartbeat every 5 minutes
|
||||
6. **Goal loop**: judge (auxiliary model) re-checks the card after each turn against the body to decide "is monitoring still wanted?". When the user says "stop monitoring" via comment, the worker blocks with `reason=stop-requested`.
|
||||
|
||||
## When to use
|
||||
|
||||
- You have multiple workspaces with tmux agent sessions and want a single source of truth
|
||||
- You suspect YAML drift after a host reboot / crash
|
||||
- You want a notification when a session id was just created (so you can record it before next restart)
|
||||
- You're running multi-day work and want to know "what's actually running right now"
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- One-off interactive session — just check `tmux ls` and read the YAML
|
||||
- A single, short session — overhead > benefit
|
||||
- You don't have a Kanban dispatcher running
|
||||
|
||||
## Dispatching the monitor
|
||||
|
||||
```bash
|
||||
# Goal-mode task: keeps running until the user signals stop
|
||||
hermes kanban create \
|
||||
--title "agent-sessions monitor (live reconcile)" \
|
||||
--assignee default \
|
||||
--workspace worktree \
|
||||
--branch wt/agent-sessions-monitor \
|
||||
--goal \
|
||||
--goal-max-turns 100 \
|
||||
--max-runtime 8h \
|
||||
--max-retries 1 \
|
||||
--skill agent-sessions-monitor \
|
||||
--body "$(cat <<'EOF'
|
||||
You are the agent-sessions monitor. Every 30 seconds, do:
|
||||
|
||||
1. Read ~/PuKi/lab/agent_sessions/agent-sessions.yaml
|
||||
2. Run `tmux ls` and `tmux list-panes -F 'session=#{session_name} pid=#{pane_pid} cmd=#{pane_current_command} cwd=#{pane_current_path}'`
|
||||
3. For each session in the YAML, check the corresponding tmux state
|
||||
4. For each tmux session matching `*-creator-claude` or `*-creator-agy` that's not in the YAML, register it
|
||||
5. For any drift, call `kanban_comment` with the diff
|
||||
6. Sleep 30 seconds, then repeat
|
||||
|
||||
If the user comments `stop` or `stop monitoring` on this card, call `kanban_block(reason="stop-requested by user")`.
|
||||
|
||||
If you find that a Claude session's `claude_session_id_own` is null but there's a new *.jsonl in the project dir, read the sessionId from the first line and update the YAML.
|
||||
|
||||
Use the helper script at ~/PuKi/lab/agent_sessions/skills/agent-sessions-monitor/scripts/reconcile.sh for the YAML updates — it handles all the merge logic and writes a structured comment to this card.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Helper script: `reconcile.sh`
|
||||
|
||||
The worker calls this script every 30s. It:
|
||||
|
||||
1. Diffs YAML ↔ tmux ↔ disk artifacts
|
||||
2. Updates YAML if needed (only when changes are real, not on every poll — avoids spamming)
|
||||
3. Emits a JSON diff to stdout that the worker turns into a `kanban_comment`
|
||||
|
||||
```bash
|
||||
# Reconcile + auto-update YAML (atomic, flock-guarded). Emits JSON drift to stdout.
|
||||
bash ~/PuKi/lab/agent_sessions/skills/agent-sessions-monitor/scripts/reconcile.sh --once --emit-diff
|
||||
|
||||
# Read-only: compute drift WITHOUT writing the YAML (use for "what's running?" checks).
|
||||
bash ~/PuKi/lab/agent_sessions/skills/agent-sessions-monitor/scripts/reconcile.sh --once --emit-diff --dry-run
|
||||
```
|
||||
|
||||
Flags: `--once` (single pass), `--emit-diff` (print JSON), `--dry-run` (P1-E — no
|
||||
mutation). There are **no** `--workspace` / `--agent` / `--comment-card` flags; the
|
||||
worker turns the emitted JSON `drifts[]` into `kanban_comment` calls itself.
|
||||
|
||||
## Drift classes (what the script handles)
|
||||
|
||||
### A. tmux dead, YAML says running → auto-terminate
|
||||
|
||||
```
|
||||
YAML: status=running, pane.pid=201132, cmd=claude
|
||||
tmux: no session
|
||||
→ set status=terminated, terminated_at=<now>, termination_mode=auto-detected
|
||||
→ comment: "lab-landing-page-creator-claude: tmux gone (was pane 201132, cmd claude). Marked terminated."
|
||||
```
|
||||
|
||||
### B. tmux alive, not in YAML → auto-register
|
||||
|
||||
```
|
||||
tmux: session=lab-paper-pdf2md-creator-agy, pid=...,
|
||||
cmd=agy, cwd=/home/godopu16/PuKi/lab/paper-pdf2md
|
||||
YAML: no such session
|
||||
→ register as new entry: status=running, last_visible_status=auto-registered
|
||||
→ comment: "lab-paper-pdf2md-creator-agy: tmux found but not in YAML. Auto-registered."
|
||||
```
|
||||
|
||||
### C. New session id materializes (claude first message sent)
|
||||
|
||||
```
|
||||
YAML: claude_session_id_own=null (placeholder)
|
||||
disk: ~/.claude/projects/.../b3a7...c2f.jsonl exists, mtime=now,
|
||||
first line sessionId=b3a7...c2f
|
||||
→ update claude_session_id_own=b3a7...c2f
|
||||
→ comment: "lab-landing-page-creator-claude: session id materialized b3a7...c2f"
|
||||
```
|
||||
|
||||
### D. Stale UUID (artifact gone)
|
||||
|
||||
```
|
||||
YAML: agent_identities.claude.session_id=87dc548e-...
|
||||
disk: ~/.claude/projects/.../87dc548e-...jsonl: missing
|
||||
→ flag in comment, but DO NOT delete from YAML
|
||||
(the user may have moved the file or the disk may be temporarily unavailable;
|
||||
only `--purge-conversation` should remove the id)
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Don't run the monitor without `--goal`** — without goal mode, a single turn will spawn, do one reconcile, and complete. Goal mode keeps the worker alive across many turns.
|
||||
- **The 30s poll is a default** — workers may override if they detect heavy churn. A workspace with 5+ agent sessions should bump to 60s to avoid noise.
|
||||
- **`kanban_comment` rate limits** — Kanban may throttle if you comment too fast. Coalesce: only comment when the diff is *new* (not the same drift on every poll). The script tracks a state file at `~/.cache/agent-sessions-monitor/<workspace>.state` for this.
|
||||
- **Don't fight the user's explicit action** — if `multi-agent-delete` is mid-flight and the monitor sees the same session in two states within 5s, prefer the user's most recent action. The monitor should not auto-revert a fresh `terminated` to `running` because of a stale `tmux has-session` check.
|
||||
- **The monitor should never modify the conversation artifacts** (jsonl, db) — only the YAML. If you see a stale UUID, comment about it but don't delete the file.
|
||||
- **TUI capture-pane is expensive** — only capture when you need to update `last_visible_status`, not every poll.
|
||||
|
||||
## Worker body template (for `hermes kanban create --body`)
|
||||
|
||||
The `--body` of the dispatched task IS the worker's behavior spec. Here's a tested template:
|
||||
|
||||
```markdown
|
||||
# agent-sessions monitor
|
||||
|
||||
## Loop (every 30s)
|
||||
|
||||
1. Read agent-sessions.yaml
|
||||
2. Bash: `bash ~/PuKi/lab/agent_sessions/skills/agent-sessions-monitor/scripts/reconcile.sh --emit-diff`
|
||||
3. Parse the JSON diff from stdout
|
||||
4. If `drifts` is non-empty:
|
||||
- For each drift, call `kanban_comment` with the diff message
|
||||
5. Bash: `sleep 30`
|
||||
6. Heartbeat every 5 min: `kanban_heartbeat(progress="alive, N drifts detected, last at <time>")`
|
||||
|
||||
## Stop condition
|
||||
|
||||
If `$HERMES_KANBAN_TASK` card has any comment containing "stop" or "stop monitoring" from a user:
|
||||
- Call `kanban_block(reason="stop-requested by user at <timestamp>")`
|
||||
|
||||
## Drift responses
|
||||
|
||||
- A. tmux dead + YAML running: auto-terminate YAML, comment
|
||||
- B. tmux alive not in YAML: auto-register, comment
|
||||
- C. New session id from *.jsonl: update YAML, comment
|
||||
- D. Stale UUID: comment only, no YAML change
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Do NOT modify conversation artifacts (jsonl, db, brain/)
|
||||
- Do NOT spawn/delete tmux sessions — that's the create/delete skills' job
|
||||
- Do NOT call multi-agent-create or multi-agent-delete — only the user initiates those
|
||||
- Do NOT call `git commit` / `git push`
|
||||
```
|
||||
|
||||
## Verification (one-shot)
|
||||
|
||||
```bash
|
||||
# Run reconcile once and inspect output
|
||||
bash ~/PuKi/lab/agent_sessions/skills/agent-sessions-monitor/scripts/reconcile.sh --emit-diff --once \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
|
||||
## Related skills
|
||||
|
||||
- `kanban-worker` — base lifecycle for the dispatched worker
|
||||
- `kanban-orchestrator` — if you want to dispatch this monitor *from* an orchestrator, use this to know how to phrase the body
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env bash
|
||||
# reconcile.sh — agent-sessions-monitor 의 부속 스크립트
|
||||
# YAML ↔ tmux ↔ 디스크 artifact 간 drift 감지 (+ YAML 자동 갱신).
|
||||
#
|
||||
# Usage:
|
||||
# bash reconcile.sh --once --emit-diff # drift 감지 + 갱신
|
||||
# bash reconcile.sh --once --emit-diff --dry-run # drift 만 계산, 쓰기 안 함 (P1-E)
|
||||
#
|
||||
# --dry-run: 부수효과 없는 read-only. "지금 뭐 돌고 있지?" 질문에 안전.
|
||||
# multi-agent-status 스킬이 이걸 재사용.
|
||||
#
|
||||
# 출력 (JSON): {timestamp, yaml_path, tmux_sessions_alive, tmux_confirmed, drifts, actions}
|
||||
#
|
||||
# Exit codes: 0 = ok | 1 = YAML not found | 2 = error
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$HOME/.cache/agent-sessions-monitor}"
|
||||
|
||||
ONCE=0
|
||||
EMIT_DIFF=0
|
||||
DRY_RUN=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--once) ONCE=1; shift ;;
|
||||
--emit-diff) EMIT_DIFF=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
-h|--help) echo "Usage: $0 [--once] [--emit-diff] [--dry-run]"; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# 모든 비교 로직을 단일 소스로 둔다. dry-run 은 env_python(읽기전용), 그 외엔
|
||||
# atomic_dump_yaml(flock + temp+rename) 로 같은 소스를 돌린다. atomic 래퍼에서는
|
||||
# 'actions' 가 없으면 SystemExit(0) 으로 쓰기를 건너뛴다 (불필요한 재포맷 방지).
|
||||
read -r -d '' RECON_SRC <<'PYEOF' || true
|
||||
import os, json, glob, subprocess, time
|
||||
from datetime import datetime, timezone
|
||||
import yaml
|
||||
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
home = os.environ['HOME_DIR']
|
||||
|
||||
now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
# atomic 래퍼에서는 d 가 이미 로드돼 있음. env_python(dry-run)에서는 여기서 로드.
|
||||
try:
|
||||
d
|
||||
except NameError:
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
|
||||
drifts = []
|
||||
actions = []
|
||||
|
||||
# === 현재 tmux 상태 — transient 실패를 'no sessions' 와 구분 (P1-E) ===
|
||||
tmux_sessions = []
|
||||
tmux_confirmed = True
|
||||
|
||||
# YAML 에 등록된 고유한 tmux_server 목록 수집 + 환경변수 TMUX_SERVER_NAME 포함
|
||||
unique_servers = {'default'}
|
||||
if 'TMUX_SERVER_NAME' in os.environ:
|
||||
unique_servers.add(os.environ['TMUX_SERVER_NAME'])
|
||||
for s in d.get('tmux_sessions', []):
|
||||
srv = s.get('tmux_server') or 'default'
|
||||
unique_servers.add(srv)
|
||||
|
||||
try:
|
||||
for srv in sorted(unique_servers):
|
||||
cmd = ['tmux']
|
||||
if srv != 'default':
|
||||
cmd += ['-L', srv]
|
||||
cmd += ['ls', '-F', '#{session_name}|#{session_created}']
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode == 0:
|
||||
for line in r.stdout.strip().split('\n'):
|
||||
if not line:
|
||||
continue
|
||||
name, created = line.split('|', 1)
|
||||
tmux_sessions.append({'name': name, 'created': int(created), 'server': srv})
|
||||
else:
|
||||
err = (r.stderr or '').lower()
|
||||
is_empty = ('no server running' in err) or ('no sessions' in err) or ('failed to connect' in err)
|
||||
if not is_empty:
|
||||
tmux_confirmed = False
|
||||
except Exception:
|
||||
tmux_confirmed = False
|
||||
|
||||
|
||||
def pane_meta(session, srv):
|
||||
try:
|
||||
cmd = ['tmux']
|
||||
if srv != 'default':
|
||||
cmd += ['-L', srv]
|
||||
cmd += ['list-panes', '-t', session, '-F',
|
||||
'#{pane_pid}|#{pane_current_path}|#{pane_current_command}']
|
||||
out = subprocess.check_output(cmd, text=True)
|
||||
parts = out.strip().split('\n')[0].split('|')
|
||||
return {'pid': int(parts[0]), 'cwd': parts[1], 'cmd': parts[2]}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
yaml_sessions = d.get('tmux_sessions', [])
|
||||
yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')}
|
||||
alive_set = {(t['name'], t.get('server', 'default')) for t in tmux_sessions}
|
||||
|
||||
# === drift A: tmux dead + YAML running → auto-terminate ===
|
||||
# tmux 응답을 확정했을 때만. transient 실패 시 모두 terminated 로 마크하지 않음 (P1-E)
|
||||
if tmux_confirmed:
|
||||
for s in yaml_sessions:
|
||||
name = s.get('name')
|
||||
if not name:
|
||||
continue
|
||||
if s.get('status') in ('terminated', 'archived'):
|
||||
continue
|
||||
srv = s.get('tmux_server') or 'default'
|
||||
if (name, srv) not in alive_set:
|
||||
s['status'] = 'terminated'
|
||||
s['terminated_at'] = now_iso
|
||||
s['terminated_at_epoch'] = int(datetime.now(timezone.utc).timestamp())
|
||||
s['termination_mode'] = 'auto-detected (tmux gone)'
|
||||
pane = s.get('pane') or {}
|
||||
drifts.append({'class': 'A', 'name': name,
|
||||
'msg': f"{name}: tmux gone (was pane {pane.get('pid')}, cmd {pane.get('cmd')}). Marked terminated."})
|
||||
actions.append(f"terminated: {name}")
|
||||
|
||||
# === drift B: tmux alive + not in YAML → auto-register ===
|
||||
if tmux_confirmed:
|
||||
for t in tmux_sessions:
|
||||
name = t['name']
|
||||
if name in yaml_session_names:
|
||||
continue
|
||||
if not (name.endswith('-creator-claude') or name.endswith('-creator-agy')):
|
||||
continue
|
||||
srv = t.get('server', 'default')
|
||||
pm = pane_meta(name, srv)
|
||||
if not pm:
|
||||
continue
|
||||
agent = 'claude' if name.endswith('-creator-claude') else 'agy'
|
||||
cmd_full = 'claude' if agent == 'claude' else 'agy --dangerously-skip-permissions'
|
||||
server_opt = f"-L {srv} " if srv != 'default' else ""
|
||||
entry = {
|
||||
'name': name,
|
||||
'status': 'running',
|
||||
'tmux_session_created_at': datetime.fromtimestamp(t['created'], tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
'tmux_session_epoch': t['created'],
|
||||
'tmux_server': srv,
|
||||
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
|
||||
# P2: cwd 인용
|
||||
'start_command': f'tmux {server_opt}new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
|
||||
'attach_command': f'tmux {server_opt}attach -t {name}',
|
||||
'kill_command': f'tmux {server_opt}kill-session -t {name}',
|
||||
'last_visible_status': 'auto-registered by monitor',
|
||||
}
|
||||
if agent == 'claude':
|
||||
entry['tui'] = {'model': '(unknown — capture after first message)', 'provider': 'anthropic',
|
||||
'plan': '(unknown)', 'account': '(unknown)', 'version': '(unknown)'}
|
||||
entry['claude_session_id_own'] = None
|
||||
else:
|
||||
entry['child_pid'] = 0
|
||||
entry['agy_conversation_id_own'] = None
|
||||
entry['mcp_attachments'] = [
|
||||
{
|
||||
'name': 'stitch',
|
||||
'transport': 'mcp-remote',
|
||||
'endpoint': 'https://stitch.googleapis.com/mcp'
|
||||
}
|
||||
]
|
||||
d.setdefault('tmux_sessions', []).append(entry)
|
||||
yaml_session_names.add(name)
|
||||
drifts.append({'class': 'B', 'name': name,
|
||||
'msg': f"{name}: tmux found but not in YAML. Auto-registered (pane {pm['pid']}, cmd {pm['cmd']}, cwd {pm['cwd']})."})
|
||||
actions.append(f"registered: {name}")
|
||||
|
||||
# === drift C: claude 새 session id materialize (per-row own id) ===
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if not s.get('name', '').endswith('-creator-claude'):
|
||||
continue
|
||||
if s.get('status') != 'running':
|
||||
continue
|
||||
if s.get('claude_session_id_own'):
|
||||
continue
|
||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||
if not cwd:
|
||||
continue
|
||||
proj_key = cwd.replace('/', '-').replace('_', '-')
|
||||
proj_dir = f"{home}/.claude/projects/{proj_key}"
|
||||
if not os.path.isdir(proj_dir):
|
||||
continue
|
||||
jsonls = sorted(glob.glob(f"{proj_dir}/*.jsonl"), key=os.path.getmtime, reverse=True)
|
||||
if not jsonls:
|
||||
continue
|
||||
latest = jsonls[0]
|
||||
if time.time() - os.path.getmtime(latest) > 300:
|
||||
continue
|
||||
try:
|
||||
with open(latest) as f:
|
||||
first = f.readline().strip()
|
||||
if not first:
|
||||
continue
|
||||
sid = json.loads(first).get('sessionId')
|
||||
if not sid:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
s['claude_session_id_own'] = sid
|
||||
drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: session id materialized: {sid}"})
|
||||
actions.append(f"updated session id: {sid}")
|
||||
|
||||
# === drift C (agy): agy 새 session id materialize (per-row own id) ===
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if not s.get('name', '').endswith('-creator-agy'):
|
||||
continue
|
||||
if s.get('status') != 'running':
|
||||
continue
|
||||
if s.get('agy_conversation_id_own'):
|
||||
continue
|
||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||
if not cwd:
|
||||
continue
|
||||
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||
if os.path.exists(lc):
|
||||
try:
|
||||
with open(lc) as f:
|
||||
lc_data = json.load(f)
|
||||
cid = lc_data.get(cwd)
|
||||
if cid and os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{cid}.db"):
|
||||
s['agy_conversation_id_own'] = cid
|
||||
drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: conversation id materialized: {cid}"})
|
||||
actions.append(f"updated conversation id: {cid}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# === drift D: stale UUID (cache 의 artifact 가 사라짐) — 보고만, 변경 없음 ===
|
||||
ai = d.get('agent_identities', {}) or {}
|
||||
cl = (ai.get('claude') or {})
|
||||
if cl.get('session_id'):
|
||||
sid = cl['session_id']
|
||||
if not glob.glob(f"{home}/.claude/projects/*/{sid}.jsonl"):
|
||||
drifts.append({'class': 'D', 'name': '(claude identity cache)',
|
||||
'msg': f"stale UUID in agent_identities.claude.session_id: {sid} (jsonl missing)"})
|
||||
ag = (ai.get('agy') or {})
|
||||
if ag.get('conversation_id'):
|
||||
cid = ag['conversation_id']
|
||||
if not os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{cid}.db"):
|
||||
drifts.append({'class': 'D', 'name': '(agy identity cache)',
|
||||
'msg': f"stale UUID in agent_identities.agy.conversation_id: {cid} (.db missing)"})
|
||||
|
||||
result = {
|
||||
'timestamp': now_iso,
|
||||
'yaml_path': yaml_path,
|
||||
'tmux_sessions_alive': sorted(f"{t['name']}|{t.get('server', 'default')}" for t in tmux_sessions),
|
||||
'tmux_confirmed': tmux_confirmed,
|
||||
'drifts': drifts,
|
||||
'actions': actions,
|
||||
}
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
# atomic 래퍼: actions 가 없으면 쓰기를 건너뛴다. env_python(dry-run)에선 무해.
|
||||
if not actions:
|
||||
raise SystemExit(0)
|
||||
PYEOF
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
printf '%s' "$RECON_SRC" | env_python "$AGENT_SESSIONS_YAML"
|
||||
else
|
||||
printf '%s' "$RECON_SRC" | atomic_dump_yaml "$AGENT_SESSIONS_YAML"
|
||||
fi
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib.sh — shared library for the multi-agent-* / agent-sessions-* skills.
|
||||
#
|
||||
# Single source of truth for the four things that were inconsistently
|
||||
# re-implemented across create/resume/delete/monitor (REVIEW.md §4.1):
|
||||
# - derive_session_name : the tmux session slug (P0-A)
|
||||
# - atomic_dump_yaml : flock + temp+rename + .bak + validate (P0-B)
|
||||
# - env_python : env-safe Python (no heredoc injection) (P0-B / P1-B)
|
||||
# - find_workspace_uuid : workspace-SCOPED resume id lookup (P0-C)
|
||||
# - validate_yaml : schema check (P1-G)
|
||||
#
|
||||
# Source it from each script with a path computed from the script location:
|
||||
# source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
#
|
||||
# HARD RULE: the agent-sessions.yaml file is only ever written through
|
||||
# atomic_dump_yaml. Never `open(yaml_path, 'w')` anywhere else.
|
||||
|
||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$HOME/PuKi/lab/agent_sessions/agent-sessions.yaml}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tmux Server Isolation support
|
||||
# ---------------------------------------------------------------------------
|
||||
TMUX_SERVER_NAME="${TMUX_SERVER_NAME:-default}"
|
||||
|
||||
_resolve_real_tmux_path() {
|
||||
if [ -z "${_REAL_TMUX_PATH:-}" ] || [[ "$_REAL_TMUX_PATH" == *"/multi-agent-tmux-shim/"* ]] || [[ "$_REAL_TMUX_PATH" == *"/skills/.bin"* ]]; then
|
||||
local dir save_ifs="$IFS"
|
||||
_REAL_TMUX_PATH=""
|
||||
IFS=:
|
||||
for dir in $PATH; do
|
||||
if [[ "$dir" != *"/multi-agent-tmux-shim/"* ]] && [[ "$dir" != *"/skills/.bin"* ]] && [ -x "$dir/tmux" ]; then
|
||||
_REAL_TMUX_PATH="$dir/tmux"
|
||||
break
|
||||
fi
|
||||
done
|
||||
IFS="$save_ifs"
|
||||
if [ -z "$_REAL_TMUX_PATH" ]; then
|
||||
_REAL_TMUX_PATH="tmux"
|
||||
fi
|
||||
export _REAL_TMUX_PATH
|
||||
fi
|
||||
}
|
||||
|
||||
_init_tmux_isolation() {
|
||||
_resolve_real_tmux_path
|
||||
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
|
||||
local wrapper_dir="${TMPDIR:-/tmp}/multi-agent-tmux-shim/${TMUX_SERVER_NAME}"
|
||||
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
||||
mkdir -p "$wrapper_dir"
|
||||
cat <<EOF > "$wrapper_dir/tmux"
|
||||
#!/usr/bin/env bash
|
||||
if [ -z "\${TMUX_SERVER_NAME:-}" ] || [ "\$TMUX_SERVER_NAME" = "default" ]; then
|
||||
exec "$_REAL_TMUX_PATH" "\$@"
|
||||
else
|
||||
exec "$_REAL_TMUX_PATH" -L "\$TMUX_SERVER_NAME" "\$@"
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$wrapper_dir/tmux"
|
||||
export PATH="$wrapper_dir:$PATH"
|
||||
fi
|
||||
else
|
||||
# 격리 비활성화 시 shim 자동 cleanup (PATH에서 제거)
|
||||
local new_path="" dir save_ifs="$IFS"
|
||||
IFS=:
|
||||
for dir in $PATH; do
|
||||
if [[ "$dir" != *"/multi-agent-tmux-shim/"* ]] && [[ "$dir" != *"/skills/.bin"* ]]; then
|
||||
if [ -z "$new_path" ]; then
|
||||
new_path="$dir"
|
||||
else
|
||||
new_path="$new_path:$dir"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
IFS="$save_ifs"
|
||||
export PATH="$new_path"
|
||||
fi
|
||||
}
|
||||
|
||||
_tmux() {
|
||||
_init_tmux_isolation
|
||||
if [ -z "${TMUX_SERVER_NAME:-}" ] || [ "$TMUX_SERVER_NAME" = "default" ]; then
|
||||
"$_REAL_TMUX_PATH" "$@"
|
||||
else
|
||||
"$_REAL_TMUX_PATH" -L "$TMUX_SERVER_NAME" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
tmux() {
|
||||
_tmux "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_tmux_server <session_name>
|
||||
#
|
||||
# Query agent-sessions.yaml to find the tmux_server associated with a session.
|
||||
# Fallback to TMUX_SERVER_NAME or 'default' if not registered or field is missing.
|
||||
# Prints the resolved server name on stdout.
|
||||
# ---------------------------------------------------------------------------
|
||||
resolve_tmux_server() {
|
||||
local session_name="$1"
|
||||
SESSION_NAME="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||
import os, sys, yaml
|
||||
name = os.environ['SESSION_NAME']
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
if os.path.exists(yaml_path):
|
||||
try:
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('name') == name:
|
||||
server = s.get('tmux_server')
|
||||
if server:
|
||||
print(server)
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback
|
||||
print(os.environ.get('TMUX_SERVER_NAME', 'default'))
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_session_name <workspace> <agent>
|
||||
#
|
||||
# THE single source of truth for the tmux session name. Rule:
|
||||
# slug = the two trailing path components of the absolute workspace,
|
||||
# '_' -> '-', lowercased, joined with '-'
|
||||
# name = "<slug>-creator-<agent>"
|
||||
#
|
||||
# /home/godopu16/PuKi/lab/landing_page/refer_landing_page + claude
|
||||
# -> landing-page-refer-landing-page-creator-claude
|
||||
#
|
||||
# Decision (REVIEW P0-A): the actual workspace basename (refer_landing_page)
|
||||
# IS included. The hand-written historical entry that dropped it
|
||||
# (lab-landing-page-creator-claude) was the bug, not the convention.
|
||||
# Every script and SKILL.md must use exactly this rule.
|
||||
# ---------------------------------------------------------------------------
|
||||
derive_session_name() {
|
||||
local workspace="$1" agent="$2"
|
||||
local abs parent work slug
|
||||
abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
|
||||
parent="$(basename "$(dirname "$abs")")"
|
||||
work="$(basename "$abs")"
|
||||
slug="$(printf '%s-%s' "$parent" "$work" | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
|
||||
printf '%s-creator-%s' "$slug" "$agent"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# env_python <yaml_path> [KEY=VALUE ...] (Python source read from stdin)
|
||||
#
|
||||
# Run python3 with the source supplied on stdin via a *quoted* heredoc, so the
|
||||
# shell never interpolates the source. All values are passed through the
|
||||
# environment (YAML_PATH plus any KEY=VALUE pairs). Untrusted data (workspace
|
||||
# paths, capture-pane text) must travel as env vars and be read via os.environ
|
||||
# inside the script — never spliced into the source. Read-only by convention;
|
||||
# use atomic_dump_yaml when you need to write the YAML.
|
||||
# ---------------------------------------------------------------------------
|
||||
env_python() {
|
||||
local yaml_path="$1"; shift
|
||||
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME")
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
*=*) envs+=("$1"); shift ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
env "${envs[@]}" python3 - "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# atomic_dump_yaml <yaml_path> [KEY=VALUE ...] (mutation source from stdin)
|
||||
#
|
||||
# The ONLY sanctioned way to write agent-sessions.yaml. It:
|
||||
# 1. takes an exclusive flock on <yaml_path>.lock (serialises all writers)
|
||||
# 2. loads the YAML into `d`
|
||||
# 3. exec()s the caller's mutation source (sees d, yaml, os, datetime,
|
||||
# timezone, glob, subprocess; reads values via os.environ). The mutation
|
||||
# may print and may `raise SystemExit(n)` to abort *without* writing.
|
||||
# 4. validates the resulting schema
|
||||
# 5. backs up to <yaml_path>.bak, then writes atomically (temp + os.replace)
|
||||
#
|
||||
# The mutation source is passed via env and exec()'d — it is never string
|
||||
# spliced and untrusted data never lands in Python source (P0-B / P1-B).
|
||||
# ---------------------------------------------------------------------------
|
||||
atomic_dump_yaml() {
|
||||
local yaml_path="$1"; shift
|
||||
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME")
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
*=*) envs+=("$1"); shift ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
local mutation; mutation="$(cat)"
|
||||
env "${envs[@]}" AGENT_SESSIONS_MUTATION="$mutation" python3 - <<'PYEOF'
|
||||
import os, sys, fcntl, tempfile, shutil, glob, subprocess, json
|
||||
from datetime import datetime, timezone
|
||||
import yaml
|
||||
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
lock_path = yaml_path + '.lock'
|
||||
|
||||
|
||||
def _validate(d):
|
||||
if not isinstance(d, dict):
|
||||
raise SystemExit("VALIDATE: top-level is not a mapping")
|
||||
sessions = d.get('tmux_sessions', [])
|
||||
if not isinstance(sessions, list):
|
||||
raise SystemExit("VALIDATE: tmux_sessions is not a list")
|
||||
valid = {'running', 'terminated', 'archived'}
|
||||
for i, s in enumerate(sessions):
|
||||
if not isinstance(s, dict):
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] not a mapping")
|
||||
if not s.get('name') or not s.get('status'):
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] missing name/status")
|
||||
if s['status'] not in valid:
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}")
|
||||
if not isinstance(s.get('pane'), dict):
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} missing pane")
|
||||
|
||||
|
||||
lock_fh = open(lock_path, 'w')
|
||||
fcntl.flock(lock_fh, fcntl.LOCK_EX)
|
||||
try:
|
||||
if os.path.exists(yaml_path):
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
else:
|
||||
d = {}
|
||||
|
||||
# --- caller mutation (module scope: sees d, yaml, os, glob, subprocess) ---
|
||||
exec(compile(os.environ['AGENT_SESSIONS_MUTATION'], '<mutation>', 'exec'), globals())
|
||||
|
||||
_validate(d)
|
||||
|
||||
if os.path.exists(yaml_path):
|
||||
try:
|
||||
shutil.copy2(yaml_path, yaml_path + '.bak')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dir_ = os.path.dirname(yaml_path) or '.'
|
||||
fd, tmp = tempfile.mkstemp(dir=dir_, prefix='.agent-sessions.', suffix='.tmp')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False,
|
||||
allow_unicode=True, width=4096)
|
||||
os.replace(tmp, yaml_path)
|
||||
except Exception:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
raise
|
||||
finally:
|
||||
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
||||
lock_fh.close()
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_yaml [yaml_path]
|
||||
#
|
||||
# Schema check (P1-G). Exits non-zero with an actionable message on failure.
|
||||
# Safe to call as a preflight in any mutator.
|
||||
# ---------------------------------------------------------------------------
|
||||
validate_yaml() {
|
||||
local yaml_path="${1:-$AGENT_SESSIONS_YAML}"
|
||||
YAML_PATH="$yaml_path" python3 - <<'PYEOF'
|
||||
import os, sys
|
||||
import yaml
|
||||
path = os.environ['YAML_PATH']
|
||||
try:
|
||||
with open(path) as f:
|
||||
d = yaml.safe_load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"VALIDATE: file not found: {path}", file=sys.stderr); sys.exit(1)
|
||||
except yaml.YAMLError as e:
|
||||
print(f"VALIDATE: YAML parse error: {e}", file=sys.stderr); sys.exit(1)
|
||||
d = d or {}
|
||||
if not isinstance(d, dict):
|
||||
print("VALIDATE: top-level is not a mapping", file=sys.stderr); sys.exit(1)
|
||||
sessions = d.get('tmux_sessions', [])
|
||||
if not isinstance(sessions, list):
|
||||
print("VALIDATE: tmux_sessions is not a list", file=sys.stderr); sys.exit(1)
|
||||
valid = {'running', 'terminated', 'archived'}
|
||||
for i, s in enumerate(sessions):
|
||||
if not isinstance(s, dict):
|
||||
print(f"VALIDATE: tmux_sessions[{i}] not a mapping", file=sys.stderr); sys.exit(1)
|
||||
for k in ('name', 'status'):
|
||||
if not s.get(k):
|
||||
print(f"VALIDATE: tmux_sessions[{i}] missing '{k}'", file=sys.stderr); sys.exit(1)
|
||||
if s['status'] not in valid:
|
||||
print(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}",
|
||||
file=sys.stderr); sys.exit(1)
|
||||
if not isinstance(s.get('pane'), dict):
|
||||
print(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} missing pane", file=sys.stderr); sys.exit(1)
|
||||
print(f"VALIDATE OK: {len(sessions)} session(s)")
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_workspace_uuid <workspace> <agent>
|
||||
#
|
||||
# Workspace-SCOPED resolution of the resume UUID (P0-C). It NEVER returns a
|
||||
# global agent_identities id unless that id's project_cwd matches THIS
|
||||
# workspace. Resolution order:
|
||||
# 1) tmux_sessions[] row whose pane.cwd == this workspace -> per-row own id
|
||||
# (claude_session_id_own / agy_conversation_id_own)
|
||||
# 2) on-disk scan scoped to this workspace
|
||||
# (claude: ~/.claude/projects/<key>/*.jsonl ; agy: last_conversations.json[cwd])
|
||||
# 3) agent_identities cache, ONLY when its project_cwd == this workspace
|
||||
# Prints the UUID on stdout (empty line if none). Always exits 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
find_workspace_uuid() {
|
||||
local workspace="$1" agent="$2"
|
||||
local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
|
||||
WS_ABS="$abs" AGENT="$agent" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||
import os, json, glob
|
||||
import yaml
|
||||
|
||||
ws = os.environ['WS_ABS']
|
||||
agent = os.environ['AGENT']
|
||||
home = os.environ['HOME_DIR']
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
|
||||
d = {}
|
||||
if os.path.exists(yaml_path):
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def jsonl_exists(uuid):
|
||||
key = ws.replace('/', '-').replace('_', '-')
|
||||
return os.path.exists(f"{home}/.claude/projects/{key}/{uuid}.jsonl")
|
||||
|
||||
|
||||
def db_exists(uuid):
|
||||
return os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{uuid}.db")
|
||||
|
||||
|
||||
def emit(u):
|
||||
print(u)
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
# 1) per-row own id for THIS workspace
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
if (s.get('pane') or {}).get('cwd') != ws:
|
||||
continue
|
||||
name = s.get('name', '')
|
||||
if agent == 'claude' and name.endswith('-creator-claude'):
|
||||
cand = s.get('claude_session_id_own')
|
||||
if cand and jsonl_exists(cand):
|
||||
emit(cand)
|
||||
if agent == 'agy' and name.endswith('-creator-agy'):
|
||||
cand = s.get('agy_conversation_id_own')
|
||||
if cand and db_exists(cand):
|
||||
emit(cand)
|
||||
|
||||
# 2) disk scan scoped to THIS workspace
|
||||
if agent == 'claude':
|
||||
key = ws.replace('/', '-').replace('_', '-')
|
||||
proj = f"{home}/.claude/projects/{key}"
|
||||
if os.path.isdir(proj):
|
||||
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
||||
sid = None
|
||||
try:
|
||||
with open(j) as f:
|
||||
first = f.readline().strip()
|
||||
if first:
|
||||
sid = json.loads(first).get('sessionId')
|
||||
except Exception:
|
||||
sid = None
|
||||
cand = sid or os.path.basename(j)[:-6]
|
||||
if cand and jsonl_exists(cand):
|
||||
emit(cand)
|
||||
elif agent == 'agy':
|
||||
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||
if os.path.exists(lc):
|
||||
cand = None
|
||||
try:
|
||||
cand = json.load(open(lc)).get(ws)
|
||||
except Exception:
|
||||
cand = None
|
||||
if cand and db_exists(cand):
|
||||
emit(cand)
|
||||
|
||||
# 3) agent_identities cache, workspace-checked only
|
||||
ai = (d.get('agent_identities') or {}).get(agent) or {}
|
||||
if ai.get('project_cwd') == ws:
|
||||
if agent == 'claude':
|
||||
cand = ai.get('session_id')
|
||||
if cand and jsonl_exists(cand):
|
||||
emit(cand)
|
||||
elif agent == 'agy':
|
||||
cand = ai.get('conversation_id')
|
||||
if cand and db_exists(cand):
|
||||
emit(cand)
|
||||
|
||||
print('')
|
||||
PYEOF
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
name: multi-agent-create
|
||||
description: "Create a new agent session (claude, antigravity/agy) in a dedicated tmux session for context-preserving long-running work. Always creates a tmux session — never backgrounds with nohup/disown. Writes the new session to ~/PuKi/lab/agent_sessions/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
|
||||
version: 1.0.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [terminal, tmux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [agent, tmux, claude, antigravity, agy, multi-agent, context, session]
|
||||
related_skills: [multi-agent-resume, multi-agent-delete, agent-sessions-monitor, claude-code]
|
||||
prereq_skills: [claude-code]
|
||||
---
|
||||
|
||||
# Multi-Agent Create — Start a Fresh Agent in a tmux Session
|
||||
|
||||
> **Companion skills**: `multi-agent-resume` (resume an existing UUID), `multi-agent-delete` (terminate), `agent-sessions-monitor` (live status).
|
||||
> **Single source of truth**: `~/PuKi/lab/agent_sessions/agent-sessions.yaml` (this skill writes to it; never read it ad-hoc — go through this skill).
|
||||
|
||||
## What this skill does
|
||||
|
||||
Spawn a new agent (`claude` or `agy`/antigravity-cli) in a **dedicated tmux session** for context-preserving long-running work. The tmux session is the *container*; the agent's session ID is *data* inside the container. **This skill creates the container + starts the agent — but does not resume an old conversation** (use `multi-agent-resume` for that).
|
||||
|
||||
For all agents: the tmux session name is produced by **`lib.sh::derive_session_name`** — the single source of truth shared by create/resume/delete/status/monitor (P0-A). The rule (verbatim from the function):
|
||||
|
||||
> slug = the **two trailing path components** of the absolute workspace, `_`→`-`, lowercased, joined with `-`; name = `<slug>-creator-<agent>`.
|
||||
|
||||
So `/home/godopu16/PuKi/lab/landing_page/refer_landing_page` + `claude` → `landing-page-refer-landing-page-creator-claude`. The workspace basename (`refer_landing_page`) **is** included; the hand-written historical entry that dropped it (`lab-landing-page-creator-claude`) was the bug, not the convention.
|
||||
|
||||
## Pre-flight checks
|
||||
|
||||
Before doing anything, verify the environment:
|
||||
|
||||
```bash
|
||||
# 1) tmux available and isolated server status
|
||||
command -v tmux || { echo "ERROR: tmux not installed"; exit 1; }
|
||||
echo "Tmux server name: ${TMUX_SERVER_NAME:-default}"
|
||||
|
||||
# 2) claude / agy available
|
||||
command -v claude # required for --agent claude
|
||||
command -v agy # required for --agent agy
|
||||
|
||||
# 3) claude auth (if --agent claude)
|
||||
claude auth status 2>&1 | python3 -c "import json,sys; d=json.load(sys.stdin); assert d.get('loggedIn'), 'claude not logged in'"
|
||||
|
||||
# 4) target workspace exists
|
||||
test -d "$WORKSPACE" || { echo "ERROR: workspace $WORKSPACE not a directory"; exit 1; }
|
||||
```
|
||||
|
||||
If any check fails → `kanban_block(reason="...")` (worker path) or report to user (interactive path). Do not proceed with a half-broken setup.
|
||||
|
||||
## Standard names
|
||||
|
||||
- **tmux session name**: `derive_session_name <workspace> <agent>` (lib.sh)
|
||||
- `<workspace-slug>` = `basename $(dirname $WORKSPACE)` `-` `basename $WORKSPACE` (lowercase, `_`→`-`)
|
||||
- examples: `landing-page-refer-landing-page-creator-claude`, `paper-pdf2md-creator-agy`
|
||||
- never re-derive this by hand — source lib.sh and call the function
|
||||
- **wrapper script** (claude only): `~/.local/bin/<workspace-slug>-creator-claude`
|
||||
- contents: tmux new-session with `claude` inside, auto-handles trust/bypass dialogs
|
||||
- see `~/PuKi/lab/landing_page/refer_landing_page/agent_sessions.md` for the canonical wrapper template
|
||||
|
||||
## Tmux Server Isolation (격리 서버)
|
||||
|
||||
When running multiple agent sessions alongside other workflows (e.g., cmux, Kanban workers, manual tmux sessions), sharing the default tmux server can lead to session name conflicts, monitoring clutter, and accidental destruction of user sessions via global commands.
|
||||
|
||||
To prevent this, you can run this skill inside an **isolated tmux server** using the `TMUX_SERVER_NAME` environment variable or the `--tmux-server <name>` flag (opt-in).
|
||||
|
||||
### How to use
|
||||
1. **Via Environment Variable**:
|
||||
```bash
|
||||
export TMUX_SERVER_NAME=multi-agent-canary
|
||||
# All subsequent commands (create, status, delete, etc.) will run in the isolated 'multi-agent-canary' tmux server.
|
||||
```
|
||||
2. **Via Option Flag**:
|
||||
```bash
|
||||
bash scripts/create_session.sh --workspace /path/to/project --agent claude --tmux-server multi-agent-canary
|
||||
```
|
||||
|
||||
### Recommended Alias
|
||||
You can set an alias in your shell to easily query sessions on the isolated server:
|
||||
```bash
|
||||
alias tmc='tmux -L multi-agent-canary'
|
||||
tmc ls # Lists only your multi-agent sessions
|
||||
```
|
||||
|
||||
### Safety Rules (Pitfall 29 Summary)
|
||||
- Never use global server termination commands like `tmux kill-server` or `tmux kill-session -a` as they will destroy all sessions on that server (including your own workspace sessions if they share the server).
|
||||
- By using an isolated server via `TMUX_SERVER_NAME`, your agent sessions are completely separated from your default user workspace, ensuring 0% interference.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
WORKSPACE=/path/to/project
|
||||
AGENT=claude # or agy
|
||||
source ~/PuKi/lab/agent_sessions/skills/lib.sh
|
||||
SESSION_NAME="$(derive_session_name "$WORKSPACE" "$AGENT")"
|
||||
|
||||
# 1. If session already alive, fail fast
|
||||
tmux has-session -t "$SESSION_NAME" 2>/dev/null && {
|
||||
echo "ERROR: tmux session '$SESSION_NAME' already exists. Use multi-agent-resume to attach or multi-agent-delete first."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 2. Spawn the tmux session with the agent inside
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
# Use the wrapper if it exists, else inline tmux new-session
|
||||
if [ -x "$HOME/.local/bin/$SESSION_NAME" ]; then
|
||||
nohup "$HOME/.local/bin/$SESSION_NAME" >/dev/null 2>&1 &
|
||||
else
|
||||
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude"
|
||||
fi
|
||||
;;
|
||||
agy)
|
||||
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "agy --dangerously-skip-permissions"
|
||||
;;
|
||||
*) echo "ERROR: --agent must be claude or agy, got: $AGENT"; exit 2 ;;
|
||||
esac
|
||||
|
||||
# 3. Wait for agent TUI to be ready (varies: claude ~5s, agy ~3s)
|
||||
sleep 6
|
||||
|
||||
# 4. Capture pane metadata
|
||||
PANE_PID=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}')
|
||||
PANE_CWD=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_path}')
|
||||
PANE_CMD=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_command}')
|
||||
TMUX_EPOCH=$(tmux list-sessions -F '#{session_created}' -t "$SESSION_NAME" 2>/dev/null | head -1)
|
||||
```
|
||||
|
||||
## Registering the session in agent-sessions.yaml
|
||||
|
||||
After spawn, append a new `tmux_sessions[]` entry to `~/PuKi/lab/agent_sessions/agent-sessions.yaml`:
|
||||
|
||||
```yaml
|
||||
- name: <SESSION_NAME>
|
||||
status: running
|
||||
tmux_session_created_at: 2026-06-17T...Z # ISO 8601 UTC
|
||||
tmux_session_epoch: <TMUX_EPOCH>
|
||||
tmux_server: <TMUX_SERVER_NAME> # Isolated server name (default: 'default')
|
||||
pane:
|
||||
index: 0
|
||||
pid: <PANE_PID>
|
||||
cmd: <AGENT> # 'claude' or 'agy'
|
||||
cmd_full: <full command line, see table below>
|
||||
cwd: <PANE_CWD>
|
||||
tui: # only for claude
|
||||
model: <from TUI status>
|
||||
provider: <from TUI status>
|
||||
plan: <from TUI status>
|
||||
account: <from TUI status>
|
||||
version: <from TUI status>
|
||||
start_command: <the exact tmux new-session command used>
|
||||
attach_command: "tmux attach -t <SESSION_NAME>"
|
||||
kill_command: "tmux kill-session -t <SESSION_NAME>"
|
||||
```
|
||||
|
||||
`cmd_full` per agent (this is the actual command line in the pane, not the resume command):
|
||||
|
||||
| agent | cmd_full |
|
||||
|---|---|
|
||||
| claude (interactive) | `claude` |
|
||||
| agy (interactive) | `agy --dangerously-skip-permissions` |
|
||||
|
||||
Use the `agent-sessions-yaml-edit` script in `scripts/` to safely append (preserves comments + format):
|
||||
|
||||
```bash
|
||||
bash ~/PuKi/lab/agent_sessions/skills/multi-agent-create/scripts/create_session.sh \
|
||||
--workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME"
|
||||
```
|
||||
|
||||
The script handles the YAML append, pane capture, and the `last_visible_status` placeholder.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside tmux. The whole point of this skill is *the tmux session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the tmux session via `tmux new-session -d`).
|
||||
- **Don't trust `--session-id <uuid>` flags blindly** — claude/agy may not accept a fixed session id on first spawn. The session id is *assigned* on first user message; you can read it back from `~/.claude/projects/.../session.jsonl` headers or `~/.gemini/.../cache/last_conversations.json` AFTER the first message.
|
||||
- **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the tmux behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template).
|
||||
- **Always use the workspace-relative path** in tmux `cwd` — relative paths break when tmux respawns in a different shell context.
|
||||
- **The first `claude` message generates the session id** — `multi-agent-create` only sets up the *container*. If you need a known session id for later resume, send a placeholder message (e.g. "init") and read it back, then call `multi-agent-resume` later.
|
||||
|
||||
## Verification
|
||||
|
||||
After spawn + YAML append:
|
||||
|
||||
```bash
|
||||
# 1. tmux session is alive
|
||||
tmux has-session -t "$SESSION_NAME" && echo OK || echo MISSING
|
||||
|
||||
# 2. pane has the expected cmd + cwd
|
||||
tmux list-panes -t "$SESSION_NAME" -F 'cmd=#{pane_current_command} cwd=#{pane_current_path}'
|
||||
|
||||
# 3. agent-sessions.yaml has the new entry
|
||||
python3 -c "
|
||||
import yaml
|
||||
d = yaml.safe_load(open('$HOME/PuKi/lab/agent_sessions/agent-sessions.yaml'))
|
||||
names = [s['name'] for s in d['tmux_sessions']]
|
||||
assert '$SESSION_NAME' in names, 'session not registered'
|
||||
print('OK:', names)
|
||||
"
|
||||
|
||||
# 4. Optional: send a probe via tmux send-keys and capture-pane
|
||||
tmux send-keys -t "$SESSION_NAME" "" Enter
|
||||
sleep 2
|
||||
tmux capture-pane -t "$SESSION_NAME" -p -S -20
|
||||
```
|
||||
|
||||
## When NOT to use this skill
|
||||
|
||||
- **Resuming an old conversation** → `multi-agent-resume`
|
||||
- **Killing an existing session** → `multi-agent-delete`
|
||||
- **Just attaching to an existing session** → `tmux attach -t <name>` (no skill needed)
|
||||
- **One-shot print mode (claude -p "...")** → no tmux needed; use `claude-code` skill's print mode
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
# create_session.sh — multi-agent-create 의 부속 스크립트
|
||||
# Usage:
|
||||
# bash create_session.sh --workspace <path> --agent <claude|agy> [--session <name>] [--wrapper]
|
||||
#
|
||||
# 동작:
|
||||
# 1) preflight: tmux/claude/agy 가용성, workspace 존재
|
||||
# 2) tmux 세션 이름 결정 (--session 없으면 자동)
|
||||
# 3) tmux 세션 시작 (claude 는 wrapper 우선, agy 는 인라인)
|
||||
# 4) pane 메타 캡처 (pid, cmd, cwd)
|
||||
# 5) agent-sessions.yaml 에 tmux_sessions[] 엔트리 append
|
||||
# 6) 검증 출력
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = success
|
||||
# 1 = preflight failure
|
||||
# 2 = invalid args
|
||||
# 3 = tmux session already exists (use multi-agent-resume or delete first)
|
||||
# 4 = agent-sessions.yaml append failure
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --workspace <path> --agent <claude|agy> [options]
|
||||
|
||||
Options:
|
||||
--workspace PATH project directory (required)
|
||||
--agent AGENT claude | agy (required)
|
||||
--session NAME tmux session name (default: derived from workspace)
|
||||
--wrapper force use of ~/.local/bin/<session> wrapper even if not present
|
||||
--dry-run print commands without executing
|
||||
--tmux-server NAME specify isolated tmux server name
|
||||
-h, --help this help
|
||||
EOF
|
||||
}
|
||||
|
||||
WORKSPACE=""
|
||||
AGENT=""
|
||||
SESSION_NAME=""
|
||||
USE_WRAPPER=0
|
||||
DRY_RUN=0
|
||||
TMUX_SERVER_OPT=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--workspace) WORKSPACE="$2"; shift 2 ;;
|
||||
--agent) AGENT="$2"; shift 2 ;;
|
||||
--session) SESSION_NAME="$2"; shift 2 ;;
|
||||
--wrapper) USE_WRAPPER=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--tmux-server) TMUX_SERVER_OPT="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -n "$TMUX_SERVER_OPT" ]; then
|
||||
export TMUX_SERVER_NAME="$TMUX_SERVER_OPT"
|
||||
fi
|
||||
|
||||
# Preflight
|
||||
[ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; usage; exit 2; }
|
||||
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; usage; exit 2; }
|
||||
[ -d "$WORKSPACE" ] || { echo "ERROR: workspace $WORKSPACE not a directory" >&2; exit 1; }
|
||||
command -v tmux >/dev/null || { echo "ERROR: tmux not installed" >&2; exit 1; }
|
||||
command -v "$AGENT" >/dev/null || { echo "ERROR: $AGENT CLI not in PATH" >&2; exit 1; }
|
||||
|
||||
# Auth Check (OAuth check for agy, loggedIn check for claude)
|
||||
if [ "$AGENT" = "claude" ]; then
|
||||
if ! claude auth status 2>/dev/null | grep -q '"loggedIn":\s*true'; then
|
||||
echo "ERROR: claude not logged in. Run 'claude auth login' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$AGENT" = "agy" ]; then
|
||||
if ! agy models >/dev/null 2>&1; then
|
||||
echo "ERROR: agy is not authenticated. Please log in first." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 세션 이름 — lib.sh::derive_session_name 이 단일 소스 (P0-A)
|
||||
if [ -z "$SESSION_NAME" ]; then
|
||||
SESSION_NAME="$(derive_session_name "$WORKSPACE" "$AGENT")"
|
||||
fi
|
||||
|
||||
# 이미 살아있으면 실패
|
||||
if _tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
echo "ERROR: tmux session '$SESSION_NAME' already exists. Use multi-agent-resume to attach, or multi-agent-delete first." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# tmux 세션 띄우기
|
||||
WRAPPER="$HOME/.local/bin/$SESSION_NAME"
|
||||
|
||||
spawn() {
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
if [ -x "$WRAPPER" ] || [ "$USE_WRAPPER" = "1" ]; then
|
||||
nohup "$WRAPPER" >/dev/null 2>&1 &
|
||||
disown
|
||||
else
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude"
|
||||
fi
|
||||
;;
|
||||
agy)
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "agy --dangerously-skip-permissions"
|
||||
;;
|
||||
*) echo "ERROR: --agent must be claude or agy, got: $AGENT" >&2; exit 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "[dry-run] would spawn: tmux session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
spawn
|
||||
|
||||
# TUI 준비 대기
|
||||
sleep 6
|
||||
|
||||
# pane 메타 캡처
|
||||
PANE_PID=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null || echo "")
|
||||
PANE_CWD=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE")
|
||||
PANE_CMD=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
|
||||
TMUX_EPOCH=$(date +%s)
|
||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
# cmd_full 결정
|
||||
case "$AGENT" in
|
||||
claude) CMD_FULL='claude' ;;
|
||||
agy) CMD_FULL='agy --dangerously-skip-permissions' ;;
|
||||
esac
|
||||
|
||||
# 시작 명령
|
||||
local_tmux="tmux"
|
||||
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
|
||||
local_tmux="tmux -L $TMUX_SERVER_NAME"
|
||||
fi
|
||||
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
if [ -x "$WRAPPER" ]; then
|
||||
START_CMD="$WRAPPER # ~/.local/bin 의 래퍼"
|
||||
else
|
||||
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"claude\""
|
||||
fi
|
||||
;;
|
||||
agy)
|
||||
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
;;
|
||||
esac
|
||||
|
||||
# agent-sessions.yaml 에 append
|
||||
if [ ! -f "$AGENT_SESSIONS_YAML" ]; then
|
||||
echo "ERROR: $AGENT_SESSIONS_YAML not found. Run init first." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
# atomic_dump_yaml: flock + temp+rename + .bak + schema validate (P0-B).
|
||||
# 모든 값은 환경변수로 전달 — heredoc interpolation 없음 (P1-B).
|
||||
# 자식 pid 는 bash 에서 pgrep 으로 미리 구함 (P2: 도구명 필터).
|
||||
CHILD_PID=0
|
||||
if [ "$AGENT" = "agy" ] && [ -n "$PANE_PID" ]; then
|
||||
CHILD_PID=$(pgrep -P "$PANE_PID" -x agy 2>/dev/null | head -1 || true)
|
||||
CHILD_PID="${CHILD_PID:-0}"
|
||||
fi
|
||||
|
||||
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
|
||||
TMUX_EPOCH="$TMUX_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
|
||||
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
|
||||
TMUX_SERVER_NAME="${TMUX_SERVER_NAME:-default}" <<'PYEOF'
|
||||
name = os.environ['SESSION_NAME']
|
||||
agent = os.environ['AGENT']
|
||||
pid = os.environ.get('PANE_PID', '')
|
||||
epoch = os.environ.get('TMUX_EPOCH', '')
|
||||
server_name = os.environ.get('TMUX_SERVER_NAME', 'default')
|
||||
server_opt = f"-L {server_name} " if server_name and server_name != 'default' else ""
|
||||
|
||||
sessions = d.setdefault('tmux_sessions', [])
|
||||
|
||||
# P0-D: 같은 이름 엔트리가 status=running 이면만 거부. terminated/archived 는
|
||||
# 재사용 가능 — 낡은 엔트리를 제거하고 새로 append (create -> delete -> create).
|
||||
running_same = [s for s in sessions if s.get('name') == name and s.get('status') == 'running']
|
||||
if running_same:
|
||||
print(f"ERROR: {name} already running in agent-sessions.yaml", flush=True)
|
||||
raise SystemExit(4)
|
||||
sessions[:] = [s for s in sessions if s.get('name') != name]
|
||||
|
||||
entry = {
|
||||
'name': name,
|
||||
'status': 'running',
|
||||
'tmux_session_created_at': os.environ['NOW_ISO'],
|
||||
'tmux_session_epoch': int(epoch) if epoch.isdigit() else 0,
|
||||
'tmux_server': server_name,
|
||||
'pane': {
|
||||
'index': 0,
|
||||
'pid': int(pid) if pid.isdigit() else 0,
|
||||
'cmd': agent,
|
||||
'cmd_full': os.environ['CMD_FULL'],
|
||||
'cwd': os.environ['PANE_CWD'],
|
||||
},
|
||||
'start_command': os.environ['START_CMD'],
|
||||
'attach_command': f'tmux {server_opt}attach -t {name}',
|
||||
'kill_command': f'tmux {server_opt}kill-session -t {name}',
|
||||
}
|
||||
|
||||
if agent == 'claude':
|
||||
entry['tui'] = {
|
||||
'model': '(unknown — capture after first message)',
|
||||
'provider': 'anthropic',
|
||||
'plan': '(unknown)',
|
||||
'account': '(unknown — read from claude auth status)',
|
||||
'version': '(unknown — read from TUI)',
|
||||
}
|
||||
entry['claude_session_id_own'] = None
|
||||
entry['last_visible_status'] = "TUI started; awaiting first user message"
|
||||
elif agent == 'agy':
|
||||
cp = os.environ.get('CHILD_PID', '0')
|
||||
entry['child_pid'] = int(cp) if cp.isdigit() else 0
|
||||
entry['agy_conversation_id_own'] = None
|
||||
entry['mcp_attachments'] = [
|
||||
{
|
||||
'name': 'stitch',
|
||||
'transport': 'mcp-remote',
|
||||
'endpoint': 'https://stitch.googleapis.com/mcp'
|
||||
}
|
||||
]
|
||||
entry['last_visible_status'] = "TUI started; awaiting first user message"
|
||||
|
||||
sessions.append(entry)
|
||||
|
||||
snap = d.setdefault('snapshot', {})
|
||||
snap['taken_at'] = os.environ['NOW_ISO']
|
||||
snap['cwd'] = os.environ['PANE_CWD']
|
||||
print(f"appended: {name}", flush=True)
|
||||
PYEOF
|
||||
|
||||
echo
|
||||
echo "=== created ==="
|
||||
echo "tmux session: $SESSION_NAME (pane pid $PANE_PID, cmd $PANE_CMD, cwd $PANE_CWD)"
|
||||
echo "agent-sessions.yaml updated"
|
||||
echo
|
||||
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
|
||||
echo "Attach: tmux -L $TMUX_SERVER_NAME attach -t $SESSION_NAME"
|
||||
else
|
||||
echo "Attach: tmux attach -t $SESSION_NAME"
|
||||
fi
|
||||
echo "Delete: use multi-agent-delete skill"
|
||||
echo "Resume: use multi-agent-resume skill (after first message creates a session id)"
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
name: multi-agent-delete
|
||||
description: "Terminate an agent tmux session (claude, antigravity/agy) and update ~/PuKi/lab/agent_sessions/agent-sessions.yaml to mark it terminated with timestamp. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved for future resume. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
|
||||
version: 1.0.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [terminal, tmux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [agent, tmux, claude, antigravity, agy, multi-agent, delete, terminate, cleanup]
|
||||
related_skills: [multi-agent-create, multi-agent-resume, agent-sessions-monitor]
|
||||
prereq_skills: [multi-agent-create, multi-agent-resume]
|
||||
---
|
||||
|
||||
# Multi-Agent Delete — Terminate an Agent tmux Session
|
||||
|
||||
> **Companion skills**: `multi-agent-create` (start), `multi-agent-resume` (re-attach), `agent-sessions-monitor` (live status).
|
||||
> **Tmux Isolation**: `delete` 명령은 YAML의 `tmux_server` 필드를 자동으로 파싱하여 해당 격리 서버의 세션을 안전하게 종료(kill)하므로, `TMUX_SERVER_NAME` 환경변수를 수동으로 지정할 필요가 없습니다.
|
||||
> **Single source of truth**: `~/PuKi/lab/agent_sessions/agent-sessions.yaml`.
|
||||
|
||||
## What this skill does
|
||||
|
||||
Stop an agent's tmux session and **mark the YAML entry as terminated**. Preserves:
|
||||
|
||||
- The tmux session's recorded `pane.pid / cmd / cwd / mcp_attachments` for audit
|
||||
- The agent's on-disk conversation (claude `*.jsonl`, agy `conversations/*.db`) — so the user can `multi-agent-resume` later
|
||||
- The `start_command` so a future `multi-agent-create --session <name>` reproduces the same tmux spec
|
||||
|
||||
The user explicitly chooses:
|
||||
|
||||
- **soft delete** (default): update YAML only; leave tmux running. Useful when "delete" really means "I'm done with this card".
|
||||
- **hard delete**: `tmux kill-session` + update YAML. The default when the user says "kill it" or "end the session".
|
||||
|
||||
## Pre-flight
|
||||
|
||||
```bash
|
||||
SESSION_NAME=<workspace>-creator-<agent> # convention
|
||||
AGENT_SESSIONS_YAML=~/PuKi/lab/agent_sessions/agent-sessions.yaml
|
||||
|
||||
# 1) Session is registered?
|
||||
python3 -c "
|
||||
import yaml
|
||||
d = yaml.safe_load(open('$AGENT_SESSIONS_YAML'))
|
||||
names = [s['name'] for s in d.get('tmux_sessions', [])]
|
||||
if '$SESSION_NAME' not in names:
|
||||
print('NOT in YAML — refusing to delete (no audit trail). Use multi-agent-create first, or pass --force-no-yaml.')
|
||||
raise SystemExit(1)
|
||||
"
|
||||
|
||||
# 2) Already terminated?
|
||||
ALREADY=$(python3 -c "
|
||||
import yaml
|
||||
d = yaml.safe_load(open('$AGENT_SESSIONS_YAML'))
|
||||
s = [x for x in d['tmux_sessions'] if x['name']=='$SESSION_NAME'][0]
|
||||
print(s.get('status', 'unknown'))
|
||||
")
|
||||
if [ "$ALREADY" = "terminated" ]; then
|
||||
echo "Already terminated at $(python3 -c "import yaml; d=yaml.safe_load(open('$AGENT_SESSIONS_YAML')); print([x for x in d['tmux_sessions'] if x['name']=='$SESSION_NAME'][0].get('terminated_at',''))")"
|
||||
echo "Re-running will just refresh the timestamp. Continue? (--yes to skip)"
|
||||
fi
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# 1. soft delete (YAML only — tmux left running)
|
||||
bash ~/PuKi/lab/agent_sessions/skills/multi-agent-delete/scripts/delete_session.sh \
|
||||
--session "$SESSION_NAME" --mode soft
|
||||
|
||||
# 2. hard delete (default — kill tmux + update YAML)
|
||||
bash ~/PuKi/lab/agent_sessions/skills/multi-agent-delete/scripts/delete_session.sh \
|
||||
--session "$SESSION_NAME" --mode hard
|
||||
|
||||
# 3. hard delete + clean up on-disk conversation (DANGEROUS)
|
||||
# — this prevents any future resume. Use only when user is certain.
|
||||
bash ~/PuKi/lab/agent_sessions/skills/multi-agent-delete/scripts/delete_session.sh \
|
||||
--session "$SESSION_NAME" --mode hard --purge-conversation
|
||||
```
|
||||
|
||||
The script:
|
||||
1. Verifies the session is in agent-sessions.yaml
|
||||
2. Captures the `last_visible_status` from `tmux capture-pane` (so we have a final TUI snapshot for audit)
|
||||
3. For `hard` mode: `tmux kill-session -t <name>` (which auto-SIGTERMs children including the agent)
|
||||
4. For `purge-conversation`: deletes `~/.claude/projects/.../jsonl` (claude) or `~/.gemini/antigravity-cli/conversations/...db` + `brain/...` (agy)
|
||||
5. Updates the YAML entry:
|
||||
```yaml
|
||||
- name: <SESSION_NAME>
|
||||
status: terminated
|
||||
terminated_at: 2026-06-17T...Z
|
||||
terminated_at_epoch: ...
|
||||
# all original fields preserved
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`tmux kill-session` doesn't just kill the session — it sends SIGHUP to the pane's child processes too.** This is usually what you want (the agent process dies, no zombie reparenting to init). But if you wanted to keep the agent running outside tmux for some reason, use `soft` mode.
|
||||
- **Don't delete on-disk artifacts by default** — the agent's `*.jsonl` / `conversations/*.db` is the data that `multi-agent-resume` needs. `--purge-conversation` is for when the user is genuinely done with the conversation and wants zero recovery chance.
|
||||
- **YAML is append-only until you write a delete** — if a previous run left the entry as `running` but tmux is actually dead (crash, host reboot), the YAML is stale. Running `multi-agent-delete --mode hard` will detect "tmux already dead, just update YAML" and proceed.
|
||||
- **Don't delete the `claude_session_id_own: null` placeholder** — when the user creates a fresh session with `multi-agent-create` and never sent a message, the entry has `claude_session_id_own: null`. Deletion must preserve that field (it's the audit trail showing "this tmux session never produced a session id of its own").
|
||||
- **Monitor skill may still be tracking** — if `agent-sessions-monitor` is running a heartbeat loop, deleting a session while it watches will trigger its `tmux ls != yaml` reconciliation. That's expected — let the monitor run, it will mark the entry as `terminated` on its own. Don't fight it.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# 1. tmux gone
|
||||
tmux has-session -t "$SESSION_NAME" 2>/dev/null && echo "STILL ALIVE" || echo "OK: tmux gone"
|
||||
|
||||
# 2. YAML has terminated entry
|
||||
python3 -c "
|
||||
import yaml
|
||||
d = yaml.safe_load(open('$AGENT_SESSIONS_YAML'))
|
||||
s = [x for x in d['tmux_sessions'] if x['name']=='$SESSION_NAME'][0]
|
||||
assert s['status'] == 'terminated', f'expected terminated, got {s[\"status\"]}'
|
||||
assert s.get('terminated_at'), 'missing terminated_at'
|
||||
print(f'OK: terminated at {s[\"terminated_at\"]}')
|
||||
print(f' preserved: pane.pid={s[\"pane\"][\"pid\"]}, cmd={s[\"pane\"][\"cmd\"]}, cwd={s[\"pane\"][\"cwd\"]}')
|
||||
"
|
||||
|
||||
# 3. (if --purge-conversation) disk artifacts gone
|
||||
[ -f "$HOME/.claude/projects/<projkey>/<uuid>.jsonl" ] && echo "WARN: jsonl still exists" || echo "OK: jsonl purged"
|
||||
```
|
||||
|
||||
## When NOT to use this skill
|
||||
|
||||
- **Just detaching** → `tmux detach` (Ctrl-B d) or just close the terminal. The tmux session keeps running.
|
||||
- **Stopping the agent inside but keeping tmux** → send `Ctrl-C` or `/exit` (claude) / `Ctrl-D` (agy) via `tmux send-keys`. The tmux session stays but the agent process is gone; you can then `multi-agent-create` again to spawn a fresh agent in the same tmux session.
|
||||
- **Replacing an existing session with a new one** → `multi-agent-delete --mode hard` first, then `multi-agent-create`.
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env bash
|
||||
# delete_session.sh — multi-agent-delete 의 부속 스크립트
|
||||
# Usage:
|
||||
# bash delete_session.sh --session <name> [--agent claude|agy] \
|
||||
# [--mode soft|hard] [--purge-conversation] [--yes]
|
||||
#
|
||||
# mode:
|
||||
# soft — YAML 을 status=archived 로 마크, tmux 세션은 그대로 둠 (P1-A:
|
||||
# terminated 는 tmux 가 실제로 죽은 상태에만 사용)
|
||||
# hard — tmux kill-session + YAML status=terminated
|
||||
# --purge-conversation: --mode hard 일 때만. 삭제 대상 세션의 *워크스페이스에
|
||||
# 격리된* conversation artifact 만 삭제 (P0-C). 전역
|
||||
# agent_identities 를 참조하지 않음. resume 불가.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = success | 1 = YAML not found / not registered | 2 = invalid args
|
||||
# 3 = interactive confirmation required (--yes 누락)
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --session <name> [--agent claude|agy] [--mode soft|hard] [--purge-conversation] [--yes]
|
||||
|
||||
Modes:
|
||||
soft — update YAML to status=archived, leave tmux running
|
||||
hard (default) — tmux kill-session + update YAML to status=terminated
|
||||
EOF
|
||||
}
|
||||
|
||||
SESSION_NAME=""
|
||||
AGENT=""
|
||||
MODE="hard" # "delete" 의 자연스러운 의미 = tmux 까지 종료
|
||||
PURGE=0
|
||||
YES=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--session) SESSION_NAME="$2"; shift 2 ;;
|
||||
--agent) AGENT="$2"; shift 2 ;;
|
||||
--mode) MODE="$2"; shift 2 ;;
|
||||
--purge-conversation) PURGE=1; shift ;;
|
||||
--yes) YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; usage; exit 2; }
|
||||
[ "$MODE" = "soft" ] || [ "$MODE" = "hard" ] || { echo "ERROR: --mode must be soft or hard" >&2; exit 2; }
|
||||
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
|
||||
|
||||
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
|
||||
|
||||
# --agent 미지정 시 이름 suffix 로 fallback (P1-F)
|
||||
if [ -z "$AGENT" ]; then
|
||||
case "$SESSION_NAME" in
|
||||
*-creator-claude) AGENT=claude ;;
|
||||
*-creator-agy) AGENT=agy ;;
|
||||
*) echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2; exit 2 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 추출
|
||||
TARGET_CWD=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF'
|
||||
import os, yaml
|
||||
name = os.environ['SESSION_NAME']
|
||||
with open(os.environ['YAML_PATH']) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('name') == name:
|
||||
print((s.get('pane') or {}).get('cwd', ''))
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(7)
|
||||
PYEOF
|
||||
) || {
|
||||
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# purge 확인
|
||||
if [ "$PURGE" = "1" ] && [ "$YES" != "1" ]; then
|
||||
echo "DANGER: --purge-conversation will DELETE this workspace's on-disk conversation."
|
||||
echo " workspace: ${TARGET_CWD:-<unknown>}"
|
||||
echo " This means: no future multi-agent-resume for this session."
|
||||
echo " Re-run with --yes to confirm."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# purge 대상 UUID 를 워크스페이스 격리해서 해결 (P0-C — 전역 참조 금지)
|
||||
PURGE_UUID=""
|
||||
if [ "$PURGE" = "1" ] && [ -n "$TARGET_CWD" ]; then
|
||||
PURGE_UUID=$(find_workspace_uuid "$TARGET_CWD" "$AGENT" || true)
|
||||
fi
|
||||
|
||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
NOW_EPOCH=$(date +%s)
|
||||
|
||||
# tmux 상태 + 마지막 TUI 스냅샷 (살아있을 때만; capture-pane 내용은 env 로만 전달)
|
||||
TMUX_ALIVE=0
|
||||
LAST_STATUS=""
|
||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
TMUX_ALIVE=1
|
||||
LAST_STATUS=$(tmux capture-pane -t "$SESSION_NAME" -p -S -10 2>/dev/null | tr '\n' ' ' | head -c 500 || true)
|
||||
fi
|
||||
|
||||
# hard 모드면 tmux 죽임
|
||||
if [ "$MODE" = "hard" ] && [ "$TMUX_ALIVE" = "1" ]; then
|
||||
tmux kill-session -t "$SESSION_NAME"
|
||||
echo "killed tmux: $SESSION_NAME"
|
||||
elif [ "$MODE" = "hard" ]; then
|
||||
echo "tmux already dead, just updating YAML"
|
||||
fi
|
||||
|
||||
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" MODE="$MODE" PURGE="$PURGE" \
|
||||
NOW_ISO="$NOW_ISO" NOW_EPOCH="$NOW_EPOCH" LAST_STATUS="$LAST_STATUS" \
|
||||
PURGE_UUID="$PURGE_UUID" TARGET_CWD="$TARGET_CWD" <<'PYEOF'
|
||||
import shutil
|
||||
name = os.environ['SESSION_NAME']
|
||||
agent = os.environ['AGENT']
|
||||
mode = os.environ['MODE']
|
||||
purge = os.environ['PURGE'] == '1'
|
||||
now = os.environ['NOW_ISO']
|
||||
home = os.environ['HOME_DIR']
|
||||
last_status = os.environ.get('LAST_STATUS', '')
|
||||
purge_uuid = os.environ.get('PURGE_UUID', '').strip()
|
||||
ws = os.environ.get('TARGET_CWD', '')
|
||||
|
||||
target = None
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('name') == name:
|
||||
target = s
|
||||
break
|
||||
if target is None:
|
||||
print(f"ERROR: disappeared during script: {name}", flush=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
if mode == 'soft':
|
||||
# P1-A: soft 는 tmux 가 살아있으니 archived. terminated 아님.
|
||||
target['status'] = 'archived'
|
||||
target['archived_at'] = now
|
||||
target['termination_mode'] = 'soft'
|
||||
else:
|
||||
target['status'] = 'terminated'
|
||||
target['terminated_at'] = now
|
||||
target['terminated_at_epoch'] = int(os.environ['NOW_EPOCH'])
|
||||
target['termination_mode'] = 'hard'
|
||||
|
||||
if last_status:
|
||||
target['last_visible_status_at_termination'] = last_status
|
||||
|
||||
# --purge-conversation: 워크스페이스 격리된 UUID 의 디스크 artifact 만 삭제 (P0-C)
|
||||
if purge and purge_uuid:
|
||||
if agent == 'claude':
|
||||
key = ws.replace('/', '-').replace('_', '-')
|
||||
jsonl = f"{home}/.claude/projects/{key}/{purge_uuid}.jsonl"
|
||||
if os.path.exists(jsonl):
|
||||
os.remove(jsonl)
|
||||
print(f"purged: {jsonl}", flush=True)
|
||||
target['claude_session_id_own'] = None
|
||||
elif agent == 'agy':
|
||||
db = f"{home}/.gemini/antigravity-cli/conversations/{purge_uuid}.db"
|
||||
if os.path.exists(db):
|
||||
os.remove(db)
|
||||
print(f"purged: {db}", flush=True)
|
||||
brain = f"{home}/.gemini/antigravity-cli/brain/{purge_uuid}"
|
||||
if os.path.isdir(brain):
|
||||
shutil.rmtree(brain)
|
||||
print(f"purged: {brain}", flush=True)
|
||||
target['agy_conversation_id_own'] = None
|
||||
# agent_identities 는 cache — 이 워크스페이스 것일 때만 비운다
|
||||
ai = (d.get('agent_identities') or {}).get(agent) or {}
|
||||
if ai.get('project_cwd') == ws:
|
||||
if agent == 'claude' and ai.get('session_id') == purge_uuid:
|
||||
ai['session_id'] = None
|
||||
ai['session_jsonl'] = None
|
||||
ai.pop('session_size_bytes', None)
|
||||
ai.pop('session_lines', None)
|
||||
elif agent == 'agy' and ai.get('conversation_id') == purge_uuid:
|
||||
ai['conversation_id'] = None
|
||||
ai['conversation_db'] = None
|
||||
ai['conversation_brain_dir'] = None
|
||||
elif purge and not purge_uuid:
|
||||
print("WARN: --purge-conversation requested but no workspace-scoped UUID resolved; nothing purged", flush=True)
|
||||
|
||||
print(f"updated: {name} status={target['status']}", flush=True)
|
||||
PYEOF
|
||||
|
||||
echo
|
||||
echo "=== delete complete ==="
|
||||
echo " session: $SESSION_NAME"
|
||||
echo " agent: $AGENT"
|
||||
echo " mode: $MODE"
|
||||
echo " purge: $PURGE${PURGE_UUID:+ (uuid $PURGE_UUID)}"
|
||||
echo " time: $NOW_ISO"
|
||||
echo
|
||||
echo "Recovery: multi-agent-create + multi-agent-resume 로 동일 컨텍스트 복원 가능"
|
||||
echo " (단 --purge-conversation 사용 시 복원 불가)"
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: multi-agent-resume
|
||||
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a tmux session. Reads ~/PuKi/lab/agent_sessions/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a tmux session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose tmux died but the agent's conversation is still on disk."
|
||||
version: 1.0.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [terminal, tmux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [agent, tmux, claude, antigravity, agy, multi-agent, context, resume, session-id]
|
||||
related_skills: [multi-agent-create, multi-agent-delete, agent-sessions-monitor, claude-code]
|
||||
prereq_skills: [multi-agent-create]
|
||||
---
|
||||
|
||||
# Multi-Agent Resume — Reattach to a Saved Conversation
|
||||
|
||||
> **Companion skills**: `multi-agent-create` (start a fresh agent), `multi-agent-delete` (terminate), `agent-sessions-monitor` (live status).
|
||||
> **Tmux Isolation**: `TMUX_SERVER_NAME` env var를 create에서 설정한 경우, 동일 서버에서 동작합니다. 자세한 격리 패턴은 [multi-agent-create/SKILL.md](file:///home/godopu16/PuKi/laa/canary_projects/advanced_multi_agent/skills/multi-agent-create/SKILL.md) 참조.
|
||||
> **Single source of truth**: `~/PuKi/lab/agent_sessions/agent-sessions.yaml`.
|
||||
|
||||
## What this skill does
|
||||
|
||||
**Container + data reconstruction**: spawn a tmux session (the container), then run the agent inside with a specific session id (the data) so the previous conversation's context is restored.
|
||||
|
||||
Three cases this skill handles:
|
||||
|
||||
1. **tmux is dead, conversation lives** — `agent-sessions.yaml` has the UUID. The JSONL/db is on disk. Re-spawn the tmux session + run `claude -r <id>` / `agy --conversation <id>`.
|
||||
2. **tmux is alive but empty** — You started a session with `multi-agent-create` but haven't sent a message yet (so no session id was assigned). The user can either send their first message (and the id is auto-assigned), or you can read the *workspace's* most recent conversation from `~/.gemini/antigravity-cli/cache/last_conversations.json` for agy, or the latest `*.jsonl` in `~/.claude/projects/<workspace-key>/` for claude.
|
||||
3. **tmux is alive AND the agent inside is already running** — Just attach. No re-spawn needed.
|
||||
|
||||
## UUID resolution order
|
||||
|
||||
`agent-sessions.yaml` is the *primary* source. The skill reads in this order:
|
||||
|
||||
1. **`agent-sessions.yaml` → `agent_identities.<agent>.session_id` (claude) / `conversation_id` (agy)** — explicit saved value
|
||||
2. **`agent-sessions.yaml` → `agent_identities.<agent>.session_jsonl` (claude) / `conversation_db` (agy)** — the on-disk artifact
|
||||
3. **Fallback: scan disk for the workspace's most recent conversation** —
|
||||
- claude: `ls -t ~/.claude/projects/<workspace-key>/*.jsonl | head -1` and parse the `sessionId` from the first line
|
||||
- agy: `jq -r '."<workspace>"' ~/.gemini/antigravity-cli/cache/last_conversations.json`
|
||||
|
||||
If all three are empty → the workspace has no conversation yet. Fall back to `multi-agent-create`.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
WORKSPACE=/path/to/project
|
||||
AGENT=claude # or agy
|
||||
SESSION_NAME=<workspace>-creator-<agent> # same convention as multi-agent-create
|
||||
|
||||
# 1. Resolve the session id
|
||||
UUID=$(bash ~/PuKi/lab/agent_sessions/skills/multi-agent-resume/scripts/resolve_session_id.sh \
|
||||
--workspace "$WORKSPACE" --agent "$AGENT")
|
||||
|
||||
if [ -z "$UUID" ]; then
|
||||
echo "No saved session for $WORKSPACE ($AGENT). Use multi-agent-create first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve the isolated tmux server name
|
||||
source ~/PuKi/lab/agent_sessions/skills/lib.sh
|
||||
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
|
||||
|
||||
# 2. If tmux is alive, attach. Done.
|
||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
echo "tmux '$SESSION_NAME' already running. Attaching..."
|
||||
exec tmux attach -t "$SESSION_NAME"
|
||||
fi
|
||||
|
||||
# 3. Spawn new tmux session + run agent with the saved id
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \
|
||||
"claude --dangerously-skip-permissions -r $UUID"
|
||||
# auto-handle trust / bypass dialogs
|
||||
sleep 5
|
||||
tmux send-keys -t "$SESSION_NAME" Enter 2>/dev/null || true
|
||||
sleep 3
|
||||
tmux send-keys -t "$SESSION_NAME" Down 2>/dev/null || true
|
||||
sleep 0.3
|
||||
tmux send-keys -t "$SESSION_NAME" Enter 2>/dev/null || true
|
||||
;;
|
||||
agy)
|
||||
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \
|
||||
"agy --dangerously-skip-permissions --conversation $UUID"
|
||||
;;
|
||||
esac
|
||||
|
||||
# 4. Update agent-sessions.yaml: status running, last_visible_status
|
||||
bash ~/PuKi/lab/agent_sessions/skills/multi-agent-resume/scripts/update_yaml_resumed.sh \
|
||||
--session "$SESSION_NAME" --uuid "$UUID"
|
||||
|
||||
# 5. Attach
|
||||
tmux attach -t "$SESSION_NAME"
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`claude -r` requires the SAME project directory** — if the workspace path differs from when the session was created, claude will create a new project dir key (`-home-...-different-name`) and put the resume in a different location. Always `-c` (cd to workspace) before running.
|
||||
- **agy's `--conversation` flag name varies by version** — older versions used `--resume` or `-r`. Check `agy --help | grep -E "conversation|resume"` and use the right flag. v1.0.x: `--conversation`.
|
||||
- **The first message after resume might re-trigger TUI dialogs** — if the original session was created with `--dangerously-skip-permissions`, those flags are NOT persisted; you must re-apply them on resume. The script above re-passes them.
|
||||
- **Don't resume if the session is brand new and empty** — `multi-agent-create` already set up an empty container; sending a probe message ("init") is the right way to materialize a session id, NOT `claude -r` with a placeholder.
|
||||
- **`agy --conversation <id>` will fail if the conversation was deleted from disk** — check `~/.gemini/antigravity-cli/conversations/<uuid>.db` exists before attempting resume. If missing, the conversation is gone; you need a fresh session via `multi-agent-create`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# 1. tmux alive with the right cmd
|
||||
tmux list-panes -t "$SESSION_NAME" -F 'cmd=#{pane_current_command} cwd=#{pane_current_path}'
|
||||
|
||||
# 2. agent-sessions.yaml updated
|
||||
python3 -c "
|
||||
import yaml
|
||||
d = yaml.safe_load(open('$HOME/PuKi/lab/agent_sessions/agent-sessions.yaml'))
|
||||
s = [s for s in d['tmux_sessions'] if s['name'] == '$SESSION_NAME'][0]
|
||||
print(f' status: {s[\"status\"]}')
|
||||
print(f' pane.cmd_full: {s[\"pane\"][\"cmd_full\"]}')
|
||||
"
|
||||
|
||||
# 3. TUI shows resumed conversation (capture-pane to verify)
|
||||
sleep 5
|
||||
tmux capture-pane -t "$SESSION_NAME" -p -S -30
|
||||
# look for the previous message at top of the buffer (claude) or last_visible_status set (agy)
|
||||
```
|
||||
|
||||
## When NOT to use this skill
|
||||
|
||||
- **No saved session yet** → `multi-agent-create`
|
||||
- **Killing an existing session** → `multi-agent-delete`
|
||||
- **Just attaching** → `tmux attach -t <name>` (no skill needed)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# resolve_session_id.sh — multi-agent-resume 의 부속 스크립트
|
||||
# Usage:
|
||||
# bash resolve_session_id.sh --workspace <path> --agent <claude|agy>
|
||||
# 출력: stdout 으로 UUID 한 줄 (없으면 빈 줄 + exit 0)
|
||||
#
|
||||
# P0-C: 전역 agent_identities 를 즉시 반환하지 않는다. lib.sh::find_workspace_uuid
|
||||
# 가 워크스페이스 격리된 해결 경로(per-row own id -> 디스크 스캔 -> cwd 일치하는
|
||||
# cache)만 사용. 다른 워크스페이스의 UUID 를 절대 반환하지 않음.
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --workspace <path> --agent <claude|agy>
|
||||
Outputs the resolved UUID on stdout (empty if not found).
|
||||
EOF
|
||||
}
|
||||
|
||||
WORKSPACE=""
|
||||
AGENT=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--workspace) WORKSPACE="$2"; shift 2 ;;
|
||||
--agent) AGENT="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; exit 2; }
|
||||
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; exit 2; }
|
||||
case "$AGENT" in
|
||||
claude|agy) ;;
|
||||
*) echo "ERROR: --agent must be claude or agy" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
find_workspace_uuid "$WORKSPACE" "$AGENT"
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# update_yaml_resumed.sh — multi-agent-resume 의 부속 스크립트
|
||||
# Resume 한 세션의 agent-sessions.yaml 엔트리를 status=running + resume 메타로 갱신.
|
||||
# resume UUID 를 per-row own id (claude_session_id_own / agy_conversation_id_own)
|
||||
# 에 박는다 — agent_identities 전역은 더 이상 primary 아님 (cache 로 강등, P0-C/단계 e).
|
||||
#
|
||||
# Usage: bash update_yaml_resumed.sh --session <name> --uuid <id> [--agent claude|agy]
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --session <name> --uuid <id> [--agent claude|agy]
|
||||
EOF
|
||||
}
|
||||
|
||||
SESSION_NAME=""
|
||||
UUID=""
|
||||
AGENT=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--session) SESSION_NAME="$2"; shift 2 ;;
|
||||
--uuid) UUID="$2"; shift 2 ;;
|
||||
--agent) AGENT="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; }
|
||||
[ -n "$UUID" ] || { echo "ERROR: --uuid required" >&2; exit 2; }
|
||||
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
|
||||
|
||||
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
|
||||
|
||||
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
|
||||
if [ -z "$AGENT" ]; then
|
||||
case "$SESSION_NAME" in
|
||||
*-creator-claude) AGENT=claude ;;
|
||||
*-creator-agy) AGENT=agy ;;
|
||||
*) echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2; exit 2 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
# 새 tmux pane pid / 자식 pid 를 bash 에서 캡처 (env 로 전달, P1-B)
|
||||
PANE_PID=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
|
||||
PANE_PID="${PANE_PID:-}"
|
||||
CHILD_PID=0
|
||||
if [ "$AGENT" = "agy" ] && [ -n "$PANE_PID" ]; then
|
||||
CHILD_PID=$(pgrep -P "$PANE_PID" -x agy 2>/dev/null | head -1 || true)
|
||||
CHILD_PID="${CHILD_PID:-0}"
|
||||
fi
|
||||
|
||||
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
SESSION_NAME="$SESSION_NAME" UUID="$UUID" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
|
||||
PANE_PID="$PANE_PID" CHILD_PID="$CHILD_PID" <<'PYEOF'
|
||||
name = os.environ['SESSION_NAME']
|
||||
uuid = os.environ['UUID']
|
||||
agent = os.environ['AGENT']
|
||||
now = os.environ['NOW_ISO']
|
||||
pane_pid = os.environ.get('PANE_PID', '')
|
||||
|
||||
target = None
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('name') == name:
|
||||
target = s
|
||||
break
|
||||
|
||||
if target is None:
|
||||
print(f"ERROR: session not in YAML: {name}", flush=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
target['status'] = 'running'
|
||||
target.pop('terminated_at', None)
|
||||
target.pop('terminated_at_epoch', None)
|
||||
target.pop('termination_mode', None)
|
||||
target.pop('archived_at', None)
|
||||
target['last_visible_status'] = f'resumed conversation {uuid} at {now}'
|
||||
|
||||
target.setdefault('pane', {})
|
||||
if pane_pid.isdigit():
|
||||
target['pane']['pid'] = int(pane_pid)
|
||||
|
||||
if agent == 'claude':
|
||||
target['pane']['cmd'] = 'claude'
|
||||
target['pane']['cmd_full'] = f'claude --dangerously-skip-permissions -r {uuid}'
|
||||
target['claude_session_id_own'] = uuid
|
||||
elif agent == 'agy':
|
||||
target['pane']['cmd'] = 'agy'
|
||||
target['pane']['cmd_full'] = f'agy --dangerously-skip-permissions --conversation {uuid}'
|
||||
target['agy_conversation_id_own'] = uuid
|
||||
cp = os.environ.get('CHILD_PID', '0')
|
||||
if cp.isdigit() and int(cp) > 0:
|
||||
target['child_pid'] = int(cp)
|
||||
|
||||
snap = d.setdefault('snapshot', {})
|
||||
snap['taken_at'] = now
|
||||
snap.pop('terminated_at', None)
|
||||
snap.pop('terminated_at_epoch', None)
|
||||
|
||||
print(f"updated: {name} status=running (resume id -> per-row own id)", flush=True)
|
||||
PYEOF
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: multi-agent-status
|
||||
description: "Read-only instant snapshot of all agent tmux sessions — name, YAML status, tmux alive, pane cmd/cwd, resume UUID on disk, and any drift. No Kanban, no mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up a Kanban monitor worker."
|
||||
version: 1.0.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [terminal, tmux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [agent, tmux, claude, antigravity, agy, status, read-only, snapshot]
|
||||
related_skills: [multi-agent-create, multi-agent-resume, multi-agent-delete, agent-sessions-monitor]
|
||||
prereq_skills: [multi-agent-create, agent-sessions-monitor]
|
||||
---
|
||||
|
||||
# Multi-Agent Status — Read-Only Instant Snapshot
|
||||
|
||||
> **Companion skills**: `multi-agent-create` (start), `multi-agent-resume` (re-attach), `multi-agent-delete` (terminate), `agent-sessions-monitor` (live polling).
|
||||
> **Tmux Isolation**: `status` 명령은 YAML에 등록된 모든 세션의 격리 서버(`tmux_server` 필드)를 자동으로 조회하여 상태를 확인하므로, `TMUX_SERVER_NAME` 환경변수를 수동으로 지정하지 않아도 모든 격리 서버의 세션 상태를 통합 조회합니다.
|
||||
> **Single source of truth**: `~/PuKi/lab/agent_sessions/agent-sessions.yaml`.
|
||||
|
||||
## What this skill does
|
||||
|
||||
Print a single table of every agent tmux session, comparing YAML state to actual tmux state. **No mutation. No Kanban. No polling loop.**
|
||||
|
||||
This is the "what's running right now?" answer — faster than dispatching `agent-sessions-monitor` (which polls every 30s) and safer than `reconcile.sh --once --emit-diff` (which mutates as a side effect).
|
||||
|
||||
## Pre-flight
|
||||
|
||||
```bash
|
||||
command -v tmux
|
||||
command -v python3
|
||||
test -f ~/PuKi/lab/agent_sessions/agent-sessions.yaml
|
||||
```
|
||||
|
||||
If `agent-sessions.yaml` doesn't exist or is malformed → print clear error, exit 1. **Do not create it.** (Use `multi-agent-create` first.)
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
bash ~/PuKi/lab/agent_sessions/skills/multi-agent-status/scripts/status.sh [--json]
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. Calls `reconcile.sh --once --emit-diff --dry-run` (read-only; no YAML mutation) for the drift snapshot
|
||||
2. Loads `agent-sessions.yaml` (read-only) to enrich the table
|
||||
3. For each row in `tmux_sessions[]`:
|
||||
- tmux alive? (via `tmux has-session -t <name>`)
|
||||
- pane cmd, cwd (via `tmux list-panes`)
|
||||
- resume UUID on disk? (claude: `~/.claude/projects/<key>/<uuid>.jsonl`; agy: `~/.gemini/antigravity-cli/conversations/<uuid>.db`)
|
||||
4. For each tmux session matching `*-creator-*` not in YAML → flag as "unregistered"
|
||||
5. Prints a table (default) or JSON (with `--json`)
|
||||
|
||||
## Output format (default = aligned table)
|
||||
|
||||
```
|
||||
AGENT SESSIONS STATUS
|
||||
yaml_path: ~/PuKi/lab/agent_sessions/agent-sessions.yaml
|
||||
tmux_sessions_alive: 2
|
||||
yaml_entries: 3
|
||||
unregistered: 0
|
||||
drifts: 0
|
||||
|
||||
NAME | YAML | TMUX | PANE CMD | PANE CWD | RESUME UUID ON DISK
|
||||
--------------------------------------------------|----------|-------|-------------------|---------------------------------------------------|--------------------
|
||||
lab-landing-page-creator-claude | running | ✓ | claude | /home/.../refer_landing_page | 87dc548e-... ✓
|
||||
lab-landing-page-creator-agy | terminated| ✗ | - | - | 22255a9a-... ✓ (orphan)
|
||||
lab-paper-pdf2md-creator-claude | running | ✓ | claude | /home/.../paper-pdf2md | -
|
||||
|
||||
DRIFTS
|
||||
(none)
|
||||
|
||||
UNREGISTERED TMUX SESSIONS
|
||||
(none)
|
||||
```
|
||||
|
||||
## Output format (`--json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"yaml_path": "...",
|
||||
"tmux_sessions_alive": ["..."],
|
||||
"yaml_entries": [...],
|
||||
"rows": [
|
||||
{
|
||||
"name": "lab-landing-page-creator-claude",
|
||||
"yaml_status": "running",
|
||||
"tmux_alive": true,
|
||||
"pane_cmd": "claude",
|
||||
"pane_cwd": "/home/.../refer_landing_page",
|
||||
"resume_uuid_on_disk": true,
|
||||
"drift": null
|
||||
},
|
||||
{
|
||||
"name": "lab-landing-page-creator-agy",
|
||||
"yaml_status": "terminated",
|
||||
"tmux_alive": false,
|
||||
"drift": "yaml-says-terminated-but-disk-uuid-still-present"
|
||||
}
|
||||
],
|
||||
"unregistered": [],
|
||||
"drifts": []
|
||||
}
|
||||
```
|
||||
|
||||
## Drift classes (read-only — never mutates)
|
||||
|
||||
| Class | Detection | Meaning |
|
||||
|---|---|---|
|
||||
| `A` | YAML `running`, tmux dead | session died without going through `multi-agent-delete`. *Could* auto-terminate but won't — that's `agent-sessions-monitor`'s job. |
|
||||
| `B` | tmux alive, not in YAML | ad-hoc session someone started without `multi-agent-create`. Suggest: "use multi-agent-create to register, or tmux kill-session to clean up." |
|
||||
| `C` | YAML has `claude_session_id_own: null` AND a new *.jsonl exists | new session id materialized; suggest: "run multi-agent-resume or reconcile to register it." |
|
||||
| `D` | YAML has UUID in `agent_identities`, but the on-disk artifact is gone | stale UUID; user should `multi-agent-delete --purge-conversation` to clean up. |
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Do NOT use this skill to drive mutations** — the output is a snapshot, not a call to action. If you need to fix drifts, dispatch `agent-sessions-monitor` (Kanban worker) or run `multi-agent-resume` / `multi-agent-delete` manually.
|
||||
- **Read-only is enforced by script** — `status.sh` opens the YAML with `open(path)` (no `'w'`), never calls `tmux kill-session`, never writes anywhere. The `reconcile.sh --dry-run` mode is the same path.
|
||||
- **If `agent-sessions.yaml` is malformed** — print the YAML error verbatim and exit 1. Do NOT attempt recovery (that's `multi-agent-delete --purge-conversation` or manual edit's job).
|
||||
- **Sessions outside the `<workspace>-creator-*` naming convention** are still shown but tagged `ad-hoc` — they didn't go through `multi-agent-create` and aren't tracked in YAML.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Is the claude session still running?" → this skill, not the monitor
|
||||
- "What UUID does this workspace have?" → this skill
|
||||
- "Is there drift between YAML and reality?" → this skill, then dispatch monitor or fix manually
|
||||
- Quick sanity check before dispatching a long Kanban task
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- Continuous live tracking → `agent-sessions-monitor` (Kanban worker)
|
||||
- Recovering from corruption → manual edit + `.bak` restore
|
||||
- Polling more than once a minute → `agent-sessions-monitor` (it dedupes)
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# status.sh — multi-agent-status 의 부속 스크립트 (READ-ONLY)
|
||||
# 한 번 호출로 현재 agent 세션 상태표를 출력. 부수효과 없음.
|
||||
# reconcile.sh --dry-run 을 재사용해 drift 를 계산하고 (P1-E), YAML/디스크에서
|
||||
# 보강한 표를 그린다. YAML 을 절대 수정하지 않는다.
|
||||
#
|
||||
# Usage: bash status.sh [--json]
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
RECONCILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/agent-sessions-monitor/scripts/reconcile.sh"
|
||||
|
||||
JSON=0
|
||||
[ "${1:-}" = "--json" ] && JSON=1
|
||||
|
||||
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found. Run multi-agent-create first." >&2; exit 1; }
|
||||
|
||||
# read-only drift snapshot — reconcile.sh --dry-run (no side effects)
|
||||
DRIFT_JSON="$(bash "$RECONCILE" --once --emit-diff --dry-run)"
|
||||
|
||||
if [ "$JSON" = "1" ]; then
|
||||
printf '%s\n' "$DRIFT_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||
import os, json, glob
|
||||
import yaml
|
||||
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
home = os.environ['HOME_DIR']
|
||||
drift = json.loads(os.environ['DRIFT_JSON'])
|
||||
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
|
||||
alive = set(drift.get('tmux_sessions_alive', []))
|
||||
drift_by_name = {}
|
||||
for dr in drift.get('drifts', []):
|
||||
drift_by_name.setdefault(dr['name'], []).append(dr['class'])
|
||||
|
||||
|
||||
def resume_on_disk(s):
|
||||
# workspace-SCOPED check only — per-row own id, never a global identity (P0-C)
|
||||
name = s.get('name', '')
|
||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||
if name.endswith('-creator-claude'):
|
||||
u = s.get('claude_session_id_own')
|
||||
if u:
|
||||
key = cwd.replace('/', '-').replace('_', '-')
|
||||
return 'yes' if os.path.exists(f"{home}/.claude/projects/{key}/{u}.jsonl") else 'MISSING'
|
||||
key = cwd.replace('/', '-').replace('_', '-')
|
||||
return 'scan' if glob.glob(f"{home}/.claude/projects/{key}/*.jsonl") else 'no'
|
||||
if name.endswith('-creator-agy'):
|
||||
u = s.get('agy_conversation_id_own')
|
||||
if u:
|
||||
return 'yes' if os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{u}.db") else 'MISSING'
|
||||
return 'no'
|
||||
return '?'
|
||||
|
||||
|
||||
sessions = d.get('tmux_sessions', [])
|
||||
print(f"agent-sessions status — {drift['timestamp']} (tmux_confirmed={drift['tmux_confirmed']})")
|
||||
print("=" * 116)
|
||||
print(f"{'NAME':<44} {'SERVER':<12} {'YAML':<10} {'TMUX':<6} {'CMD':<6} {'RESUME':<8} DRIFT")
|
||||
print("-" * 116)
|
||||
if not sessions:
|
||||
print("(no sessions registered)")
|
||||
for s in sessions:
|
||||
name = s.get('name', '?')
|
||||
server = s.get('tmux_server') or 'default'
|
||||
status = s.get('status', '?')
|
||||
tmux = 'alive' if f"{name}|{server}" in alive else 'dead'
|
||||
cmd = (s.get('pane') or {}).get('cmd', '?')
|
||||
res = resume_on_disk(s)
|
||||
drs = ','.join(drift_by_name.get(name, [])) or '-'
|
||||
print(f"{name:<44} {server:<12} {status:<10} {tmux:<6} {cmd:<6} {res:<8} {drs}")
|
||||
# drifts not tied to a registered row (e.g. class B unregistered, class D cache)
|
||||
known = {s.get('name') for s in sessions}
|
||||
extra = [dr for dr in drift.get('drifts', []) if dr['name'] not in known]
|
||||
if extra:
|
||||
print("-" * 116)
|
||||
for dr in extra:
|
||||
print(f" [{dr['class']}] {dr['msg']}")
|
||||
print("=" * 116)
|
||||
print(f"alive tmux: {sorted(alive)}")
|
||||
PYEOF
|
||||
Reference in New Issue
Block a user