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:
@@ -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 <session-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]
|
||||
+21
-55
@@ -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
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user