feat(opencode): wire 29-point CLI lifecycle scripts, drift-C reconciler, and skill docs

This commit is contained in:
2026-08-29 14:59:01 +09:00
parent 7341186ef9
commit 94af7f6b8a
14 changed files with 303 additions and 35 deletions
+5 -1
View File
@@ -607,15 +607,18 @@ print('\t'.join(env_flags) + '\n' + ' '.join(binary_tokens))
*-creator-agy|*-planner-agy|*-reviewer-agy) kind="agy" ;; *-creator-agy|*-planner-agy|*-reviewer-agy) kind="agy" ;;
*-creator-hermes|*-planner-hermes|*-reviewer-hermes) kind="hermes" ;; *-creator-hermes|*-planner-hermes|*-reviewer-hermes) kind="hermes" ;;
*-creator-grok|*-planner-grok|*-reviewer-grok) kind="grok" ;; *-creator-grok|*-planner-grok|*-reviewer-grok) kind="grok" ;;
*-creator-opencode|*-planner-opencode|*-reviewer-opencode) kind="opencode" ;;
*) *)
if echo "$name" | grep -qi "agy"; then kind="agy" if echo "$name" | grep -qi "agy"; then kind="agy"
elif echo "$name" | grep -qi "claude"; then kind="claude" elif echo "$name" | grep -qi "claude"; then kind="claude"
elif echo "$name" | grep -qi "hermes"; then kind="hermes" elif echo "$name" | grep -qi "hermes"; then kind="hermes"
elif echo "$name" | grep -qi "grok"; then kind="grok" elif echo "$name" | grep -qi "grok"; then kind="grok"
elif echo "$name" | grep -qi "opencode"; then kind="opencode"
elif echo "${final_cmd:-}" | grep -qi "agy"; then kind="agy" elif echo "${final_cmd:-}" | grep -qi "agy"; then kind="agy"
elif echo "${final_cmd:-}" | grep -qi "claude"; then kind="claude" elif echo "${final_cmd:-}" | grep -qi "claude"; then kind="claude"
elif echo "${final_cmd:-}" | grep -qi "hermes"; then kind="hermes" elif echo "${final_cmd:-}" | grep -qi "hermes"; then kind="hermes"
elif echo "${final_cmd:-}" | grep -qi "grok"; then kind="grok" elif echo "${final_cmd:-}" | grep -qi "grok"; then kind="grok"
elif echo "${final_cmd:-}" | grep -qi "opencode"; then kind="opencode"
fi fi
;; ;;
esac esac
@@ -629,7 +632,7 @@ try:
tokens = shlex.split(cmd) tokens = shlex.split(cmd)
if tokens: if tokens:
first = tokens[0] first = tokens[0]
if first in ('claude', 'agy', 'hermes', 'grok') or any(first.endswith('/' + a) for a in ('claude', 'agy', 'hermes', 'grok')) or (kind and (first == kind or first.endswith('/' + kind))): if first in ('claude', 'agy', 'hermes', 'grok', 'opencode') or any(first.endswith('/' + a) for a in ('claude', 'agy', 'hermes', 'grok', 'opencode')) or (kind and (first == kind or first.endswith('/' + kind))):
tokens = tokens[1:] tokens = tokens[1:]
print(' '.join(shlex.quote(t) for t in tokens)) print(' '.join(shlex.quote(t) for t in tokens))
except Exception: except Exception:
@@ -2059,6 +2062,7 @@ send_keys_safe() {
*-agy|*-agy-[0-9]*|agy|agy-[0-9]*) _sks_agent="agy" ;; *-agy|*-agy-[0-9]*|agy|agy-[0-9]*) _sks_agent="agy" ;;
*-hermes|*-hermes-[0-9]*|hermes|hermes-[0-9]*) _sks_agent="hermes" ;; *-hermes|*-hermes-[0-9]*|hermes|hermes-[0-9]*) _sks_agent="hermes" ;;
*-grok|*-grok-[0-9]*|grok|grok-[0-9]*) _sks_agent="grok" ;; *-grok|*-grok-[0-9]*|grok|grok-[0-9]*) _sks_agent="grok" ;;
*-opencode|*-opencode-[0-9]*|opencode|opencode-[0-9]*) _sks_agent="opencode" ;;
esac esac
if [ -n "$_sks_agent" ]; then if [ -n "$_sks_agent" ]; then
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$_sks_agent" 2>/dev/null || true)" eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$_sks_agent" 2>/dev/null || true)"
@@ -129,7 +129,7 @@ herdr_sessions:
```bash ```bash
WORKSPACE=/path/to/project WORKSPACE=/path/to/project
AGENT=claude # claude | agy | hermes | grok — always pass it explicitly AGENT=claude # claude | agy | hermes | grok | opencode — always pass it explicitly
source .agents/skills/lib.sh source .agents/skills/lib.sh
SESSION_NAME="$(derive_session_name "$WORKSPACE" "$AGENT")" SESSION_NAME="$(derive_session_name "$WORKSPACE" "$AGENT")"
@@ -160,7 +160,10 @@ case "$AGENT" in
grok) grok)
herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "grok --permission-mode bypassPermissions" herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "grok --permission-mode bypassPermissions"
;; ;;
*) echo "ERROR: --agent must be claude, agy, hermes, or grok, got: $AGENT"; exit 2 ;; opencode)
herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "opencode --auto --agent build"
;;
*) echo "ERROR: --agent must be claude, agy, hermes, grok, or opencode, got: $AGENT"; exit 2 ;;
esac esac
# 3. Wait for agent TUI to be ready (varies: claude ~5s, agy ~3s) # 3. Wait for agent TUI to be ready (varies: claude ~5s, agy ~3s)
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# create_session.sh — multi-agent-mux-create 의 부속 스크립트 # create_session.sh — multi-agent-mux-create 의 부속 스크립트
# Usage: # Usage:
# bash create_session.sh --workspace <path> --agent <claude|agy|hermes|grok> --role <role> [--session <name>] [--herdr-session <name>] [--wrapper] # bash create_session.sh --workspace <path> --agent <claude|agy|hermes|grok|opencode> --role <role> [--session <name>] [--herdr-session <name>] [--wrapper]
# #
# 동작: # 동작:
# 1) preflight: herdr/claude/agy 가용성, workspace 존재 # 1) preflight: herdr/claude/agy 가용성, workspace 존재
@@ -26,11 +26,11 @@ source "$_lib_sh"
usage() { usage() {
cat <<EOF cat <<EOF
Usage: $0 --workspace <path> --agent <claude|agy|hermes|grok> --role <role> [options] Usage: $0 --workspace <path> --agent <claude|agy|hermes|grok|opencode> --role <role> [options]
Options: Options:
--workspace PATH project directory (required) --workspace PATH project directory (required)
--agent AGENT claude | agy | hermes | grok (required) --agent AGENT claude | agy | hermes | grok | opencode (required)
--role ROLE assigned role (required) --role ROLE assigned role (required)
--session NAME herdr session name (default: derived from workspace) --session NAME herdr session name (default: derived from workspace)
--wrapper force use of ~/.local/bin/<session> wrapper even if not present --wrapper force use of ~/.local/bin/<session> wrapper even if not present
@@ -90,8 +90,8 @@ fi
[ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; usage; exit 2; } [ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; usage; exit 2; }
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; usage; exit 2; } [ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; usage; exit 2; }
case "$AGENT" in case "$AGENT" in
claude|agy|hermes|grok) ;; claude|agy|hermes|grok|opencode) ;;
*) echo "ERROR: --agent must be claude, agy, hermes or grok, got: $AGENT" >&2; exit 2 ;; *) echo "ERROR: --agent must be claude, agy, hermes, grok, or opencode, got: $AGENT" >&2; exit 2 ;;
esac esac
[ -n "$ROLE" ] || { echo "ERROR: --role required" >&2; usage; exit 2; } [ -n "$ROLE" ] || { echo "ERROR: --role required" >&2; usage; exit 2; }
[ -d "$WORKSPACE" ] || { echo "ERROR: workspace $WORKSPACE not a directory" >&2; exit 1; } [ -d "$WORKSPACE" ] || { echo "ERROR: workspace $WORKSPACE not a directory" >&2; exit 1; }
@@ -126,6 +126,13 @@ elif [ "$AGENT" = "grok" ]; then
echo "ERROR: grok is not functional or configured." >&2 echo "ERROR: grok is not functional or configured." >&2
exit 1 exit 1
fi fi
elif [ "$AGENT" = "opencode" ]; then
if [ -f "$HOME/.local/share/opencode/auth.json" ] || [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ] || [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${GROQ_API_KEY:-}" ]; then
true
elif ! opencode --version >/dev/null 2>&1; then
echo "ERROR: opencode is not functional or configured." >&2
exit 1
fi
fi fi
# 세션 이름 — lib.sh::derive_session_name 이 단일 소스 (P0-A) # 세션 이름 — lib.sh::derive_session_name 이 단일 소스 (P0-A)
@@ -181,6 +188,8 @@ if [ -z "$CMD_FULL" ]; then
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ;; claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ;;
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;; agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;; hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;;
grok) CMD_FULL="${RESOLVED_BIN} --permission-mode bypassPermissions" ;;
opencode) CMD_FULL="${RESOLVED_BIN} --auto --agent build" ;;
esac esac
fi fi
@@ -204,7 +213,13 @@ spawn() {
agy|hermes|grok) agy|hermes|grok)
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL" HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
;; ;;
*) echo "ERROR: --agent must be claude, agy, hermes or grok, got: $AGENT" >&2; exit 2 ;; opencode)
if [ -z "${OPENCODE_PERMISSION:-}" ]; then
export OPENCODE_PERMISSION='{"*":"allow"}'
fi
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
;;
*) echo "ERROR: --agent must be claude, agy, hermes, grok, or opencode, got: $AGENT" >&2; exit 2 ;;
esac esac
} }
@@ -312,6 +327,7 @@ if [ -n "$SUBMIT_JOB_PROMPT" ]; then
hermes) delegate_agent="hermes-agent" ;; hermes) delegate_agent="hermes-agent" ;;
agy) delegate_agent="antigravity-cli" ;; agy) delegate_agent="antigravity-cli" ;;
grok) delegate_agent="grok-build" ;; grok) delegate_agent="grok-build" ;;
opencode) delegate_agent="opencode-cli" ;;
*) echo "ERROR: cannot resolve delegate agent key for '$AGENT'" >&2; exit 2 ;; *) echo "ERROR: cannot resolve delegate agent key for '$AGENT'" >&2; exit 2 ;;
esac esac
fi fi
@@ -419,6 +435,13 @@ elif agent == 'grok':
entry['session_id_source'] = 'assigned' if assigned else 'pending-discovery' entry['session_id_source'] = 'assigned' if assigned else 'pending-discovery'
entry['session_id_verified'] = False entry['session_id_verified'] = False
entry['last_visible_status'] = "assigned (awaiting first message)" if assigned else "unverified" entry['last_visible_status'] = "assigned (awaiting first message)" if assigned else "unverified"
elif agent == 'opencode':
cp = os.environ.get('CHILD_PID', '0')
entry['child_pid'] = int(cp) if cp.isdigit() else 0
entry['opencode_session_id_own'] = None
entry['session_id_source'] = 'pending-discovery'
entry['session_id_verified'] = False
entry['last_visible_status'] = "unverified"
last_vis_override = os.environ.get('LAST_VISIBLE_STATUS_OVERRIDE', '') last_vis_override = os.environ.get('LAST_VISIBLE_STATUS_OVERRIDE', '')
if last_vis_override: if last_vis_override:
@@ -309,6 +309,10 @@ if not agent:
agent = 'agy' agent = 'agy'
elif 'hermes' in segments: elif 'hermes' in segments:
agent = 'hermes' agent = 'hermes'
elif 'grok' in segments:
agent = 'grok'
elif 'opencode' in segments:
agent = 'opencode'
else: else:
agent = 'claude' agent = 'claude'
print(agent) print(agent)
@@ -317,11 +317,13 @@ fi
# atomic_dump_yaml(flock + temp+rename) 로 같은 소스를 돌린다. atomic 래퍼에서는 # atomic_dump_yaml(flock + temp+rename) 로 같은 소스를 돌린다. atomic 래퍼에서는
# 'actions' 가 없으면 SystemExit(0) 으로 쓰기를 건너뛴다 (불필요한 재포맷 방지). # 'actions' 가 없으면 SystemExit(0) 으로 쓰기를 건너뛴다 (불필요한 재포맷 방지).
read -r -d '' RECON_SRC <<'PYEOF' || true read -r -d '' RECON_SRC <<'PYEOF' || true
import os, json, glob, subprocess, time, sqlite3, re import os, json, glob, subprocess, time, sqlite3, re, shutil
from datetime import datetime, timezone from datetime import datetime, timezone
import yaml import yaml
from lib_py.verify_session import verify_session_uuid, workspace_key from lib_py.verify_session import verify_session_uuid, workspace_key
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
def _slug(path): def _slug(path):
if not path: if not path:
@@ -518,7 +520,7 @@ if herdr_confirmed:
agent = None agent = None
role = 'creator' role = 'creator'
for r_name in ('creator', 'planner', 'reviewer'): for r_name in ('creator', 'planner', 'reviewer'):
for a_name in ('claude', 'agy', 'hermes', 'grok'): for a_name in ('claude', 'agy', 'hermes', 'grok', 'opencode'):
if name.endswith(f"-{r_name}-{a_name}"): if name.endswith(f"-{r_name}-{a_name}"):
role = r_name role = r_name
agent = a_name agent = a_name
@@ -537,7 +539,7 @@ if herdr_confirmed:
if tok.startswith('MAM_MANAGED='): if tok.startswith('MAM_MANAGED='):
managed_path = tok.split('=', 1)[1] managed_path = tok.split('=', 1)[1]
if os.path.realpath(managed_path) == os.path.realpath(workspace_root): if os.path.realpath(managed_path) == os.path.realpath(workspace_root):
for a_name in ('claude', 'agy', 'hermes', 'grok'): for a_name in ('claude', 'agy', 'hermes', 'grok', 'opencode'):
if a_name in pm_check.get('cmd', '') or a_name in pm_check.get('cmd_full', ''): if a_name in pm_check.get('cmd', '') or a_name in pm_check.get('cmd_full', ''):
agent = a_name agent = a_name
break break
@@ -598,6 +600,9 @@ if herdr_confirmed:
elif agent == 'hermes': elif agent == 'hermes':
entry['child_pid'] = 0 entry['child_pid'] = 0
entry['hermes_conversation_id_own'] = None entry['hermes_conversation_id_own'] = None
elif agent == 'opencode':
entry['child_pid'] = 0
entry['opencode_session_id_own'] = None
d.setdefault('herdr_sessions', []).append(entry) d.setdefault('herdr_sessions', []).append(entry)
yaml_session_names.add(name) yaml_session_names.add(name)
drifts.append({'class': 'B', 'name': name, drifts.append({'class': 'B', 'name': name,
@@ -608,7 +613,7 @@ def row_agent(s):
return agent_of_row(s) return agent_of_row(s)
OWN_KEY_BY_AGENT = { OWN_KEY_BY_AGENT = {
a: _get_own_key(a) for a in ('claude', 'agy', 'hermes', 'grok') a: _get_own_key(a) for a in ('claude', 'agy', 'hermes', 'grok', 'opencode')
} }
# === drift C0: 지정된 ID 는 발견이 아니라 '확인'만 필요하다 === # === drift C0: 지정된 ID 는 발견이 아니라 '확인'만 필요하다 ===
@@ -779,6 +784,118 @@ for s in d.get('herdr_sessions', []):
else: else:
_pin_and_verify_resume(s, 'hermes', cwd, uuid, degraded=True) _pin_and_verify_resume(s, 'hermes', cwd, uuid, degraded=True)
# === drift C (opencode): opencode 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
if row_agent(s) != 'opencode':
continue
if s.get('status') != 'running':
continue
if s.get('opencode_session_id_own'):
continue
cwd = (s.get('pane') or {}).get('cwd', '')
if not cwd:
continue
# Resolve DB path via shared OpenCodeAgentAdapter helper with fallbacks
odb = None
try:
op_adapter = get_adapter('opencode')
if op_adapter:
odb = op_adapter._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
except Exception:
pass
if not odb:
opencode_base = f"{home}/.local/share/opencode"
candidates = [
f"{opencode_base}/opencode.db",
f"{opencode_base}/storage.db",
]
for cand in candidates:
if os.path.exists(cand):
odb = cand
break
if not odb and os.path.isdir(f"{opencode_base}/project"):
for root, _, files in os.walk(f"{opencode_base}/project"):
for f in files:
if f.endswith(".db"):
odb = os.path.join(root, f)
break
if odb and os.path.exists(odb):
break
if not odb or not os.path.exists(odb):
continue
epoch_threshold = s.get('herdr_session_epoch', 0)
sibling_claimed = [
other.get('opencode_session_id_own')
for other in d.get('herdr_sessions', [])
if other is not s
and (other.get('pane') or {}).get('cwd') == cwd
and other.get('status') not in ('stopped', 'terminated')
and other.get('opencode_session_id_own')
]
s_eval = dict(s)
s_eval['_sibling_claimed_uuids'] = sibling_claimed
valid_candidates = []
try:
conn = sqlite3.connect(odb)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(session)")
cols = [col[1] for col in cursor.fetchall()]
if cols:
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
query = "SELECT id FROM session"
params = []
where_clauses = []
if cwd_col and cwd:
where_clauses.append(f"{cwd_col} = ?")
params.append(cwd)
if time_col and epoch_threshold:
if time_col == "time_created":
where_clauses.append(f"{time_col} >= ?")
params.append(int(float(epoch_threshold) * 1000))
else:
where_clauses.append(f"{time_col} >= ?")
params.append(epoch_threshold)
if where_clauses:
query += " WHERE " + " AND ".join(where_clauses)
if time_col:
query += f" ORDER BY {time_col} DESC"
query += " LIMIT 20"
rows = cursor.execute(query, params).fetchall()
conn.close()
for (uuid,) in rows:
if uuid in (sibling_claimed or []):
continue
if verify_session_uuid(cwd, 'opencode', uuid, s_eval, mode="discover"):
valid_candidates.append(uuid)
else:
conn.close()
except Exception:
pass
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "opencode" "{cwd}"']
rc = subprocess.run(cmd).returncode
if rc == 0:
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=False)
elif rc == 1:
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
else:
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=True)
result = { result = {
'timestamp': now_iso, 'timestamp': now_iso,
'yaml_path': yaml_path, 'yaml_path': yaml_path,
@@ -8,7 +8,7 @@ platforms: [linux, macos]
environments: [terminal, herdr] environments: [terminal, herdr]
metadata: metadata:
hermes: hermes:
tags: [agent, herdr, claude, antigravity, agy, hermes, grok, orchestrator, onboard, isolation] tags: [agent, herdr, claude, antigravity, agy, hermes, grok, opencode, orchestrator, onboard, isolation]
related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-monitor] related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-monitor]
prereq_skills: [multi-agent-mux-create] prereq_skills: [multi-agent-mux-create]
--- ---
@@ -32,12 +32,13 @@ Registers an orchestrator session UUID into the `orchestrator_uuids` list of `.m
## Auto-Detection Hierarchy (Rev.2) ## Auto-Detection Hierarchy (Rev.2)
When `--uuid` is omitted, `orc_onboard.sh` inspects the process ancestry tree of the nearest agent ancestor (`claude`, `agy`, `hermes`, `grok`) in the following order: When `--uuid` is omitted, `orc_onboard.sh` inspects the process ancestry tree of the nearest agent ancestor (`claude`, `agy`, `hermes`, `grok`, `opencode`) in the following order:
1. **CLI `argv`**: 1. **CLI `argv`**:
- `claude -r <uuid>` / `claude --session-id <uuid>` - `claude -r <uuid>` / `claude --session-id <uuid>`
- `agy --conversation <uuid>` - `agy --conversation <uuid>`
- `grok --session-id <uuid>` / `grok --resume <uuid>` - `grok --session-id <uuid>` / `grok --resume <uuid>`
- `opencode --session <uuid>`
2. **Family-Matched Environment Variables**: 2. **Family-Matched Environment Variables**:
- `claude``CLAUDE_CODE_SESSION_ID` - `claude``CLAUDE_CODE_SESSION_ID`
- `agy``ANTIGRAVITY_CONVERSATION_ID` - `agy``ANTIGRAVITY_CONVERSATION_ID`
@@ -103,7 +103,7 @@ detect_nearest_agent() {
local base local base
base="$(basename "$tok")" base="$(basename "$tok")"
case "$base" in case "$base" in
claude|agy|hermes|grok) claude|agy|hermes|grok|opencode)
match="$base" match="$base"
break break
;; ;;
@@ -127,6 +127,9 @@ detect_nearest_agent() {
grok) grok)
detected_id=$(echo "$cmd_line" | grep -oE '(--session-id|--resume)[[:space:]=]+[^[:space:]]+' | head -n 1 | sed -E 's/^(--session-id|--resume)[[:space:]=]+//' || true) detected_id=$(echo "$cmd_line" | grep -oE '(--session-id|--resume)[[:space:]=]+[^[:space:]]+' | head -n 1 | sed -E 's/^(--session-id|--resume)[[:space:]=]+//' || true)
;; ;;
opencode)
detected_id=$(echo "$cmd_line" | grep -oE '(--session|-s)[[:space:]=]+[^[:space:]]+' | head -n 1 | sed -E 's/^(--session|-s)[[:space:]=]+//' || true)
;;
esac esac
if [ -n "$detected_id" ] && is_valid_id "$detected_id"; then if [ -n "$detected_id" ] && is_valid_id "$detected_id"; then
@@ -141,6 +144,7 @@ detect_nearest_agent() {
agy) env_var="${ANTIGRAVITY_CONVERSATION_ID:-}" ;; agy) env_var="${ANTIGRAVITY_CONVERSATION_ID:-}" ;;
hermes) env_var="${HERMES_SESSION_ID:-}" ;; hermes) env_var="${HERMES_SESSION_ID:-}" ;;
grok) env_var="${GROK_SESSION_ID:-}" ;; grok) env_var="${GROK_SESSION_ID:-}" ;;
opencode) env_var="${OPENCODE_SESSION_ID:-}" ;;
esac esac
if [ -n "$env_var" ] && is_valid_id "$env_var"; then if [ -n "$env_var" ] && is_valid_id "$env_var"; then
@@ -49,7 +49,7 @@ ideal resume path:
`agent-sessions.yaml` and on-disk discovery are used to resolve the UUID in this order: `agent-sessions.yaml` and on-disk discovery are used to resolve the UUID in this order:
1. **`herdr_sessions[]` row's per-row own id** (`claude_session_id_own` / `agy_conversation_id_own` / `hermes_conversation_id_own` / `grok_session_id_own`) — explicitly saved by `multi-agent-mux-stop` right before teardown (tier-1, race-free). 1. **`herdr_sessions[]` row's per-row own id** (`claude_session_id_own` / `agy_conversation_id_own` / `hermes_conversation_id_own` / `grok_session_id_own` / `opencode_session_id_own`) — explicitly saved by `multi-agent-mux-stop` right before teardown (tier-1, race-free).
2. **Workspace-scoped on-disk scan** (adapter `discover()`) 2. **Workspace-scoped on-disk scan** (adapter `discover()`)
If both are empty → the workspace has no conversation yet. Fall back to `multi-agent-mux-create`. If both are empty → the workspace has no conversation yet. Fall back to `multi-agent-mux-create`.
@@ -58,7 +58,7 @@ If both are empty → the workspace has no conversation yet. Fall back to `multi
```bash ```bash
WORKSPACE=/path/to/project WORKSPACE=/path/to/project
AGENT=claude # claude | agy | hermes | grok — pass it explicitly AGENT=claude # claude | agy | hermes | grok | opencode — pass it explicitly
SESSION_NAME=<workspace>-creator-<agent> # same convention as multi-agent-mux-create SESSION_NAME=<workspace>-creator-<agent> # same convention as multi-agent-mux-create
# Resolve the isolated herdr server name & load common utils # Resolve the isolated herdr server name & load common utils
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# resolve_session_id.sh — multi-agent-mux-resume 의 부속 스크립트 # resolve_session_id.sh — multi-agent-mux-resume 의 부속 스크립트
# Usage: # Usage:
# bash resolve_session_id.sh --workspace <path> --agent <claude|agy|hermes|grok> # bash resolve_session_id.sh --workspace <path> --agent <claude|agy|hermes|grok|opencode>
# 출력: stdout 으로 UUID 한 줄 (없으면 빈 줄 + exit 0) # 출력: stdout 으로 UUID 한 줄 (없으면 빈 줄 + exit 0)
# #
# P0-C: 전역 agent_identities 를 즉시 반환하지 않는다. lib.sh::find_workspace_uuid # P0-C: 전역 agent_identities 를 즉시 반환하지 않는다. lib.sh::find_workspace_uuid
@@ -13,7 +13,7 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
usage() { usage() {
cat <<EOF cat <<EOF
Usage: $0 --workspace <path> --agent <claude|agy|hermes|grok> [--session <name>] Usage: $0 --workspace <path> --agent <claude|agy|hermes|grok|opencode> [--session <name>]
Outputs the resolved UUID on stdout (empty if not found). Outputs the resolved UUID on stdout (empty if not found).
--session prefers that registry row's recorded id; falls back to workspace-wide discovery if it does not verify. --session prefers that registry row's recorded id; falls back to workspace-wide discovery if it does not verify.
EOF EOF
@@ -36,8 +36,8 @@ done
[ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; exit 2; } [ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; exit 2; }
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; exit 2; } [ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; exit 2; }
case "$AGENT" in case "$AGENT" in
claude|agy|hermes|grok) ;; claude|agy|hermes|grok|opencode) ;;
*) echo "ERROR: --agent must be claude, agy, hermes, or grok" >&2; exit 2 ;; *) echo "ERROR: --agent must be claude, agy, hermes, grok, or opencode" >&2; exit 2 ;;
esac esac
find_workspace_uuid "$WORKSPACE" "$AGENT" "$SESSION_NAME" find_workspace_uuid "$WORKSPACE" "$AGENT" "$SESSION_NAME"
@@ -9,7 +9,7 @@ source "$LIB_SH"
usage() { usage() {
cat <<EOF cat <<EOF
Usage: $0 --workspace <path> --agent <claude|agy|hermes|grok> --session <name> [options] Usage: $0 --workspace <path> --agent <claude|agy|hermes|grok|opencode> --session <name> [options]
Options: Options:
--herdr-session NAME specify isolated herdr session name (alias: --herdr-server) --herdr-session NAME specify isolated herdr session name (alias: --herdr-server)
@@ -42,7 +42,7 @@ done
[ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; exit 2; } [ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; exit 2; }
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; exit 2; } [ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; exit 2; }
case "$AGENT" in case "$AGENT" in
claude|agy|hermes|grok) ;; claude|agy|hermes|grok|opencode) ;;
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;; *) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
esac esac
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; } [ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; }
@@ -103,6 +103,7 @@ if [ -z "$CMD_FULL" ]; then
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;; agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID --no-restore-cwd --yolo --accept-hooks" ;; hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID --no-restore-cwd --yolo --accept-hooks" ;;
grok) CMD_FULL="${RESOLVED_BIN} --resume $UUID --permission-mode bypassPermissions" ;; grok) CMD_FULL="${RESOLVED_BIN} --resume $UUID --permission-mode bypassPermissions" ;;
opencode) CMD_FULL="${RESOLVED_BIN} --session $UUID --auto --agent build" ;;
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;; *) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
esac esac
fi fi
@@ -125,6 +126,12 @@ if [ "${DRY_RUN:-0}" = "1" ]; then
exit 0 exit 0
fi fi
if [ "$AGENT" = "opencode" ]; then
if [ -z "${OPENCODE_PERMISSION:-}" ]; then
export OPENCODE_PERMISSION='{"*":"allow"}'
fi
fi
# 4. Spawn new agent session (delegates to herdr translation shim) # 4. Spawn new agent session (delegates to herdr translation shim)
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CMD_FULL" _herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CMD_FULL"
if [ "$AGENT" = "claude" ]; then if [ "$AGENT" = "claude" ]; then
@@ -4,14 +4,14 @@
# resume UUID 를 per-row own id (claude_session_id_own / agy_conversation_id_own) # resume UUID 를 per-row own id (claude_session_id_own / agy_conversation_id_own)
# 에 박는다 — agent_identities 전역은 더 이상 primary 아님 (cache 로 강등, P0-C/단계 e). # 에 박는다 — agent_identities 전역은 더 이상 primary 아님 (cache 로 강등, P0-C/단계 e).
# #
# Usage: bash update_yaml_resumed.sh --session <name> --uuid <id> [--agent claude|agy|hermes|grok] # Usage: bash update_yaml_resumed.sh --session <name> --uuid <id> [--agent claude|agy|hermes|grok|opencode]
set -euo pipefail set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
usage() { usage() {
cat <<EOF cat <<EOF
Usage: $0 --session <name> --uuid <id> [--agent claude|agy|hermes|grok] [--herdr-session <name>] Usage: $0 --session <name> --uuid <id> [--agent claude|agy|hermes|grok|opencode] [--herdr-session <name>]
EOF EOF
} }
@@ -190,6 +190,20 @@ elif agent == 'hermes':
cp = os.environ.get('CHILD_PID', '0') cp = os.environ.get('CHILD_PID', '0')
if cp.isdigit() and int(cp) > 0: if cp.isdigit() and int(cp) > 0:
target['child_pid'] = int(cp) target['child_pid'] = int(cp)
elif agent == 'grok':
target['pane']['cmd'] = 'grok'
target['pane']['cmd_full'] = f'grok --resume {uuid}'
target['grok_session_id_own'] = uuid
cp = os.environ.get('CHILD_PID', '0')
if cp.isdigit() and int(cp) > 0:
target['child_pid'] = int(cp)
elif agent == 'opencode':
target['pane']['cmd'] = 'opencode'
target['pane']['cmd_full'] = f'opencode --session {uuid} --auto --agent build'
target['opencode_session_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 = d.setdefault('snapshot', {})
snap['taken_at'] = now snap['taken_at'] = now
@@ -53,12 +53,12 @@ def resume_on_disk(s):
name = s.get('name', '') name = s.get('name', '')
cwd = (s.get('pane') or {}).get('cwd', '') cwd = (s.get('pane') or {}).get('cwd', '')
agent = None agent = None
for a in ('claude', 'agy', 'hermes', 'grok'): for a in ('claude', 'agy', 'hermes', 'grok', 'opencode'):
if any(name.endswith(f'-{r}-{a}') for r in ('creator', 'planner', 'reviewer')) or name.endswith(f'-{a}'): if any(name.endswith(f'-{r}-{a}') for r in ('creator', 'planner', 'reviewer')) or name.endswith(f'-{a}'):
agent = a agent = a
break break
if not agent: if not agent:
for a in ('claude', 'agy', 'hermes', 'grok'): for a in ('claude', 'agy', 'hermes', 'grok', 'opencode'):
if f"-{a}" in name or f"_{a}" in name: if f"-{a}" in name or f"_{a}" in name:
agent = a agent = a
break break
@@ -97,6 +97,34 @@ def resume_on_disk(s):
ws_slug = urllib.parse.quote(cwd, safe='') ws_slug = urllib.parse.quote(cwd, safe='')
return 'yes' if os.path.exists(f"{home}/.grok/sessions/{ws_slug}/{u}/chat_history.jsonl") else 'MISSING' return 'yes' if os.path.exists(f"{home}/.grok/sessions/{ws_slug}/{u}/chat_history.jsonl") else 'MISSING'
return 'no' return 'no'
if agent == 'opencode':
u = s.get('opencode_session_id_own')
if u:
base_dir = f"{home}/.local/share/opencode"
odb = None
for cand in [f"{base_dir}/opencode.db", f"{base_dir}/storage.db"]:
if os.path.exists(cand):
odb = cand
break
if not odb and os.path.isdir(f"{base_dir}/project"):
for root, _, files in os.walk(f"{base_dir}/project"):
for f in files:
if f.endswith(".db"):
odb = os.path.join(root, f)
break
if odb:
break
if not odb or not os.path.exists(odb):
return 'MISSING'
try:
import sqlite3
conn = sqlite3.connect(odb)
r = conn.execute("SELECT 1 FROM session WHERE id=?", (u,)).fetchone()
conn.close()
return 'yes' if r else 'MISSING'
except Exception:
return 'MISSING'
return 'no'
return '?' return '?'
@@ -202,18 +230,79 @@ def resume_on_disk(s):
# workspace-SCOPED check only — per-row own id, never a global identity (P0-C) # workspace-SCOPED check only — per-row own id, never a global identity (P0-C)
name = s.get('name', '') name = s.get('name', '')
cwd = (s.get('pane') or {}).get('cwd', '') cwd = (s.get('pane') or {}).get('cwd', '')
if name.endswith('-creator-claude'): agent = None
for a in ('claude', 'agy', 'hermes', 'grok', 'opencode'):
if any(name.endswith(f'-{r}-{a}') for r in ('creator', 'planner', 'reviewer')) or name.endswith(f'-{a}'):
agent = a
break
if not agent:
for a in ('claude', 'agy', 'hermes', 'grok', 'opencode'):
if f"-{a}" in name or f"_{a}" in name:
agent = a
break
if agent == 'claude':
u = s.get('claude_session_id_own') u = s.get('claude_session_id_own')
if u: if u:
key = cwd.replace('/', '-').replace('_', '-') key = cwd.replace('/', '-').replace('_', '-')
return 'yes' if os.path.exists(f"{claude_project_dir}/{key}/{u}.jsonl") else 'MISSING' return 'yes' if os.path.exists(f"{claude_project_dir}/{key}/{u}.jsonl") else 'MISSING'
key = cwd.replace('/', '-').replace('_', '-') key = cwd.replace('/', '-').replace('_', '-')
return 'scan' if glob.glob(f"{claude_project_dir}/{key}/*.jsonl") else 'no' return 'scan' if glob.glob(f"{claude_project_dir}/{key}/*.jsonl") else 'no'
if name.endswith('-creator-agy'): if agent == 'agy':
u = s.get('agy_conversation_id_own') u = s.get('agy_conversation_id_own')
if u: if u:
return 'yes' if os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{u}.db") else 'MISSING' return 'yes' if os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{u}.db") else 'MISSING'
return 'no' return 'no'
if agent == 'hermes':
u = s.get('hermes_conversation_id_own')
if u:
hdb = f"{home}/.hermes/state.db"
if not os.path.exists(hdb):
return 'MISSING'
try:
import sqlite3
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (u,)).fetchone()
conn.close()
return 'yes' if r else 'MISSING'
except Exception:
return 'MISSING'
return 'no'
if agent == 'grok':
u = s.get('grok_session_id_own')
if u:
import urllib.parse
ws_slug = urllib.parse.quote(cwd, safe='')
return 'yes' if os.path.exists(f"{home}/.grok/sessions/{ws_slug}/{u}/chat_history.jsonl") else 'MISSING'
return 'no'
if agent == 'opencode':
u = s.get('opencode_session_id_own')
if u:
base_dir = f"{home}/.local/share/opencode"
odb = None
for cand in [f"{base_dir}/opencode.db", f"{base_dir}/storage.db"]:
if os.path.exists(cand):
odb = cand
break
if not odb and os.path.isdir(f"{base_dir}/project"):
for root, _, files in os.walk(f"{base_dir}/project"):
for f in files:
if f.endswith(".db"):
odb = os.path.join(root, f)
break
if odb:
break
if not odb or not os.path.exists(odb):
return 'MISSING'
try:
import sqlite3
conn = sqlite3.connect(odb)
r = conn.execute("SELECT 1 FROM session WHERE id=?", (u,)).fetchone()
conn.close()
return 'yes' if r else 'MISSING'
except Exception:
return 'MISSING'
return 'no'
return '?' return '?'
+1 -1
View File
@@ -37,7 +37,7 @@ The stop command is always **graceful by default**:
```bash ```bash
SESSION_NAME=<workspace>-creator-<agent> # convention SESSION_NAME=<workspace>-creator-<agent> # convention
AGENT=claude # claude | agy | hermes | grok — always pass it AGENT=claude # claude | agy | hermes | grok | opencode — always pass it
AGENT_SESSIONS_YAML=.mam/agent-sessions.yaml AGENT_SESSIONS_YAML=.mam/agent-sessions.yaml
# 1) Session is registered? # 1) Session is registered?
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# stop_session.sh — multi-agent-mux-stop 의 부속 스크립트 # stop_session.sh — multi-agent-mux-stop 의 부속 스크립트
# Usage: # Usage:
# bash stop_session.sh --session <name> [--agent claude|agy|hermes|grok] [--herdr-session <name>] \ # bash stop_session.sh --session <name> [--agent claude|agy|hermes|grok|opencode] [--herdr-session <name>] \
# [--reason <reason>] [--purge-conversation] [--yes] # [--reason <reason>] [--purge-conversation] [--yes]
# #
# 동작: 항상 graceful stop 입니다. send-keys 로 정상 종료를 유도하고 # 동작: 항상 graceful stop 입니다. send-keys 로 정상 종료를 유도하고
@@ -12,7 +12,7 @@
# #
# 옵션: # 옵션:
# --session <name> — 대상 세션 (필수) # --session <name> — 대상 세션 (필수)
# --agent <type> — claude | agy | hermes | grok # --agent <type> — claude | agy | hermes | grok | opencode
# (권장: 항상 명시. 미지정 시 레지스트리 기록으로 # (권장: 항상 명시. 미지정 시 레지스트리 기록으로
# 해석 — agent 필드 → 세션명 접미사 → pane.cmd; # 해석 — agent 필드 → 세션명 접미사 → pane.cmd;
# 셋 다 실패하면 exit 2) # 셋 다 실패하면 exit 2)
@@ -91,8 +91,8 @@ while [ $# -gt 0 ]; do
done done
if [ -n "$AGENT" ]; then if [ -n "$AGENT" ]; then
case "$AGENT" in case "$AGENT" in
claude|agy|hermes|grok) ;; claude|agy|hermes|grok|opencode) ;;
*) echo "ERROR: invalid agent type '$AGENT'. Allowed types are: claude, agy, hermes, grok." >&2; exit 2 ;; *) echo "ERROR: invalid agent type '$AGENT'. Allowed types are: claude, agy, hermes, grok, opencode." >&2; exit 2 ;;
esac esac
fi fi
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; usage; exit 2; } [ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; usage; exit 2; }
@@ -287,6 +287,8 @@ if captured and not purge:
target['hermes_conversation_id_own'] = captured target['hermes_conversation_id_own'] = captured
elif agent == 'grok': elif agent == 'grok':
target['grok_session_id_own'] = captured target['grok_session_id_own'] = captured
elif agent == 'opencode':
target['opencode_session_id_own'] = captured
target['resumable'] = True target['resumable'] = True
if purge and purge_uuid: if purge and purge_uuid: