feat(a4,c3b): complete A-4 Phase 2 agent knowledge migration & Option B isolation removal

- Option B / C-3b: Deprecate isolation.root and remove its 4 legacy consumers in lib.sh, workspace_uuid.py, verify_session.py, and stop_session.sh.
- M2~M7 Migration: Implement artifact_path, verify_artifact, purge_artifacts, spawn_spec, resume_spec, auth_ok, discover, ready_tokens, exit_key, and delegate_agent_key across BaseAgentAdapter and all 4 adapters (Claude, Agy, Hermes, Cline).
- Migrate shell duplications in lib.sh, create_session.sh, resume_session.sh, stop_session.sh, and reconcile.sh to python -m lib_py.agents facts.
- Hardening & Corrections: Fix Cline resume_spec flag to '-i --id', apply shlex.quote across all facts variables with MAM_ prefix, add safe fallback in wait_for_tui_ready.
- Add comprehensive contract tests in tests/test_a4_adapter_contract.py and sync IMPROVEMENTS.md / LOG.md.
- Verified: 100% PASS across unit, component, contract, and integration tests.
This commit is contained in:
2026-08-16 23:15:24 +09:00
parent 971f14ad3f
commit b4821fafa8
21 changed files with 904 additions and 531 deletions
+21 -55
View File
@@ -60,7 +60,7 @@ done
# Central TUI dialog and readiness validation tokens (OP-6)
_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary'
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|projects'
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome'
# Workspace-relative defaults with environment overrides (Phase Z)
HOME_DIR="${HOME_DIR:-$HOME}"
@@ -1097,25 +1097,6 @@ mam_workspace_key() {
printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--'
}
# ---------------------------------------------------------------------------
# mam_session_iso_root <session_name>
# ---------------------------------------------------------------------------
mam_session_iso_root() {
MAM_STATE_JSON="$(load_state_json)" MAM_ISO_SESSION="$1" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import json, os
name = os.environ.get('MAM_ISO_SESSION', '')
try:
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
except Exception:
d = {}
for s in (d.get('herdr_sessions') or []):
if s.get('name') == name:
iso = s.get('isolation')
if isinstance(iso, dict) and iso.get('root'):
print(iso['root'])
break
PYEOF
}
# ---------------------------------------------------------------------------
# derive_session_name <workspace> <agent> [role]
@@ -1354,18 +1335,14 @@ capture_conversation_id() {
}
# ---------------------------------------------------------------------------
# Session isolation — Universal Global Config (Option A, Job 536a6625)
# Session isolation — Universal Global Config (Option B, P3-1)
#
# Config-home isolation (.mam/agent_homes/<uuid>/) was removed in favor of:
# Config-home isolation (.mam/agent_homes/<uuid>/) and legacy isolation.root
# row consumers were completely deprecated and removed in favor of:
# 1. Universal Global Config: all agents read/write standard ~/.claude, ~/.gemini,
# ~/.hermes, ~/.cline user configuration and credential stores.
# 2. Process Isolation: each agent-workspace pair runs in its own herdr pane.
# 3. Conversation Isolation: session UUIDs discriminate conversation history.
# The backward-compat stubs (provision_isolation / isolation_lever /
# isolation_env_prefix / isolation_cmd_args) were removed in P2-2 (C-3a);
# they had zero production callers. The `isolation.root` row field is still
# consumed (C-3b) — see verify_session_uuid / find_workspace_uuid /
# mam_session_iso_root / stop_session.sh purge guard.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
@@ -1528,11 +1505,22 @@ start_watchdog() {
}
# wait_for_tui_ready <session_name> <agent>
# Waits up to 15 seconds for the agent's TUI to render its welcome screen.
# Waits up to 30 seconds for the agent's TUI to render its welcome screen.
wait_for_tui_ready() {
local sess="$1" agent="$2"
# Self-contained resolution: works whether or not caller evaluated facts (C1-(3)).
local tokens="${MAM_READY_TOKENS:-}"
if [ -z "$tokens" ]; then
local _facts=""
_facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _facts=""
tokens="$(printf '%s\n' "$_facts" | sed -n 's/^MAM_READY_TOKENS=//p')"
[ -n "$tokens" ] && eval "tokens=$tokens"
fi
if [ -z "$tokens" ]; then
echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2
return 1
fi
local i
for i in {1..30}; do
if _pane_dialog_open "$sess"; then
if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then
@@ -1545,32 +1533,10 @@ wait_for_tui_ready() {
local content
content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "")
if [ -n "$content" ]; then
case "$agent" in
claude)
if echo "$content" | grep -E -q "$_MAM_READY_TOKENS_CLAUDE" 2>/dev/null; then
echo "✅ Claude TUI detected ready."
return 0
fi
;;
agy)
if echo "$content" | grep -q "Antigravity" 2>/dev/null; then
echo "✅ Antigravity TUI detected ready."
return 0
fi
;;
hermes)
if echo "$content" | grep -q "Hermes" 2>/dev/null; then
echo "✅ Hermes TUI detected ready."
return 0
fi
;;
cline)
if echo "$content" | grep -E -q "Cline|history|Chat|What can I do|slash commands" 2>/dev/null; then
echo "✅ Cline TUI detected ready."
return 0
fi
;;
esac
if echo "$content" | grep -E -q "$tokens" 2>/dev/null; then
echo "$agent TUI detected ready."
return 0
fi
fi
sleep 1
done
+10 -6
View File
@@ -1,6 +1,6 @@
# __main__.py — CLI bridge for lib_py.agents facts and resolution (N4)
import sys, json
import sys, json, shlex
from lib_py.agents.registry import get_adapter, own_key, agent_of_row
def main():
@@ -16,11 +16,15 @@ def main():
print(f"ERROR: Unknown agent {agent_name!r}", file=sys.stderr)
sys.exit(1)
# Emit shell-eval friendly facts
print(f"AGENT_NAME={adapter.name}")
print(f"OWN_KEY={adapter.own_key}")
print(f"INPUT_PROMPT={adapter.input_prompt or ''}")
print(f"INPUT_PLACEHOLDER={adapter.input_placeholder or ''}")
print(f"INPUT_RULE_PATTERN={adapter.input_rule_pattern or ''}")
q = shlex.quote
print(f"MAM_AGENT_NAME={q(adapter.name)}")
print(f"MAM_OWN_KEY={q(adapter.own_key)}")
print(f"MAM_INPUT_PROMPT={q(adapter.input_prompt or '')}")
print(f"MAM_INPUT_PLACEHOLDER={q(adapter.input_placeholder or '')}")
print(f"MAM_INPUT_RULE_PATTERN={q(adapter.input_rule_pattern or '')}")
print(f"MAM_READY_TOKENS={q(adapter.ready_tokens)}")
print(f"MAM_EXIT_KEY={q(adapter.exit_key)}")
print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}")
elif cmd == 'resolve':
name = sys.argv[2] if len(sys.argv) > 2 else ''
+87 -3
View File
@@ -1,6 +1,6 @@
# agy.py — Antigravity (agy) agent adapter
from lib_py.agents.base import BaseAgentAdapter
import os, json, sqlite3, shutil
from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
class AgyAgentAdapter(BaseAgentAdapter):
@property
@@ -11,6 +11,22 @@ class AgyAgentAdapter(BaseAgentAdapter):
def own_key(self) -> str:
return 'agy_conversation_id_own'
@property
def ready_tokens(self) -> str:
return 'Antigravity'
@property
def exit_key(self) -> str:
return 'Exit'
@property
def delegate_agent_key(self) -> str:
return 'antigravity-cli'
@property
def identity_cache_fields(self) -> tuple:
return ('conversation_id', 'conversation_db', 'conversation_brain_dir')
@property
def input_prompt(self) -> str:
return '>'
@@ -22,3 +38,71 @@ class AgyAgentAdapter(BaseAgentAdapter):
@property
def input_rule_pattern(self) -> str:
return '{10,}'
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
return f"{ctx.home_dir}/.gemini/antigravity-cli/conversations/{uuid}.db"
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
path = self.artifact_path(uuid, ctx)
if not os.path.exists(path):
return False
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
return False
if ctx.mode == "discover":
lc = f"{ctx.home_dir}/.gemini/antigravity-cli/cache/last_conversations.json"
cache_match = False
if os.path.exists(lc):
try:
with open(lc) as f:
lc_data = json.load(f)
cache_match = (lc_data.get(ctx.cwd) == uuid)
except Exception:
cache_match = False
if not cache_match:
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
return False
try:
conn = sqlite3.connect(path)
r = conn.execute("SELECT count(*) FROM steps").fetchone()
conn.close()
if not r or r[0] < 1:
return False
except Exception:
return False
return True
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
purged = []
db = self.artifact_path(uuid, ctx)
if os.path.exists(db):
os.remove(db)
purged.append(db)
brain = f"{ctx.home_dir}/.gemini/antigravity-cli/brain/{uuid}"
if os.path.isdir(brain):
shutil.rmtree(brain, ignore_errors=True)
purged.append(brain)
return purged
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --dangerously-skip-permissions"
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
if materialized and session_uuid:
return f"{binary} --dangerously-skip-permissions --conversation {session_uuid}"
return f"{binary} --dangerously-skip-permissions"
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
home = os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
return os.path.exists(f"{home}/.gemini/oauth_creds.json") or os.path.exists(f"{home}/.gemini/antigravity-cli/antigravity-oauth-token")
def discover(self, ctx: DiscoveryContext) -> list:
lc = f"{ctx.home_dir}/.gemini/antigravity-cli/cache/last_conversations.json"
if os.path.exists(lc):
try:
with open(lc) as f:
cand = json.load(f).get(ctx.workspace)
if cand and self.verify_artifact(cand, ctx):
return [cand]
except Exception:
pass
return []
+100 -3
View File
@@ -1,6 +1,7 @@
# claude.py — Claude Code agent adapter
from lib_py.agents.base import BaseAgentAdapter
import os, json, glob, subprocess
from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
from lib_py.verify_session import workspace_key
class ClaudeAgentAdapter(BaseAgentAdapter):
@property
@@ -11,6 +12,22 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
def own_key(self) -> str:
return 'claude_session_id_own'
@property
def ready_tokens(self) -> str:
return 'Anthropic|Assistant|Chat|Welcome'
@property
def exit_key(self) -> str:
return '/exit'
@property
def delegate_agent_key(self) -> str:
return 'claude-code'
@property
def identity_cache_fields(self) -> tuple:
return ('session_id', 'session_jsonl', 'session_size_bytes', 'session_lines')
@property
def input_prompt(self) -> str:
return ''
@@ -22,3 +39,83 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
@property
def input_rule_pattern(self) -> str:
return '{10,}'
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl"
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
path = self.artifact_path(uuid, ctx)
if not os.path.exists(path):
return False
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
return False
try:
valid_session = False
found_cwd = None
with open(path) as f:
for _ in range(50):
line = f.readline()
if not line:
break
line = line.strip()
if not line:
continue
try:
payload = json.loads(line)
if payload.get("sessionId") == uuid:
valid_session = True
if payload.get("cwd"):
found_cwd = payload.get("cwd")
break
except Exception:
pass
if not valid_session:
return False
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
return False
except Exception:
return False
return True
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
jsonl = self.artifact_path(uuid, ctx)
if os.path.exists(jsonl):
os.remove(jsonl)
return [jsonl]
return []
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
if use_wrapper or not session_uuid:
return f"{binary} --dangerously-skip-permissions"
return f"{binary} --dangerously-skip-permissions --session-id {session_uuid}"
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
if materialized and session_uuid:
return f"{binary} --dangerously-skip-permissions -r {session_uuid}"
elif session_uuid:
return f"{binary} --dangerously-skip-permissions --session-id {session_uuid}"
return f"{binary} --dangerously-skip-permissions"
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
if run_cmd is not None:
try:
rc, stdout, stderr = run_cmd(['claude', 'auth', 'status'])
return rc == 0 and ('"loggedIn":true' in (stdout or '').replace(' ', ''))
except Exception:
return False
try:
res = subprocess.run(['claude', 'auth', 'status'], capture_output=True, text=True)
return res.returncode == 0 and ('"loggedIn":true' in (res.stdout or '').replace(' ', ''))
except Exception:
return False
def discover(self, ctx: DiscoveryContext) -> list:
key = workspace_key(ctx.workspace)
proj = f"{ctx.claude_dir}/{key}"
candidates = []
if os.path.isdir(proj):
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
cand = os.path.basename(j)[:-6]
if cand and self.verify_artifact(cand, ctx):
candidates.append(cand)
return candidates
+79 -3
View File
@@ -1,6 +1,7 @@
# cline.py — Cline agent adapter
from lib_py.agents.base import BaseAgentAdapter
import os, json, shutil, glob
from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
from lib_py.verify_session import workspace_key
class ClineAgentAdapter(BaseAgentAdapter):
@property
@@ -11,6 +12,22 @@ class ClineAgentAdapter(BaseAgentAdapter):
def own_key(self) -> str:
return 'cline_conversation_id_own'
@property
def ready_tokens(self) -> str:
return 'Cline|history|Chat|What can I do|slash commands'
@property
def exit_key(self) -> str:
return '/exit'
@property
def delegate_agent_key(self) -> str:
return 'cline-agent'
@property
def identity_cache_fields(self) -> tuple:
return ('session_id',)
@property
def input_prompt(self) -> str:
return ''
@@ -22,3 +39,62 @@ class ClineAgentAdapter(BaseAgentAdapter):
@property
def input_rule_pattern(self) -> str:
return '{10,}'
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
return f"{ctx.home_dir}/.cline/data/sessions/{uuid}/{uuid}.json"
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
path = self.artifact_path(uuid, ctx)
if not os.path.exists(path):
return False
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
return False
try:
with open(path) as f:
sdata = json.load(f)
if sdata.get("session_id") != uuid:
return False
found_cwd = sdata.get("cwd") or sdata.get("workspace_root")
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
return False
except Exception:
return False
return True
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
purged = []
sessions_dir = f"{ctx.home_dir}/.cline/data/sessions/{uuid}"
if os.path.isdir(sessions_dir):
shutil.rmtree(sessions_dir)
purged.append(sessions_dir)
return purged
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} -i"
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
if materialized and session_uuid:
return f"{binary} -i --id {session_uuid}"
return f"{binary} -i"
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
return True
def discover(self, ctx: DiscoveryContext) -> list:
sessions_dir = f"{ctx.home_dir}/.cline/data/sessions"
if os.path.isdir(sessions_dir):
files = []
for folder in glob.glob(f"{sessions_dir}/*"):
if os.path.isdir(folder):
fn = os.path.basename(folder)
jf = f"{folder}/{fn}.json"
if os.path.exists(jf):
files.append(jf)
files.sort(key=os.path.getmtime, reverse=True)
candidates = []
for j in files:
cand = os.path.basename(j)[:-5]
if cand and self.verify_artifact(cand, ctx):
candidates.append(cand)
return candidates
return []
@@ -1,6 +1,7 @@
# hermes.py — Hermes agent adapter
from lib_py.agents.base import BaseAgentAdapter
import os, sys, sqlite3
from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
from lib_py.verify_session import workspace_key
class HermesAgentAdapter(BaseAgentAdapter):
@property
@@ -10,3 +11,86 @@ class HermesAgentAdapter(BaseAgentAdapter):
@property
def own_key(self) -> str:
return 'hermes_conversation_id_own'
@property
def ready_tokens(self) -> str:
return 'Hermes'
@property
def exit_key(self) -> str:
return '/exit'
@property
def delegate_agent_key(self) -> str:
return 'hermes-agent'
@property
def identity_cache_fields(self) -> tuple:
return ('session_id',)
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
return f"{ctx.home_dir}/.hermes/sessions/session_{uuid}.json"
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
hdb = f"{ctx.home_dir}/.hermes/state.db"
if not os.path.exists(hdb):
return False
if ctx.epoch and os.path.getmtime(hdb) < ctx.epoch:
return False
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT cwd FROM sessions WHERE id=?", (uuid,)).fetchone()
conn.close()
if not r:
return False
found_cwd = r[0]
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
return False
except Exception:
return False
return True
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
purged = []
json_file = self.artifact_path(uuid, ctx)
if os.path.exists(json_file):
os.remove(json_file)
purged.append(json_file)
hdb = f"{ctx.home_dir}/.hermes/state.db"
if os.path.exists(hdb):
try:
conn = sqlite3.connect(hdb)
conn.execute("DELETE FROM sessions WHERE id=?", (uuid,))
conn.execute("DELETE FROM messages WHERE session_id=?", (uuid,))
conn.commit()
conn.close()
purged.append(f"db records for session: {uuid}")
except Exception as e:
sys.stderr.write(f"WARN: purge hermes db records failed: {e}\n")
return purged
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return binary
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
if materialized and session_uuid:
return f"{binary} --resume {session_uuid}"
return binary
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
return True
def discover(self, ctx: DiscoveryContext) -> list:
hdb = f"{ctx.home_dir}/.hermes/state.db"
if os.path.exists(hdb):
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ctx.workspace,)).fetchone()
conn.close()
if r:
cand = r[0]
if cand and self.verify_artifact(cand, ctx):
return [cand]
except Exception:
pass
return []
+52 -1
View File
@@ -13,10 +13,24 @@ class SpawnSpec:
self.env = env or {}
class DiscoveryContext:
def __init__(self, workspace: str, agent_name: str, home_dir: Optional[str] = None):
def __init__(self, workspace: str, agent_name: str = "", home_dir: Optional[str] = None,
claude_dir: Optional[str] = None, epoch: int = 0,
row: Optional[Dict[str, Any]] = None, mode: str = "discover"):
self.workspace = workspace
self.agent_name = agent_name
self.home_dir = resolve_home(home_dir)
self.claude_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{self.home_dir}/.claude/projects")
self.epoch = epoch
self.row = row or {}
self.mode = mode
@property
def ws_key(self) -> str:
return workspace_key(self.workspace)
@property
def cwd(self) -> str:
return (self.row.get("pane", {}).get("cwd", "") if isinstance(self.row, dict) else "") or self.workspace
class BaseAgentAdapter:
@property
@@ -27,6 +41,22 @@ class BaseAgentAdapter:
def own_key(self) -> str:
raise NotImplementedError
@property
def ready_tokens(self) -> str:
raise NotImplementedError
@property
def exit_key(self) -> str:
raise NotImplementedError
@property
def delegate_agent_key(self) -> str:
raise NotImplementedError
@property
def identity_cache_fields(self) -> tuple:
raise NotImplementedError
@property
def input_prompt(self) -> Optional[str]:
return None
@@ -51,3 +81,24 @@ class BaseAgentAdapter:
def verify_session(self, ws: str, uuid: str, row: Optional[Dict[str, Any]] = None, mode: str = "discover") -> bool:
return verify_session_uuid(ws, self.name, uuid, row=row, mode=mode)
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
raise NotImplementedError
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
raise NotImplementedError
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
raise NotImplementedError
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
raise NotImplementedError
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
raise NotImplementedError
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
raise NotImplementedError
def discover(self, ctx: DiscoveryContext) -> list:
raise NotImplementedError
-4
View File
@@ -27,10 +27,6 @@ def atomic_dump_yaml_main():
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}")
if not isinstance(s.get('pane'), dict):
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} missing pane")
iso = s.get('isolation')
if iso is not None:
if not isinstance(iso, dict) or not iso.get('uuid') or not iso.get('root'):
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} isolation block requires uuid/root")
orc_uuids = d.get('orchestrator_uuids')
if orc_uuids is not None:
if not isinstance(orc_uuids, list):
+10 -106
View File
@@ -83,12 +83,11 @@ from lib_py.paths import resolve_home
def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"):
import os, json, sqlite3
home = resolve_home(home_dir)
c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects")
row = row or {}
_iso = row.get("isolation")
iso_root = _iso.get("root") if isinstance(_iso, dict) and _iso.get("root") else None
from lib_py.paths import resolve_home
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
row = row or {}
epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0
cwd = row.get("pane", {}).get("cwd", "") or ws
@@ -103,104 +102,9 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non
and not row.get("session_id_verified")):
return True
if agent == "claude":
base = (iso_root + "/projects") if iso_root else c_dir
key = workspace_key(ws)
path = f"{base}/{key}/{uuid}.jsonl"
if not os.path.exists(path):
return False
if epoch and os.path.getmtime(path) < epoch:
return False
try:
valid_session = False
found_cwd = None
with open(path) as f:
for _ in range(50):
line = f.readline()
if not line:
break
line = line.strip()
if not line:
continue
try:
payload = json.loads(line)
if payload.get("sessionId") == uuid:
valid_session = True
if payload.get("cwd"):
found_cwd = payload.get("cwd")
break
except Exception:
pass
if not valid_session:
return False
if found_cwd and workspace_key(found_cwd) != workspace_key(cwd):
return False
except Exception:
return False
elif agent == "agy":
base = f"{iso_root or home}/.gemini/antigravity-cli/conversations"
path = f"{base}/{uuid}.db"
if not os.path.exists(path):
return False
if epoch and os.path.getmtime(path) < epoch:
return False
if mode == "discover":
lc = f"{iso_root or home}/.gemini/antigravity-cli/cache/last_conversations.json"
cache_match = False
if os.path.exists(lc):
try:
with open(lc) as f:
lc_data = json.load(f)
cache_match = (lc_data.get(cwd) == uuid)
except Exception:
cache_match = False
if not cache_match:
if uuid in (row.get("_sibling_claimed_uuids") or []):
return False
try:
conn = sqlite3.connect(path)
r = conn.execute("SELECT count(*) FROM steps").fetchone()
conn.close()
if not r or r[0] < 1:
return False
except Exception:
return False
elif agent == "hermes":
hdb = f"{iso_root or home}/.hermes/state.db"
if not os.path.exists(hdb):
return False
if epoch and os.path.getmtime(hdb) < epoch:
return False
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT cwd FROM sessions WHERE id=?", (uuid,)).fetchone()
conn.close()
if not r:
return False
found_cwd = r[0]
if found_cwd and workspace_key(found_cwd) != workspace_key(cwd):
return False
except Exception:
return False
elif agent == "cline":
base = (iso_root + "/sessions") if iso_root else f"{home}/.cline/data/sessions"
path = f"{base}/{uuid}/{uuid}.json"
if not os.path.exists(path):
return False
if epoch and os.path.getmtime(path) < epoch:
return False
try:
with open(path) as f:
sdata = json.load(f)
if sdata.get("session_id") != uuid:
return False
found_cwd = sdata.get("cwd") or sdata.get("workspace_root")
if found_cwd and workspace_key(found_cwd) != workspace_key(cwd):
return False
except Exception:
return False
return True
adapter = get_adapter(agent)
if not adapter:
return False
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home_dir, claude_dir=claude_dir, epoch=epoch, row=row, mode=mode)
return adapter.verify_artifact(uuid, ctx)
+13 -115
View File
@@ -11,10 +11,6 @@ OWN_KEY = {
'cline': 'cline_conversation_id_own'
}
def iso_root_of(s):
iso = s.get('isolation')
return iso.get('root') if isinstance(iso, dict) else None
from lib_py.paths import resolve_home
def find_workspace_uuid_main():
@@ -64,110 +60,25 @@ def find_workspace_uuid_main():
for s in sessions:
if s.get('name') != target:
continue
iso = iso_root_of(s)
cand = s.get(OWN_KEY.get(agent, ''), None)
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if iso:
key = workspace_key(ws)
if agent == 'claude':
for j in sorted(glob.glob(f"{iso}/projects/{key}/*.jsonl"), key=os.path.getmtime, reverse=True):
cand = os.path.basename(j)[:-6]
if cand and verify_session_uuid(ws, agent, cand, s):
emit(cand)
elif agent == 'agy':
for j in sorted(glob.glob(f"{iso}/.gemini/antigravity-cli/conversations/*.db"), key=os.path.getmtime, reverse=True):
cand = os.path.basename(j)[:-3]
if cand and verify_session_uuid(ws, agent, cand, s):
emit(cand)
elif agent == 'hermes':
hdb = f"{iso}/.hermes/state.db"
if os.path.exists(hdb):
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
conn.close()
if r:
cand = r[0]
if verify_session_uuid(ws, agent, cand, s):
emit(cand)
except Exception:
pass
elif agent == 'cline':
for folder in sorted(glob.glob(f"{iso}/sessions/*"), key=os.path.getmtime, reverse=True):
fn = os.path.basename(folder)
if os.path.exists(f"{folder}/{fn}.json"):
if verify_session_uuid(ws, agent, fn, s):
emit(fn)
print('')
sys.exit(0)
for s in sessions:
name = s.get('name', '')
if agent == 'claude' and name.endswith('-creator-claude'):
cand = s.get('claude_session_id_own')
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if agent == 'agy' and name.endswith('-creator-agy'):
cand = s.get('agy_conversation_id_own')
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if agent == 'hermes' and name.endswith('-creator-hermes'):
cand = s.get('hermes_conversation_id_own')
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if agent == 'cline' and name.endswith('-creator-cline'):
cand = s.get('cline_conversation_id_own')
if name.endswith(f"-creator-{agent}"):
cand = s.get(OWN_KEY.get(agent, ''))
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if agent == 'claude':
key = workspace_key(ws)
proj = f"{claude_project_dir}/{key}"
if os.path.isdir(proj):
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
cand = os.path.basename(j)[:-6]
if cand and verify_session_uuid(ws, agent, 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 verify_session_uuid(ws, agent, cand):
emit(cand)
elif agent == 'hermes':
hdb = f"{home}/.hermes/state.db"
if os.path.exists(hdb):
cand = None
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
conn.close()
if r:
cand = r[0]
except Exception:
cand = None
if cand and verify_session_uuid(ws, agent, cand):
emit(cand)
elif agent == 'cline':
sessions_dir = f"{home}/.cline/data/sessions"
if os.path.isdir(sessions_dir):
candidates = []
for session_folder in glob.glob(f"{sessions_dir}/*"):
if os.path.isdir(session_folder):
folder_name = os.path.basename(session_folder)
json_file = f"{session_folder}/{folder_name}.json"
if os.path.exists(json_file):
candidates.append(json_file)
candidates.sort(key=os.path.getmtime, reverse=True)
for j in candidates:
cand = os.path.basename(j)[:-5]
if cand and verify_session_uuid(ws, agent, cand):
emit(cand)
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
adapter = get_adapter(agent)
if adapter:
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home, claude_dir=claude_project_dir)
for cand in adapter.discover(ctx):
emit(cand)
ai = d.get('agent_identities') if isinstance(d, dict) else None
if not isinstance(ai, dict) or not ai:
@@ -197,22 +108,9 @@ def find_workspace_uuid_main():
ai_agent = ai.get(agent) or {}
if ai_agent.get('project_cwd') == ws:
if agent == 'claude':
cand = ai_agent.get('session_id')
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
elif agent == 'agy':
cand = ai_agent.get('conversation_id')
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
elif agent == 'hermes':
cand = ai_agent.get('session_id') or ai_agent.get('conversation_id')
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
elif agent == 'cline':
cand = ai_agent.get('session_id') or ai_agent.get('conversation_id')
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
cand = ai_agent.get('session_id') or ai_agent.get('conversation_id')
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
print('')
@@ -156,12 +156,18 @@ if [ "$AGENT" = "claude" ]; then
SESSION_UUID="$(mam_gen_uuid)"
fi
case "$AGENT" in
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ;;
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
hermes) CMD_FULL="${RESOLVED_BIN}" ;;
cline) CMD_FULL="${RESOLVED_BIN} -i" ;;
esac
# Retrieve agent facts once
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$AGENT" 2>/dev/null || true)"
CMD_FULL="$("$(_delegate_py_bin)" -c "from lib_py.agents.registry import get_adapter; a = get_adapter('$AGENT'); print(a.spawn_spec('$RESOLVED_BIN', '$SESSION_UUID', False) if a else '')" 2>/dev/null || true)"
if [ -z "$CMD_FULL" ]; then
case "$AGENT" in
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ;;
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
hermes) CMD_FULL="${RESOLVED_BIN}" ;;
cline) CMD_FULL="${RESOLVED_BIN} -i" ;;
esac
fi
spawn() {
if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then
@@ -238,16 +244,7 @@ fi
# agent-sessions.yaml 에 append
DELEGATE_JOB_ID=""
if [ -n "$SUBMIT_JOB_PROMPT" ]; then
delegate_agent=""
if [ "$AGENT" = "claude" ]; then
delegate_agent="claude-code"
elif [ "$AGENT" = "hermes" ]; then
delegate_agent="hermes-agent"
elif [ "$AGENT" = "cline" ]; then
delegate_agent="cline-agent"
else
delegate_agent="antigravity-cli"
fi
delegate_agent="${MAM_DELEGATE_AGENT_KEY:-antigravity-cli}"
agent_session="herdr:$SESSION_NAME"
DELEGATE_JOB_ID=$(delegate_submit_job "$SUBMIT_JOB_PROMPT" "$delegate_agent" "$agent_session")
echo "Submitted delegated job: $DELEGATE_JOB_ID"
@@ -433,14 +433,12 @@ def pane_meta(session, srv):
return None
from lib_py.agents.registry import get_adapter, own_key as _get_own_key, agent_of_row
def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False):
own_key = {
'claude': 'claude_session_id_own',
'agy': 'agy_conversation_id_own',
'hermes': 'hermes_conversation_id_own',
'cline': 'cline_conversation_id_own'
}[agent]
s[own_key] = uuid
own_key_field = _get_own_key(agent)
if own_key_field:
s[own_key_field] = uuid
s['last_visible_status'] = 'pinned'
resume_cmd = ['bash', os.path.join(skills_dir, 'multi-agent-mux-resume', 'scripts', 'resume_session.sh'),
'--workspace', cwd, '--agent', agent, '--session', s['name'], '--dry-run']
@@ -539,14 +537,8 @@ if herdr_confirmed:
ws_root_abs = os.path.realpath(workspace_root)
if not pane_cwd_abs or not (pane_cwd_abs == ws_root_abs or pane_cwd_abs.startswith(ws_root_abs + os.sep)):
continue
if agent == 'claude':
cmd_full = 'claude --dangerously-skip-permissions'
elif agent == 'agy':
cmd_full = 'agy --dangerously-skip-permissions'
elif agent == 'hermes':
cmd_full = 'hermes'
elif agent == 'cline':
cmd_full = 'cline -i'
_adapter = get_adapter(agent)
cmd_full = _adapter.spawn_spec(agent) if _adapter else agent
server_opt = f"-L {srv} " if srv != 'default' else ""
# The shim resolves this from the pane's root process. Fall back to now
# only if that failed: 'now' can merely over-estimate creation time,
@@ -596,24 +588,10 @@ if herdr_confirmed:
actions.append(f"registered: {name}")
def row_agent(s):
cmd = ((s.get('pane') or {}).get('cmd') or '').strip()
if cmd in ('claude', 'agy', 'hermes', 'cline'):
return cmd
full = ((s.get('pane') or {}).get('cmd_full') or '')
for a in ('claude', 'agy', 'hermes', 'cline'):
if a in full:
return a
name = s.get('name', '')
for a in ('claude', 'agy', 'hermes', 'cline'):
if name.endswith('-creator-' + a):
return a
return None
return agent_of_row(s)
OWN_KEY_BY_AGENT = {
'claude': 'claude_session_id_own',
'agy': 'agy_conversation_id_own',
'hermes': 'hermes_conversation_id_own',
'cline': 'cline_conversation_id_own'
a: _get_own_key(a) for a in ('claude', 'agy', 'hermes', 'cline')
}
# === drift C0: 지정된 ID 는 발견이 아니라 '확인'만 필요하다 ===
@@ -80,29 +80,18 @@ if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
fi
CLAUDE_ID_FLAG="-r"
if [ "$AGENT" = "claude" ]; then
_ws_key="$(mam_workspace_key "$WORKSPACE")"
_iso_root="$(mam_session_iso_root "$SESSION_NAME" 2>/dev/null || true)"
if [ -n "$_iso_root" ]; then
_proj_dir="$_iso_root/projects"
else
_proj_dir="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
fi
if [ ! -f "${_proj_dir}/${_ws_key}/${UUID}.jsonl" ]; then
CLAUDE_ID_FLAG="--session-id"
fi
# Determine CMD_FULL via adapter
CMD_FULL="$("$(_delegate_py_bin)" -c "from lib_py.agents.registry import get_adapter; from lib_py.agents.base import DiscoveryContext; a = get_adapter('$AGENT'); ctx = DiscoveryContext('$WORKSPACE', '$AGENT'); mat = a.verify_artifact('$UUID', ctx) if a else False; print(a.resume_spec('$RESOLVED_BIN', '$UUID', mat) if a else '')" 2>/dev/null || true)"
if [ -z "$CMD_FULL" ]; then
case "$AGENT" in
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions -r $UUID" ;;
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;;
cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;;
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
esac
fi
# Determine CMD_FULL
case "$AGENT" in
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions $CLAUDE_ID_FLAG $UUID" ;;
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;;
cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;;
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
esac
# Validate binary exists and is executable
if [ -f "$RESOLVED_BIN" ] || [[ "$RESOLVED_BIN" == /* ]] || [[ "$RESOLVED_BIN" == ~/* ]]; then
if [ ! -x "$RESOLVED_BIN" ]; then
@@ -173,13 +173,7 @@ delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating"
graceful_stop() {
local pane_pid exitkey
pane_pid=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
case "$AGENT" in
claude) exitkey="/exit" ;;
agy) exitkey="Exit" ;;
hermes) exitkey="/exit" ;;
cline) exitkey="/exit" ;;
*) exitkey="/exit" ;;
esac
exitkey="$("$(_delegate_py_bin)" -c "from lib_py.agents.registry import get_adapter; a = get_adapter('$AGENT'); print(a.exit_key if a else '/exit')" 2>/dev/null || echo "/exit")"
echo "graceful: send-keys '$exitkey' to $SESSION_NAME"
send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: safe delivery failed (rc=$?) — falling back to kill chain"
_wait_session_gone "$SESSION_NAME" 5 || true
@@ -272,83 +266,28 @@ if captured and not purge:
target['cline_conversation_id_own'] = captured
target['resumable'] = True
# --purge-conversation: 워크스페이스 격리된 UUID 의 디스크 artifact 만 삭제 (P0-C)
# T6: stop-purge 시 격리 디렉터리 청소 및 경로 가드
iso = target.get('isolation')
if purge and iso:
iso_root = iso.get('root')
iso_uuid = iso.get('uuid')
if iso_root and iso_uuid:
ws_abs = os.path.abspath(ws) if ws else ""
expected_homes_dir = os.path.join(ws_abs, '.mam', 'agent_homes')
expected_iso_root = os.path.join(expected_homes_dir, iso_uuid)
if (os.path.abspath(iso_root) == os.path.abspath(expected_iso_root) and
os.path.abspath(iso_root).startswith(os.path.abspath(expected_homes_dir) + os.sep)):
if os.path.isdir(iso_root):
shutil.rmtree(iso_root)
print(f"purged isolated home: {iso_root}", flush=True)
else:
print(f"WARN: isolated home path check failed: {iso_root}", flush=True)
if purge and purge_uuid:
if agent == 'claude':
key = ws.replace('/', '-').replace('_', '-')
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
jsonl = f"{claude_project_dir}/{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, ignore_errors=True)
print(f"purged: {brain}", flush=True)
target['agy_conversation_id_own'] = None
elif agent == 'hermes':
json_file = f"{home}/.hermes/sessions/session_{purge_uuid}.json"
if os.path.exists(json_file):
os.remove(json_file)
print(f"purged: {json_file}", flush=True)
hdb = f"{home}/.hermes/state.db"
if os.path.exists(hdb):
try:
import sqlite3
hconn = sqlite3.connect(hdb)
hconn.execute("DELETE FROM sessions WHERE id=?", (purge_uuid,))
hconn.execute("DELETE FROM messages WHERE session_id=?", (purge_uuid,))
hconn.commit()
hconn.close()
print(f"purged db records for session: {purge_uuid}", flush=True)
except Exception as e:
print(f"WARN: purge hermes db records failed: {e}", flush=True)
target['hermes_conversation_id_own'] = None
elif agent == 'cline':
sessions_dir = f"{home}/.cline/data/sessions/{purge_uuid}"
if os.path.isdir(sessions_dir):
shutil.rmtree(sessions_dir)
print(f"purged: {sessions_dir}", flush=True)
target['cline_conversation_id_own'] = None
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
adapter = get_adapter(agent)
if adapter:
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home)
for item in adapter.purge_artifacts(purge_uuid, ctx):
print(f"purged: {item}", flush=True)
target[adapter.own_key] = 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 agent == 'hermes' and ai.get('session_id') == purge_uuid:
ai['session_id'] = None
elif agent == 'cline' and ai.get('session_id') == purge_uuid:
ai['session_id'] = None
if adapter and (ai.get('session_id') == purge_uuid or ai.get('conversation_id') == purge_uuid):
for field in adapter.identity_cache_fields:
ai.pop(field, None)
if 'session_id' in adapter.identity_cache_fields or 'session_jsonl' in adapter.identity_cache_fields:
ai['session_id'] = None
ai['session_jsonl'] = None
if 'conversation_id' in adapter.identity_cache_fields:
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)