diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-8fc5b0bd.md b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-8fc5b0bd.md new file mode 100644 index 0000000..9ae9e28 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-8fc5b0bd.md @@ -0,0 +1,155 @@ +# πŸ“‹ Cross Review Report β€” Job 8fc5b0bd (P3-1 / A-4 Phase 2 Reviewer-feedback fix) + +- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline) +- **Job**: 8fc5b0bd (follow-up to Job 59467505 NOT PASS) +- **Scope**: Verify the implementation addressed the 4 blocking issues from the prior NOT PASS review. +- **Date**: 2026-08-16 + +--- + +## 1. Executive Summary + +The implementer addressed **all 4 blocking issues** raised in the prior NOT PASS review +(Job 59467505). The `cline resume_spec --resume` bug is fixed (`--id`), `auth_ok`/`discover` +are implemented in all 4 adapters, `resume_session.sh` and `reconcile.sh` are migrated to the +adapter layer, and contract tests for `spawn_spec`/`resume_spec`/`auth_ok`/`discover` values +were added and pass. 132 change-relevant tests pass with 0 failures; `py_compile` is clean; +`IMPROVEMENTS.md`/`LOG.md` are synchronized. One minor non-blocking observation remains +(create_session.sh auth not yet wired to `adapter.auth_ok`), which is out of the brief's +explicit scope. + +**Verdict: PASS.** + +--- + +## 2. Prior NOT PASS Issues β€” Resolution Status + +### 2.1 [FIXED] cline `resume_spec` used non-existent `--resume` flag +- **Prior**: `cline.py:76` emitted `--resume`, but `cline --help` only exposes `--id`. +- **Now**: `cline.py:75-78` emits `f"{binary} -i --id {session_uuid}"` (materialized) / + `f"{binary} -i"` (non-materialized). +- **Verification**: `cline --help` β†’ `--id Resume an existing session by ID` + (no `--resume`). Contract test `test_adapter_spawn_and_resume_specs` (line 181-182) + asserts `cline -i --id u1` (materialized) and `cline -i` (non-materialized). βœ… + +### 2.2 [FIXED] `auth_ok` / `discover` unimplemented (2 of 7 adapter methods) +- **Prior**: `auth_ok` and `discover` were absent from `base.py` and all adapters. +- **Now**: + - `base.py:100-104` declares both as abstract (`raise NotImplementedError`). + - `claude.py:99-110` β€” `auth_ok` dual-mode (`run_cmd` callable for test injection / + `subprocess` for prod; checks `claude auth status` β†’ `"loggedIn":true`). + - `agy.py:94-96` β€” `auth_ok` checks `~/.gemini/oauth_creds.json` or antigravity-oauth-token. + - `hermes.py:80-81` / `cline.py:80-81` β€” `auth_ok` returns `True` (no auth gate). + - `claude.py:112-121` β€” `discover` globs `{claude_dir}/{ws_key}/*.jsonl`, verifies each. + - `agy.py:98-108` β€” `discover` reads `last_conversations.json[ws]`, verifies artifact. + - `hermes.py:83-96` β€” `discover` queries `state.db` sessions by `cwd`. + - `cline.py:83-100` β€” `discover` scans `~/.cline/data/sessions/*`, verifies each. + - `workspace_uuid.py:74-81` β€” disk-scan fan-out replaced by `adapter.discover(ctx)`. +- **Contract tests**: `test_adapter_auth_ok` (line 184-201) and `test_adapter_discover` + (line 203-256) verify all 4 agents. Both pass. βœ… + +### 2.3 [FIXED] `resume_session.sh` / `reconcile.sh` not migrated to adapters +- **resume_session.sh** (line 83-93): `CMD_FULL` now computed via + `adapter.resume_spec('$RESOLVED_BIN', '$UUID', mat)` where `mat = adapter.verify_artifact(...)`. + The `materialized` flag (artifact exists on disk) selects `-r`/`--session-id` (claude) or + `--id`/bare (cline) β€” a behavioral improvement: do not attempt to resume a session whose + artifact is absent. Hardcoded fallback case retained as a safety net. `_iso_root` branch + fully removed. βœ… +- **reconcile.sh**: + - `row_agent(s)` (line 590-591) delegates to `agent_of_row(s)` from registry. + - `_pin_and_verify_resume` (line 438-441) uses `_get_own_key(agent)` from registry. + - `OWN_KEY_BY_AGENT` (line 593-595) built from `_get_own_key(a)` for all 4 agents. + - Auto-register `cmd_full` (line 540-541) uses `_adapter.spawn_spec(agent)`. + - The 4-way hardcoded spawn/own-key fan-outs are now adapter-driven. βœ… + +### 2.4 [FIXED] No contract tests for `spawn_spec` / `resume_spec` values +- **Now**: `test_adapter_spawn_and_resume_specs` (line 164-182) asserts exact output strings + for all 4 agents' `spawn_spec` and `resume_spec` (materialized + non-materialized): + - claude: `--dangerously-skip-permissions --session-id u1` (spawn) / `-r u1` (resume,mat) + - agy: `--dangerously-skip-permissions` (spawn) / `--conversation u1` (resume,mat) + - hermes: `hermes` (spawn) / `hermes --resume u1` (resume,mat) + - cline: `cline -i` (spawn) / `cline -i --id u1` (resume,mat) / `cline -i` (resume,!mat) +- Plus `test_adapter_auth_ok` and `test_adapter_discover`. Total: 9 contract tests, all pass. βœ… + +--- + +## 3. Test Execution (Independent) + +| Group | Files | Result | Time | +|---|---|---|---| +| Contract | test_a4_adapter_contract.py | **9 passed** | 0.13s | +| Unit | test_tier1_unit.py, test_orc_onboard.py | **66 passed** | 13.48s | +| UUID | test_uuid_target.py | **12 passed** | 78.35s | +| Tier2 | test_tier2_component.py, test_b4_session_created.py | **45 passed** | 56.05s | +| **Total** | | **132 passed, 0 failed** | β€” | + +- `py_compile` clean on all 9 changed `.py` files. +- Removed tests (`test_t11_legacy_isolation_row`, `test_comp_stop_safe_path_checking`) + correctly tested the now-deprecated `isolation.root` feature β€” removals are justified. +- Full-suite count per LOG.md: 259 passed (consistent with +3 new contract tests over prior 256). + +--- + +## 4. Documentation Sync + +- `IMPROVEMENTS.md`: A-4 marked βœ…μ™„λ£Œ (P3-1), C-3b βœ…μ™„λ£Œ; completed 17β†’19, pending 8β†’6. +- `LOG.md`: New P3-1 section documents every migrated file (base/adapters/__main__/verify_session/ + workspace_uuid/atomic_yaml/lib.sh/create/resume/reconcile/stop/tests), records the + `cline resume_spec -i --id` fix, and the 259-pass result. + +--- + +## 5. Non-Blocking Observations + +### 5.1 `create_session.sh` auth not yet wired to `adapter.auth_ok` +`create_session.sh:96-119` still contains a 4-way hardcoded auth fan-out (claude/agy/hermes/cline). +The `auth_ok` adapter method is now implemented and tested but is **not yet invoked** from this +script, leaving two sources of truth for auth logic. The brief explicitly scoped shell-script +migration to `resume_session.sh` and `reconcile.sh` only, so this is **out of scope for this round** +and not a blocker. Recommendation: wire `create_session.sh` auth to `adapter.auth_ok` in a future +increment to close the last auth fan-out. + +### 5.2 reconcile.sh entry-field metadata still agent-branched +`reconcile.sh:564-583` still branches on agent for entry metadata (claude `tui` block, agy +`mcp_attachments`, `child_pid`). These are agent-specific *metadata* with no corresponding adapter +method (no `entry_metadata` defined), so they are arguably not "agent command knowledge" and +remain acceptable. Not a blocker. + +### 5.3 Environmental e2e hang (pre-existing, not a regression) +Orphaned `reconcile.sh --subscribe --idle-timeout 0` processes accumulate from the +subprocess-spawning test suites (test_tier2/test_b4/test_uuid). These caused the prior review's +environmental hang and are a pre-existing infrastructure issue, **not** a regression introduced by +this change. All orphans were cleaned (0 remaining) before final test runs. + +--- + +## 6. Lint / Compile / Loss Checks + +- **Lint/compile**: `py_compile` clean on `base.py`, all 4 adapters, `__main__.py`, + `verify_session.py`, `workspace_uuid.py`, `atomic_yaml.py`. +- **No lost functionality**: removed `mam_session_iso_root` (lib.sh), `iso_root_of` + (workspace_uuid.py), isolation validity check (atomic_yaml.py) β€” all consumers of the + deprecated `isolation.root` row; removed tests aligned with removed features. +- **No orphaned imports**: adapters import `os/json/glob/sqlite3/subprocess/shutil` as needed. + +--- + +## 7. Behavioral-Change Assessment + +The `materialized` parameter in `resume_spec` is a deliberate, contract-tested behavioral +improvement: when the session artifact is absent (`verify_artifact` False), the adapter starts a +fresh session bound to the UUID (`--session-id` for claude, bare `-i` for cline) instead of +attempting to resume a non-existent history (`-r`/`--id`). This avoids resume failures on missing +artifacts. The fallback case in `resume_session.sh:86-92` preserves the materialized forms, so the +shell and adapter agree when artifacts exist. + +--- + +## 8. Verdict + +All 4 prior blocking issues are resolved with verified code + passing contract tests. 132 +change-relevant tests pass (0 failures). Documentation is synchronized. The one remaining item +(create_session.sh auth wiring) is explicitly out of the brief's scope and non-blocking. No +design-level rework is needed. + +[VERDICT: PASS] \ No newline at end of file diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 5eb9b4c..6e76aaa 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -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 -# --------------------------------------------------------------------------- -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 [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//) was removed in favor of: +# Config-home isolation (.mam/agent_homes//) 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 -# 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 diff --git a/.agents/skills/lib_py/agents/__main__.py b/.agents/skills/lib_py/agents/__main__.py index d4f425c..5de3211 100644 --- a/.agents/skills/lib_py/agents/__main__.py +++ b/.agents/skills/lib_py/agents/__main__.py @@ -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 '' diff --git a/.agents/skills/lib_py/agents/adapters/agy.py b/.agents/skills/lib_py/agents/adapters/agy.py index 361e882..37c19aa 100644 --- a/.agents/skills/lib_py/agents/adapters/agy.py +++ b/.agents/skills/lib_py/agents/adapters/agy.py @@ -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 [] diff --git a/.agents/skills/lib_py/agents/adapters/claude.py b/.agents/skills/lib_py/agents/adapters/claude.py index d13cac9..aecb4d3 100644 --- a/.agents/skills/lib_py/agents/adapters/claude.py +++ b/.agents/skills/lib_py/agents/adapters/claude.py @@ -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 diff --git a/.agents/skills/lib_py/agents/adapters/cline.py b/.agents/skills/lib_py/agents/adapters/cline.py index f021702..cb062e7 100644 --- a/.agents/skills/lib_py/agents/adapters/cline.py +++ b/.agents/skills/lib_py/agents/adapters/cline.py @@ -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 [] diff --git a/.agents/skills/lib_py/agents/adapters/hermes.py b/.agents/skills/lib_py/agents/adapters/hermes.py index eba7cd9..06946c0 100644 --- a/.agents/skills/lib_py/agents/adapters/hermes.py +++ b/.agents/skills/lib_py/agents/adapters/hermes.py @@ -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 [] diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py index d3cab56..67fe791 100644 --- a/.agents/skills/lib_py/agents/base.py +++ b/.agents/skills/lib_py/agents/base.py @@ -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 diff --git a/.agents/skills/lib_py/atomic_yaml.py b/.agents/skills/lib_py/atomic_yaml.py index dcdbe05..493d855 100644 --- a/.agents/skills/lib_py/atomic_yaml.py +++ b/.agents/skills/lib_py/atomic_yaml.py @@ -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): diff --git a/.agents/skills/lib_py/verify_session.py b/.agents/skills/lib_py/verify_session.py index 67433c7..de39966 100644 --- a/.agents/skills/lib_py/verify_session.py +++ b/.agents/skills/lib_py/verify_session.py @@ -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) diff --git a/.agents/skills/lib_py/workspace_uuid.py b/.agents/skills/lib_py/workspace_uuid.py index 6576969..2148b9d 100644 --- a/.agents/skills/lib_py/workspace_uuid.py +++ b/.agents/skills/lib_py/workspace_uuid.py @@ -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('') diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 7b10ba1..1c7d06a 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -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" diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 71bf748..ee167ef 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -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 λŠ” 발견이 μ•„λ‹ˆλΌ '확인'만 ν•„μš”ν•˜λ‹€ === diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index ab83e5c..4d1f1dd 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -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 diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index 921ae66..7ce4bc8 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -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) diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index d3fba63..c99ff98 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -1,9 +1,9 @@ # πŸ› οΈ Multi-Agent Mux μ’…ν•© κ°œμ„  및 λ―Έν•΄κ²° 과제 백둜그 (`IMPROVEMENTS.md`) -- **μ΅œμ’… 갱신일**: 2026-08-16 (P2-2/C-3a/C-4 격리 빈 μŠ€ν… 4μ’… 및 λ―Έμ‚¬μš© 심볼 3μ’… 제거, --isolate no-op νšŒκ·€ κ°€λ“œ μ‹ μ„€, 전체 256/256 νšŒκ·€ 톡과 반영) +- **μ΅œμ’… 갱신일**: 2026-08-16 (P3-1/A-4 Phase 2 M2~M7 μ—μ΄μ „νŠΈ 지식 계측 및 Option B / C-3b μ™„λ£Œ, Reviewer ν”Όλ“œλ°± 반영, 전체 259/259 νšŒκ·€ 톡과 반영) - **톡합 관리 λŒ€μƒ**: κΈ°μ‘΄ `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md` -- **총 좔적 λ―Έν•΄κ²° 과제**: **8건** (μ•„ν‚€ν…μ²˜ 2건, μ—£μ§€μΌ€μ΄μŠ€ 4건, μ˜€μΌ€μŠ€νŠΈλ ˆμ΄μ…˜ 0건, λ ˆκ±°μ‹œ μž”μž¬ 2건) -- **μ™„λ£Œλœ 과제**: **17건** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, B-8, C-1, C-2, O-1, O-2, O-3, O-4-OrcOnboard, Herdr-0.8.0-Compat-SanitizeHash, P2-1-DelegateJobSafe-TrapFix, P2-2-C3a-C4-LegacyCleanup) +- **총 좔적 λ―Έν•΄κ²° 과제**: **6건** (μ•„ν‚€ν…μ²˜ 1건, μ—£μ§€μΌ€μ΄μŠ€ 4건, μ˜€μΌ€μŠ€νŠΈλ ˆμ΄μ…˜ 0건, λ ˆκ±°μ‹œ μž”μž¬ 1건) +- **μ™„λ£Œλœ 과제**: **19건** (A-1, A-3, A-4, A-5, B-1, B-3, B-4, B-7, B-8, C-1, C-2, C-3b, O-1, O-2, O-3, O-4-OrcOnboard, Herdr-0.8.0-Compat-SanitizeHash, P2-1-DelegateJobSafe-TrapFix, P2-2-C3a-C4-LegacyCleanup) --- @@ -13,13 +13,13 @@ --- -## 1. πŸ”΄ μ•„ν‚€ν…μ²˜ 결함 (Architecture Flaws β€” 2건) +## 1. πŸ”΄ μ•„ν‚€ν…μ²˜ 결함 (Architecture Flaws β€” 1건) ### **A-2: 곡개 브둜컀 + HMAC 인증 Off + μ™€μΌλ“œμΉ΄λ“œ μ „νŒŒ** - **ν˜„μƒ**: `mqtt_common.py`의 κΈ°λ³Έ λΈŒλ‘œμ»€κ°€ 곡개 μ„œλ²„(`broker.hivemq.com`)이고, 작 생성 μ‹œ `auth_token` 이 **ν•œ λ²ˆλ„ λ°œκΈ‰λ˜μ§€ μ•Šμ•„**(μ‹€μΈ‘ 26/26 작이 `auth_token=None`) `verify_hmac` 의 `if not auth_token: return True` κ²½λ‘œκ°€ 항상 νƒ€μ§‘λ‹ˆλ‹€. λ°œν–‰μžλŠ” μ›Œν¬μŠ€νŽ˜μ΄μŠ€ μ§€λ¬Έ 토픽을 μ±„νƒν•˜μ§€ μ•Šκ³  μ „μ—­ `python/mqtt/jobs//events` 둜 λ°œν–‰ν•˜λ©°, `reconcile.sh:237` 이 같은 μ „μ—­ 토픽을 κ΅¬λ…ν•©λ‹ˆλ‹€. (HMAC κ΅¬ν˜„ μžμ²΄λŠ” μ •μƒμž…λ‹ˆλ‹€ β€” 토큰이 μ—†μ–΄ 검증이 κ³΅ν—ˆν•΄μ§€λŠ” 것이 μ›μΈμž…λ‹ˆλ‹€.) - **νŒŒκΈ‰ 효과**: μ™ΈλΆ€μ—μ„œ μœ μž…λ˜λŠ” malicious `error` 이벀트 μˆ˜μ‹  μ‹œ `reconcile.sh`κ°€ 라이브 μ—μ΄μ „νŠΈ pane을 `kill-session`으둜 κ°•μ œ νŒŒκ΄΄ν•˜λŠ” 치λͺ…적 λ³΄μ•ˆ/μ•ˆμ •μ„± μœ„ν—˜μ΄ μ‘΄μž¬ν•©λ‹ˆλ‹€. -### **A-4 (섀계 μ œμ•ˆ): μ—μ΄μ „νŠΈ 지식 μ‚°μž¬ β€” `BaseAgentAdapter` μ–΄λŒ‘ν„° 계측 λ„μž… (Rev.2)** +### **A-4 (βœ… μ™„λ£Œ β€” P3-1): μ—μ΄μ „νŠΈ 지식 μ‚°μž¬ β€” `BaseAgentAdapter` μ–΄λŒ‘ν„° 계측 λ„μž… (Rev.2)** > 결함 μ‘°μΉ˜κ°€ μ•„λ‹ˆλΌ **ꡬ쑰 κ°œμ„  μ œμ•ˆ**μž…λ‹ˆλ‹€. 상세 섀계·싀츑 κ·Όκ±°λŠ” `.mam/jobs/44062a63/claude-reports/report-final.md` 및 `744ac67a` λ₯Ό μ°Έμ‘°ν•˜μ‹­μ‹œμ˜€. @@ -314,9 +314,9 @@ CHANGES_DIFF=$( ### 6.5 ⚠️ μ‹€ν–‰ μ „ λ°˜λ“œμ‹œ 확인할 μ •μ • 사항 -1. **C-3 은 κ·ΈλŒ€λ‘œ μ‹€ν–‰ν•˜λ©΄ νšŒκ·€λ₯Ό λ§Œλ“­λ‹ˆλ‹€.** ν•­λͺ©μ΄ 성격이 λ‹€λ₯Έ λ‘˜μ„ λ¬Άκ³  μžˆμŠ΅λ‹ˆλ‹€. +1. **C-3 (βœ… μ™„λ£Œ)**: - **C-3a (βœ… μ™„λ£Œ)**: `provision_isolation` / `isolation_lever` / `isolation_env_prefix` / `isolation_cmd_args` 4μ’… 빈 μŠ€ν… 및 이λ₯Ό κ³ μ •ν•˜λ˜ κ³΅ν—ˆν•œ ν…ŒμŠ€νŠΈ 4건을 μ œκ±°ν•˜κ³  `--isolate`/`--no-isolate` no-op νšŒκ·€ κ°€λ“œλ‘œ λŒ€μ²΄ν–ˆμŠ΅λ‹ˆλ‹€. - - **C-3b (보λ₯˜ β€” A-4 M2 κ²°μ • 사항)**: `isolation.root` μ†ŒλΉ„μž(`lib.sh` `verify_session_uuid` 의 `iso_root` λΆ„κΈ°, `mam_session_iso_root`, `find_workspace_uuid` 격리 λΆ„κΈ°, `stop_session.sh:277` purge κ°€λ“œ). **b4a1d094 / 44062a63 μ—μ„œ 방금 μ˜λ„μ μœΌλ‘œ λ˜μ‚΄λ¦° μ½”λ“œ**이며, μ§€μš°λ©΄ κ·Έ μˆ˜μ •μ΄ λ˜λŒμ•„κ°‘λ‹ˆλ‹€. + - **C-3b (βœ… μ™„λ£Œ β€” P3-1 / Option B)**: `isolation.root` 4개 μ†ŒλΉ„μž(`lib.sh` `verify_session_uuid` 의 `iso_root` λΆ„κΈ°, `mam_session_iso_root`, `find_workspace_uuid` 격리 λΆ„κΈ°, `stop_session.sh:277` purge κ°€λ“œ 및 `atomic_yaml.py:30-33` μœ νš¨μ„± 검사)λ₯Ό μ™„μ „νžˆ νκΈ°ν•˜κ³  Universal Global Config 및 μ–΄λŒ‘ν„° 기반 단일 경둜둜 μ΄κ΄€ν–ˆμŠ΅λ‹ˆλ‹€. 2. **C-4 (βœ… μ™„λ£Œ)**: - `_HERDR_SHIM_DIR_PATTERN` β€” **μ‚¬μš© μ€‘μž…λ‹ˆλ‹€** (`lib.sh:83` μ •μ˜, `lib.sh:105` μ‚¬μš©). λͺ©λ‘λŒ€λ‘œ μ§€μš°λ©΄ shim 경둜 νŒμ •μ΄ κΉ¨μ§‘λ‹ˆλ‹€. - `local_herdr` β€” μ°Έμ‘° 0건, **이미 제거됨**. diff --git a/LOG.md b/LOG.md index 5b64bd3..981b829 100644 --- a/LOG.md +++ b/LOG.md @@ -6,9 +6,25 @@ --- -## πŸ“Œ 1. 금일 μž‘μ—… λ‚΄μš© μš”μ•½ +### 1) **P3-1 (A-4 Phase 2 / Option B / C-3b / M2~M7): μ—μ΄μ „νŠΈ 지식 계측 λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ 및 isolation.root μ™„μ „ 폐기** β€” **μ™„λ£Œ** +- **λ°°κ²½**: μ—μ΄μ „νŠΈλ³„ μ•„ν‹°νŒ©νŠΈ 경둜, 검증 둜직, 재개/μ‹œμž‘ μŠ€νŽ™, 토큰, μ’…λ£Œ ν‚€, 인증(`auth_ok`), μžλ™ 발견(`discover`)이 슀크립트 μ „λ°˜μ— ν•˜λ“œμ½”λ”© μ‚°μž¬λ˜μ–΄ 있던 문제(A-4)와, Universal Global Config μ „ν™˜ 후에도 4개 지점에 λ‚¨μ•„μžˆλ˜ `isolation.root` μ†ŒλΉ„μž(C-3b)λ₯Ό μ™„μ „ νκΈ°ν•˜κ³  μ–΄λŒ‘ν„° 단일 μ†ŒμŠ€λ‘œ 일원화. +- **μ£Όμš” κ΅¬ν˜„**: + - [`.agents/skills/lib_py/agents/base.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/agents/base.py): `DiscoveryContext` ν™•μž₯ (`ws_key`, `cwd`, `home_dir`, `claude_dir`, `epoch`, `row`, `mode`), `BaseAgentAdapter` 좔상 μΈν„°νŽ˜μ΄μŠ€ μ •μ˜ (`ready_tokens`, `exit_key`, `delegate_agent_key`, `identity_cache_fields`, `artifact_path`, `verify_artifact`, `purge_artifacts`, `spawn_spec`, `resume_spec`, `auth_ok`, `discover`). + - [`.agents/skills/lib_py/agents/adapters/`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/agents/adapters/): `claude.py`, `agy.py`, `hermes.py`, `cline.py` 4개 μ–΄λŒ‘ν„° ꡬ체 클래슀 κ΅¬ν˜„ (Cline `resume_spec` `-i --id` μˆ˜μ • 반영, `auth_ok`, `discover` κ΅¬ν˜„). + - [`.agents/skills/lib_py/agents/__main__.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/agents/__main__.py): 8개 `MAM_*` λ³€μˆ˜ `shlex.quote` μ…Έ λΈŒλ¦¬μ§€ μ™„μ„±. + - [`.agents/skills/lib_py/verify_session.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/verify_session.py): `adapter.verify_artifact(uuid, ctx)` μœ„μž„ 및 `iso_root` λΆ„κΈ° 제거. + - [`.agents/skills/lib_py/workspace_uuid.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/workspace_uuid.py): `iso_root_of` 제거 및 λ””μŠ€ν¬ μŠ€μΊ”μ„ `adapter.discover(ctx)`둜 단일화. + - [`.agents/skills/lib_py/atomic_yaml.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/atomic_yaml.py): λ ˆκ±°μ‹œ `isolation` μœ νš¨μ„± 검사 절 제거. + - [`.agents/skills/lib.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib.sh): `mam_session_iso_root` 제거, `wait_for_tui_ready` μ…€ν”„ μ»¨ν…ŒμΈλ“œ 토큰 폴백 및 claude `projects` 토큰 제거. + - [`.agents/skills/multi-agent-mux-create/scripts/create_session.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-create/scripts/create_session.sh): `CMD_FULL` (μ–΄λŒ‘ν„° `spawn_spec`) 및 `delegate_agent` (`delegate_agent_key`) 이관. + - [`.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh): `_iso_root` λΆ„κΈ° μ™„μ „ 제거 및 `CMD_FULL` 을 `adapter.resume_spec`으둜 이관. + - [`.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh): `row_agent` / `_pin_and_verify_resume` / `OWN_KEY_BY_AGENT` / auto-register `cmd_full` 을 μ–΄λŒ‘ν„° λ ˆμ΄μ–΄λ‘œ 이관. + - [`.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh): `exitkey` (`adapter.exit_key`), `adapter.purge_artifacts`, `adapter.identity_cache_fields` μœ„μž„ 및 λ ˆκ±°μ‹œ 격리 μ‚­μ œ 블둝 제거. + - [`tests/test_a4_adapter_contract.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/tests/test_a4_adapter_contract.py): facts eval 계약, μ–΄λŒ‘ν„° 속성 계약, 볡합 μ•„ν‹°νŒ©νŠΈ μ‚­μ œ(purge_artifacts), spawn/resume_spec 계약, auth_ok 계약, discover 계약 ν…ŒμŠ€νŠΈ 9μ’… μΆ”κ°€. + - [`IMPROVEMENTS.md`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/IMPROVEMENTS.md): A-4 및 C-3b μ™„λ£Œ 처리 및 19개 과제 μ™„κ²° μƒνƒœ 동기화. +- **검증**: `pytest` μ‹€ν–‰ κ²°κ³Ό **259 passed in 493s (100%)**. -### 1) **P2-2 (C-3a / C-4): λ ˆκ±°μ‹œ 격리 μŠ€ν… 및 λ―Έμ‚¬μš© 심볼 제거** β€” **μ™„λ£Œ** +### 2) **P2-2 (C-3a / C-4): λ ˆκ±°μ‹œ 격리 μŠ€ν… 및 λ―Έμ‚¬μš© 심볼 제거** β€” **μ™„λ£Œ** - **λ°°κ²½**: 격리 ꡬ쑰가 Universal Global Config(Job `536a6625`)둜 μ „ν™˜λ˜λ©° 남은 빈 μŠ€ν… 4μ’…κ³Ό, κ·Έ 빈 좜λ ₯만 μž¬ν™•μΈν•˜λ˜ κ³΅ν—ˆν•œ ν…ŒμŠ€νŠΈ 4건, 그리고 μ°Έμ‘° 0회 심볼 3μ’… 정리. - **μ£Όμš” κ΅¬ν˜„**: - [`.agents/skills/lib.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib.sh): 빈 μŠ€ν… 4μ’…(`provision_isolation`, `isolation_lever`, `isolation_env_prefix`, `isolation_cmd_args`) 및 `_REAL_HERDR_PATH` λŒ€μž…Β·export 제거, 주석에 C-3b 경계 λͺ…μ‹œ. diff --git a/tests/test_a4_adapter_contract.py b/tests/test_a4_adapter_contract.py index 5afa12f..a9d4140 100644 --- a/tests/test_a4_adapter_contract.py +++ b/tests/test_a4_adapter_contract.py @@ -45,3 +45,216 @@ def test_agent_of_row_priority(): # Priority 3: pane.cmd exact match row3 = {'pane': {'cmd': 'cline'}} assert agent_of_row(row3) == 'cline' + +def test_adapter_required_properties(): + from lib_py.agents.base import BaseAgentAdapter + base = BaseAgentAdapter() + for prop in ('name', 'own_key', 'ready_tokens', 'exit_key', 'delegate_agent_key', 'identity_cache_fields'): + with pytest.raises(NotImplementedError): + getattr(base, prop) + + expected = { + 'claude': ('Anthropic|Assistant|Chat|Welcome', '/exit', 'claude-code', ('session_id', 'session_jsonl', 'session_size_bytes', 'session_lines')), + 'agy': ('Antigravity', 'Exit', 'antigravity-cli', ('conversation_id', 'conversation_db', 'conversation_brain_dir')), + 'hermes': ('Hermes', '/exit', 'hermes-agent', ('session_id',)), + 'cline': ('Cline|history|Chat|What can I do|slash commands', '/exit', 'cline-agent', ('session_id',)), + } + for agent, (toks, exitk, delk, cache_f) in expected.items(): + adapter = get_adapter(agent) + assert adapter is not None + assert adapter.ready_tokens == toks + assert adapter.exit_key == exitk + assert adapter.delegate_agent_key == delk + assert adapter.identity_cache_fields == cache_f + +def test_facts_bridge_eval_contract(): + import subprocess, sys + from pathlib import Path + env = os.environ.copy() + skills_dir = str(Path(__file__).resolve().parent.parent / ".agents" / "skills") + env["PYTHONPATH"] = f"{skills_dir}:{env.get('PYTHONPATH', '')}" + for agent in ('claude', 'agy', 'hermes', 'cline'): + res = subprocess.run([sys.executable, "-m", "lib_py.agents", "facts", agent], capture_output=True, text=True, env=env) + assert res.returncode == 0 + facts_output = res.stdout + + # Verify eval in bash with set -euo pipefail + bash_cmd = f""" +set -euo pipefail +eval {shlex_quote(facts_output)} +echo "AGENT=$MAM_AGENT_NAME|OWN=$MAM_OWN_KEY|TOK=$MAM_READY_TOKENS|EXIT=$MAM_EXIT_KEY|DEL=$MAM_DELEGATE_AGENT_KEY|PH=$MAM_INPUT_PLACEHOLDER" +""" + res_bash = subprocess.run(["bash", "-c", bash_cmd], capture_output=True, text=True) + assert res_bash.returncode == 0, f"Bash eval failed for {agent}:\nStdout: {res_bash.stdout}\nStderr: {res_bash.stderr}" + if agent == 'cline': + assert "PH=Ask anything..." in res_bash.stdout + +def shlex_quote(s): + import shlex + return shlex.quote(s) + +def test_purge_artifacts_composite(tmp_path): + import sqlite3 + from lib_py.agents.base import DiscoveryContext + ws = str(tmp_path / "ws") + home = str(tmp_path / "home") + os.makedirs(ws, exist_ok=True) + os.makedirs(home, exist_ok=True) + + # 1. Claude + claude_adapter = get_adapter('claude') + claude_ctx = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home) + c_path = claude_adapter.artifact_path('uuid-c', claude_ctx) + os.makedirs(os.path.dirname(c_path), exist_ok=True) + with open(c_path, 'w') as f: + f.write('{"sessionId": "uuid-c"}') + assert os.path.exists(c_path) + purged_c = claude_adapter.purge_artifacts('uuid-c', claude_ctx) + assert len(purged_c) == 1 + assert not os.path.exists(c_path) + + # 2. Agy (both DB file and brain dir) + agy_adapter = get_adapter('agy') + agy_ctx = DiscoveryContext(workspace=ws, agent_name='agy', home_dir=home) + agy_db = agy_adapter.artifact_path('uuid-a', agy_ctx) + os.makedirs(os.path.dirname(agy_db), exist_ok=True) + with open(agy_db, 'w') as f: + f.write('mock db') + agy_brain = f"{home}/.gemini/antigravity-cli/brain/uuid-a" + os.makedirs(agy_brain, exist_ok=True) + with open(f"{agy_brain}/note.txt", 'w') as f: + f.write('brain note') + purged_a = agy_adapter.purge_artifacts('uuid-a', agy_ctx) + assert len(purged_a) == 2 + assert not os.path.exists(agy_db) + assert not os.path.exists(agy_brain) + + # 3. Hermes (JSON file and SQLite rows) + hermes_adapter = get_adapter('hermes') + hermes_ctx = DiscoveryContext(workspace=ws, agent_name='hermes', home_dir=home) + h_json = hermes_adapter.artifact_path('uuid-h', hermes_ctx) + os.makedirs(os.path.dirname(h_json), exist_ok=True) + with open(h_json, 'w') as f: + f.write('{}') + h_db = f"{home}/.hermes/state.db" + os.makedirs(os.path.dirname(h_db), exist_ok=True) + conn = sqlite3.connect(h_db) + conn.execute("CREATE TABLE IF NOT EXISTS sessions (id TEXT, cwd TEXT)") + conn.execute("CREATE TABLE IF NOT EXISTS messages (session_id TEXT, msg TEXT)") + conn.execute("INSERT INTO sessions VALUES (?, ?)", ('uuid-h', ws)) + conn.execute("INSERT INTO messages VALUES (?, ?)", ('uuid-h', 'hello')) + conn.commit() + conn.close() + + purged_h = hermes_adapter.purge_artifacts('uuid-h', hermes_ctx) + assert len(purged_h) == 2 + assert not os.path.exists(h_json) + conn = sqlite3.connect(h_db) + assert conn.execute("SELECT count(*) FROM sessions WHERE id='uuid-h'").fetchone()[0] == 0 + assert conn.execute("SELECT count(*) FROM messages WHERE session_id='uuid-h'").fetchone()[0] == 0 + conn.close() + + # 4. Cline (sessions dir) + cline_adapter = get_adapter('cline') + cline_ctx = DiscoveryContext(workspace=ws, agent_name='cline', home_dir=home) + cline_dir = f"{home}/.cline/data/sessions/uuid-cl" + os.makedirs(cline_dir, exist_ok=True) + with open(f"{cline_dir}/uuid-cl.json", 'w') as f: + f.write('{"session_id": "uuid-cl"}') + purged_cl = cline_adapter.purge_artifacts('uuid-cl', cline_ctx) + assert len(purged_cl) == 1 + assert not os.path.exists(cline_dir) + +def test_adapter_spawn_and_resume_specs(): + claude = get_adapter('claude') + assert claude.spawn_spec('claude', 'u1') == 'claude --dangerously-skip-permissions --session-id u1' + assert claude.spawn_spec('claude', '', use_wrapper=True) == 'claude --dangerously-skip-permissions' + assert claude.resume_spec('claude', 'u1', materialized=True) == 'claude --dangerously-skip-permissions -r u1' + assert claude.resume_spec('claude', 'u1', materialized=False) == 'claude --dangerously-skip-permissions --session-id u1' + + agy = get_adapter('agy') + assert agy.spawn_spec('agy', 'u1') == 'agy --dangerously-skip-permissions' + assert agy.resume_spec('agy', 'u1', materialized=True) == 'agy --dangerously-skip-permissions --conversation u1' + + hermes = get_adapter('hermes') + assert hermes.spawn_spec('hermes', 'u1') == 'hermes' + assert hermes.resume_spec('hermes', 'u1', materialized=True) == 'hermes --resume u1' + + cline = get_adapter('cline') + assert cline.spawn_spec('cline', 'u1') == 'cline -i' + assert cline.resume_spec('cline', 'u1', materialized=True) == 'cline -i --id u1' + assert cline.resume_spec('cline', 'u1', materialized=False) == 'cline -i' + +def test_adapter_auth_ok(tmp_path, monkeypatch): + monkeypatch.setenv("HOME_DIR", str(tmp_path)) + # Claude auth runner + claude = get_adapter('claude') + assert claude.auth_ok(run_cmd=lambda cmd: (0, '{"loggedIn": true}', '')) is True + assert claude.auth_ok(run_cmd=lambda cmd: (1, '{"loggedIn": false}', 'error')) is False + + # Agy auth file check + agy = get_adapter('agy') + assert agy.auth_ok() is False + oauth_file = tmp_path / ".gemini" / "oauth_creds.json" + oauth_file.parent.mkdir(parents=True, exist_ok=True) + oauth_file.write_text("{}") + assert agy.auth_ok() is True + + # Hermes & Cline always True + assert get_adapter('hermes').auth_ok() is True + assert get_adapter('cline').auth_ok() is True + +def test_adapter_discover(tmp_path): + import sqlite3 + from lib_py.agents.base import DiscoveryContext + ws = str(tmp_path / "ws") + home = str(tmp_path / "home") + os.makedirs(ws, exist_ok=True) + os.makedirs(home, exist_ok=True) + + # 1. Claude + claude = get_adapter('claude') + ctx_c = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home) + c_proj = f"{ctx_c.claude_dir}/{ctx_c.ws_key}" + os.makedirs(c_proj, exist_ok=True) + with open(f"{c_proj}/u-c1.jsonl", 'w') as f: + f.write('{"sessionId": "u-c1"}\n') + assert claude.discover(ctx_c) == ['u-c1'] + + # 2. Agy + agy = get_adapter('agy') + ctx_a = DiscoveryContext(workspace=ws, agent_name='agy', home_dir=home) + a_db = f"{home}/.gemini/antigravity-cli/conversations/u-a1.db" + os.makedirs(os.path.dirname(a_db), exist_ok=True) + conn = sqlite3.connect(a_db) + conn.execute("CREATE TABLE steps (id INT)") + conn.execute("INSERT INTO steps VALUES (1)") + conn.commit() + conn.close() + lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json" + os.makedirs(os.path.dirname(lc), exist_ok=True) + with open(lc, 'w') as f: + import json + json.dump({ws: 'u-a1'}, f) + assert agy.discover(ctx_a) == ['u-a1'] + + # 3. Hermes + hermes = get_adapter('hermes') + ctx_h = DiscoveryContext(workspace=ws, agent_name='hermes', home_dir=home) + h_db = f"{home}/.hermes/state.db" + os.makedirs(os.path.dirname(h_db), exist_ok=True) + conn = sqlite3.connect(h_db) + conn.execute("CREATE TABLE sessions (id TEXT, cwd TEXT, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)") + conn.execute("INSERT INTO sessions (id, cwd) VALUES ('u-h1', ?)", (ws,)) + conn.commit() + conn.close() + assert hermes.discover(ctx_h) == ['u-h1'] + + # 4. Cline + cline = get_adapter('cline') + ctx_cl = DiscoveryContext(workspace=ws, agent_name='cline', home_dir=home) + cl_sess = f"{home}/.cline/data/sessions/u-cl1" + os.makedirs(cl_sess, exist_ok=True) + with open(f"{cl_sess}/u-cl1.json", 'w') as f: + f.write('{"session_id": "u-cl1", "cwd": "' + ws + '"}') + assert cline.discover(ctx_cl) == ['u-cl1'] diff --git a/tests/test_orc_onboard.py b/tests/test_orc_onboard.py index 138557f..b8fceff 100644 --- a/tests/test_orc_onboard.py +++ b/tests/test_orc_onboard.py @@ -219,20 +219,6 @@ def test_o10_revalidate_normal_subagent(mam_sandbox): assert run_verify_uuid(str(mam_sandbox), "claude", sub_uuid, row=row, mode="revalidate", env=env) -# O-11: verify_session_uuid respects isolation root -def test_o11_isolation_root_respected(mam_sandbox): - orc_uuid = "01eae7cf-1db6-4395-ba48-5fb02f4b6b1f" - iso_root = mam_sandbox / "iso" - make_claude_transcript(mam_sandbox, orc_uuid, target_dir=iso_root) - - row = { - "name": "my-orc", - "claude_session_id_own": orc_uuid, - "isolation": {"root": str(iso_root), "uuid": "iso-1"}, - "pane": {"cwd": str(mam_sandbox)} - } - env = {"AGENT_SESSIONS_YAML": str(mam_sandbox / ".mam" / "agent-sessions.yaml"), "HOME_DIR": str(mam_sandbox)} - assert run_verify_uuid(str(mam_sandbox), "claude", orc_uuid, row=row, mode="revalidate", env=env) # O-12: orc_onboard.sh --uuid explicitly adds UUID diff --git a/tests/test_tier2_component.py b/tests/test_tier2_component.py index 0735a8c..022b117 100644 --- a/tests/test_tier2_component.py +++ b/tests/test_tier2_component.py @@ -293,31 +293,9 @@ d['herdr_sessions'] = [{ # ============================================================================== -# FEATURE 3: Stop Session (5 Test Cases) +# FEATURE 3: Stop Session (4 Test Cases) # ============================================================================== -def test_comp_stop_safe_path_checking(mam_sandbox): - """Verify path guards block directory deletion if isolation path check fails.""" - # Mock a terminated session where isolation root is set outside .mam folder - mutation = """ -d['herdr_sessions'] = [{ - 'name': 'test-purge-guard-creator-claude', - 'status': 'running', - 'pane': {'cwd': 'WS_PLACEHOLDER'}, - 'isolation': { - 'uuid': 'some-uuid', - 'root': '/tmp/unauthorized_path_outside_mam' - } -}] -""".replace("WS_PLACEHOLDER", str(mam_sandbox)) - run_mutation(mam_sandbox, mutation) - - script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" - # Attempt to purge. The python script should print "WARN: isolated home path check failed" and NOT crash - res = subprocess.run(["bash", str(script_path), "--session", "test-purge-guard-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True) - assert res.returncode == 0 - assert "WARN: isolated home path check failed" in res.stdout - def test_comp_stop_sqlite_state_update(mam_sandbox): """Verify that stop_session.sh updates state in SQLite to stopped.""" mutation = """ diff --git a/tests/test_uuid_target.py b/tests/test_uuid_target.py index 6370b45..8ec6dd3 100644 --- a/tests/test_uuid_target.py +++ b/tests/test_uuid_target.py @@ -342,44 +342,6 @@ def test_t10_resume_workspace_paths(mam_sandbox, mock_herdr, mock_agents): assert f"would spawn:" in res.stdout -def test_t11_legacy_isolation_row(mam_sandbox, mock_herdr, mock_agents): - """T-11: legacy isolation row resolved from isolation.root""" - iso_root = str(mam_sandbox / "iso_root") - ws = str(mam_sandbox / "iso_ws") - os.makedirs(ws, exist_ok=True) - - ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-") - proj_dir = os.path.join(iso_root, "projects", ws_key) - os.makedirs(proj_dir, exist_ok=True) - u_val = str(uuid.uuid4()) - with open(os.path.join(proj_dir, f"{u_val}.jsonl"), "w") as f: - f.write(json.dumps({"type": "queue-operation", "sessionId": u_val}) + "\n") - f.write(json.dumps({"type": "user", "sessionId": u_val, "cwd": ws}) + "\n") - - mutation = f""" -entry = {{ - 'name': 'iso-session', - 'status': 'stopped', - 'role': 'creator', - 'claude_session_id_own': '{u_val}', - 'isolation': {{'root': '{iso_root}', 'uuid': 'iso-uuid-1'}}, - 'pane': {{'cwd': '{ws}'}} -}} -d.setdefault('herdr_sessions', []).append(entry) -""" - res_m = run_mutation(mam_sandbox, mutation) - assert res_m.returncode == 0, f"mutation failed: {res_m.stderr}" - - resolve_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh" - cmd = [ - "bash", str(resolve_script), - "--workspace", ws, - "--agent", "claude", - "--session", "iso-session" - ] - res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox)) - assert res.returncode == 0 - assert res.stdout.strip() == u_val def test_t12_other_workspace_assigned_row_revalidate_fails(mam_sandbox):