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:
+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
|
||||
}
|
||||
Reference in New Issue
Block a user