Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,35 @@ Expected:

## Acceptance criteria

Status: implemented in PR #75.

Implemented wrapper scope:
- `hermit_agent/orchestrators/claude.py` adds `ClaudeCodeMcpAdapter`.
- `hermit_agent/orchestrators/codex.py` adds `CodexAdapter`.
- `hermit_agent/orchestrators/__init__.py` exports both wrappers.
- `tests/test_claude_orchestrator_adapter.py` and `tests/test_codex_orchestrator_adapter.py` cover setup/health DTO mapping and explicit unsupported lifecycle methods.

Status mapping implemented:
- Claude print-only returns `AdapterInstallStatus.PRINTED` from existing config snippet generation.
- Claude fix maps `ensure_claude_mcp_registered()` outcomes to `REGISTERED`, `UNCHANGED`, or `FAILED`.
- Claude health maps `is_claude_mcp_registered()` to `PASS` or `WARN`.
- Codex print-only returns `AdapterInstallStatus.SKIPPED` with actionable details.
- Codex fix delegates to `ensure_codex_channels_ready()`, `ensure_codex_marketplace_registered()`, `ensure_codex_mcp_registered()`, and `remove_codex_reply_hook()`, then maps outcomes to `REGISTERED`, `UNCHANGED`, or `FAILED`.
- Codex health maps `get_codex_runtime_version()` to `PASS` or `WARN`.

Runtime note:
- This is wrapper-only. CLI dispatch, MCP server runtime, Claude channel notification behavior, codex-channels runtime delivery, and task lifecycle handling remain unchanged.

Validation commands:

```bash
.venv/bin/python -m pytest tests/test_claude_orchestrator_adapter.py tests/test_codex_orchestrator_adapter.py -q
.venv/bin/python -m pytest tests/test_claude_orchestrator_adapter.py tests/test_codex_orchestrator_adapter.py tests/test_hermes_orchestrator_adapter.py tests/test_orchestrator_contracts.py tests/test_codex_channels_adapter.py tests/test_codex_channels_mcp_wiring.py tests/test_mcp_server.py -q
.venv/bin/python -m pytest tests/ -q
```

Original acceptance criteria:

- Claude, Codex, and Hermes can all be described through setup/health adapter DTOs.
- Runtime behavior remains unchanged.
- Unsupported lifecycle methods fail explicitly.
Expand Down
2 changes: 2 additions & 0 deletions .dev/refactor/orchestrator-adapter-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ DTOs should stay orchestrator-neutral:
Current test anchor:
- `tests/test_orchestrator_contracts.py` verifies immutable DTO shape, stable status/event enum values, and the structural lifecycle shape of `OrchestratorAdapter`.
- `tests/test_hermes_orchestrator_adapter.py` verifies `HermesMcpAdapter` maps existing Hermes install, live-smoke, and doctor helpers into `AdapterInstallResult` / `AdapterHealth` without mutating behavior.
- `tests/test_claude_orchestrator_adapter.py` verifies `ClaudeCodeMcpAdapter` maps existing Claude MCP print/fix/registration-health helpers into `AdapterInstallResult` / `AdapterHealth` without mutating runtime delivery behavior.
- `tests/test_codex_orchestrator_adapter.py` verifies `CodexAdapter` maps existing codex-channels, Codex marketplace, Codex MCP, and legacy reply-hook helpers into `AdapterInstallResult` / `AdapterHealth` without mutating runtime delivery behavior.
- `tests/test_orchestrator_prompt_mapping.py` verifies current runtime prompts can round-trip through the neutral `InteractivePrompt` / `PromptReply` DTOs while preserving method/request/thread metadata.
- `tests/test_orchestrator_event_mapping.py` verifies current Gateway SSE events, channel actions, and task status payloads can map into neutral `TaskEvent` DTOs without rewiring delivery behavior.

Expand Down
2 changes: 1 addition & 1 deletion .dev/refactor/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Acceptance:

### R2.2 Move orchestrator-specific code behind adapters

Status: first Hermes wrapper added as `HermesMcpAdapter`; it maps existing install/doctor/live-smoke helpers to adapter DTOs but does not yet change CLI dispatch or MCP task runtime paths. Prompt mapping helpers now round-trip current runtime `InteractivePrompt` values through the neutral `InteractivePrompt` / `PromptReply` DTOs. Task event mapping helpers now convert current Gateway SSE events, channel actions, and task status payloads into neutral `TaskEvent` DTOs as another behavior-preserving extraction step.
Status: first Hermes wrapper added as `HermesMcpAdapter`; it maps existing install/doctor/live-smoke helpers to adapter DTOs but does not yet change CLI dispatch or MCP task runtime paths. Claude Code and Codex now also have thin setup/health wrappers (`ClaudeCodeMcpAdapter`, `CodexAdapter`) that map existing registration/runtime helpers into neutral `AdapterInstallResult` / `AdapterHealth` DTOs without rewiring runtime delivery. Prompt mapping helpers now round-trip current runtime `InteractivePrompt` values through the neutral `InteractivePrompt` / `PromptReply` DTOs. Task event mapping helpers now convert current Gateway SSE events, channel actions, and task status payloads into neutral `TaskEvent` DTOs as another behavior-preserving extraction step.

Likely areas:
- `hermit_agent/mcp_channel.py`
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
- Added a thin `HermesMcpAdapter` wrapper that maps existing Hermes print/fix/live-smoke and doctor helpers onto the shared adapter DTOs without rewiring current CLI or MCP runtime behavior.
- Added adapter prompt mapping helpers that round-trip the current runtime `InteractivePrompt` shape through the neutral `InteractivePrompt` / `PromptReply` DTOs without changing interactive delivery behavior.
- Added pure task event mapping helpers that convert existing Gateway SSE events, channel actions, and task status payloads into neutral `TaskEvent` DTOs without changing MCP/channel delivery behavior.
- Added thin Claude Code and Codex adapter wrappers that map existing setup and health helpers onto the shared adapter DTOs without changing the current MCP/channel runtime delivery paths.
- Started tracking the long-term refactor roadmap under `.dev/refactor/**` while leaving other `.dev/*` scratch files ignored.
- Removed warning-producing test mocks for setup coroutines so the suite no longer emits unawaited `init_db` / `create_api_key` runtime warnings.
- Aligned contributor and harness docs on `.venv/bin/python -m pytest tests/` and added a repo hygiene regression so stale `.venv/bin/pytest` shebangs are not reintroduced as the documented path.
Expand Down
4 changes: 4 additions & 0 deletions hermit_agent/orchestrators/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Orchestrator adapter contracts for Hermit executor integrations."""

from .claude import ClaudeCodeMcpAdapter
from .codex import CodexAdapter
from .contracts import (
AdapterHealth,
AdapterHealthStatus,
Expand Down Expand Up @@ -30,6 +32,8 @@
"AdapterHealthStatus",
"AdapterInstallResult",
"AdapterInstallStatus",
"ClaudeCodeMcpAdapter",
"CodexAdapter",
"HermesMcpAdapter",
"InteractivePrompt",
"OrchestratorAdapter",
Expand Down
106 changes: 106 additions & 0 deletions hermit_agent/orchestrators/claude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Claude Code adapter wrappers around existing install helpers.

This module is intentionally thin: it maps Claude MCP setup/health helpers
into orchestrator-neutral DTOs without rewiring the MCP runtime path.
"""

from __future__ import annotations

from .contracts import (
AdapterHealth,
AdapterHealthStatus,
AdapterInstallResult,
AdapterInstallStatus,
InteractivePrompt,
PromptReply,
TaskEvent,
TaskHandle,
TaskRequest,
)
from ..install_flow import inspect_claude_mcp_registration, register_claude_mcp, resolve_hermit_mcp_stdio_entry


class ClaudeCodeMcpAdapter:
"""Adapter DTO wrapper for Claude Code's Hermit MCP integration."""

name = "claude-code"

def install_or_print_instructions(self, *, cwd: str, fix: bool) -> AdapterInstallResult:
entry = resolve_hermit_mcp_stdio_entry(cwd=cwd)
command = str(entry["command"])
args = [str(arg) for arg in entry.get("args", [])] if isinstance(entry.get("args"), list) else []
if not fix:
details = (
"print-only Claude Code MCP registration instructions",
"Add `hermit-channel` under ~/.claude.json mcpServers.",
f"transport: {command} {' '.join(args)}".strip(),
)
return AdapterInstallResult(
name=self.name,
status=AdapterInstallStatus.PRINTED,
message="print-only Claude Code MCP registration instructions",
details=details,
changed=False,
)

try:
status, path, backup = register_claude_mcp(entry=entry)
except Exception as exc:
return AdapterInstallResult(
name=self.name,
status=AdapterInstallStatus.FAILED,
message=f"failed ({exc})",
changed=False,
)
return _install_result_from_status(status, path=path, backup=backup)

def health(self, *, cwd: str) -> AdapterHealth:
status = inspect_claude_mcp_registration(entry=resolve_hermit_mcp_stdio_entry(cwd=cwd))
return _health_from_registration_status(status)

def submit_task(self, request: TaskRequest) -> TaskHandle:
raise NotImplementedError("Claude Code task submission still uses the MCP server path directly")

def emit_event(self, task_id: str, event: TaskEvent) -> None:
raise NotImplementedError("Claude Code event delivery still uses the MCP server path directly")

def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None:
raise NotImplementedError("Claude Code reply delivery still uses the MCP server path directly")

def cancel(self, task_id: str) -> None:
raise NotImplementedError("Claude Code cancellation still uses the MCP server path directly")


def _install_result_from_status(status: str, *, path: object, backup: object | None) -> AdapterInstallResult:
if status == "registered":
install_status = AdapterInstallStatus.REGISTERED
changed = True
elif status == "unchanged":
install_status = AdapterInstallStatus.UNCHANGED
changed = False
else:
install_status = AdapterInstallStatus.FAILED
changed = False
details = [f"path: {path}"]
if backup is not None:
details.append(f"backup: {backup}")
return AdapterInstallResult(
name=ClaudeCodeMcpAdapter.name,
status=install_status,
message=status,
details=tuple(details),
changed=changed,
)


def _health_from_registration_status(status: str) -> AdapterHealth:
if status == "registered":
health_status = AdapterHealthStatus.PASS
message = "hermit-channel registered for Claude Code"
elif status == "invalid":
health_status = AdapterHealthStatus.FAIL
message = "Claude MCP registration invalid"
else:
health_status = AdapterHealthStatus.WARN
message = f"Claude MCP registration {status}"
return AdapterHealth(name=ClaudeCodeMcpAdapter.name, status=health_status, message=message)
136 changes: 136 additions & 0 deletions hermit_agent/orchestrators/codex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Codex adapter wrappers around existing install helpers.

This module maps Codex integration setup/health helpers into the neutral adapter
DTO layer without changing codex-channels, MCP, or runtime delivery behavior.
"""

from __future__ import annotations

from .contracts import (
AdapterHealth,
AdapterHealthStatus,
AdapterInstallResult,
AdapterInstallStatus,
InteractivePrompt,
PromptReply,
TaskEvent,
TaskHandle,
TaskRequest,
)
from ..install_flow import (
ensure_codex_channels_ready,
ensure_codex_marketplace_registered,
ensure_codex_mcp_registered,
get_codex_runtime_version,
remove_codex_reply_hook,
)


class CodexAdapter:
"""Adapter DTO wrapper for Hermit's Codex integration setup surface."""

name = "codex"

def __init__(self, *, codex_command: str = "codex", scope: str = "user") -> None:
self.codex_command = codex_command
self.scope = scope

def install_or_print_instructions(self, *, cwd: str, fix: bool) -> AdapterInstallResult:
if not fix:
return AdapterInstallResult(
name=self.name,
status=AdapterInstallStatus.SKIPPED,
message="Codex setup not changed from print-only adapter path",
details=(
"Use fix=True to install/refresh codex-channels and Codex MCP registration.",
"Existing setup path registers Codex marketplace and codex mcp hermit-channel.",
),
changed=False,
)

try:
runtime_status, runtime_details, runtime_version = ensure_codex_channels_ready(
cwd=cwd,
codex_command=self.codex_command,
scope=self.scope,
)
marketplace_status = ensure_codex_marketplace_registered(
cwd=cwd,
codex_command=self.codex_command,
scope=self.scope,
)
mcp_status = ensure_codex_mcp_registered(cwd=cwd, codex_command=self.codex_command)
hook_status = remove_codex_reply_hook(cwd=cwd)
except Exception as exc:
return AdapterInstallResult(
name=self.name,
status=AdapterInstallStatus.FAILED,
message=f"failed ({exc})",
changed=False,
)

details: list[str] = []
if runtime_version:
details.append(f"runtime version: {runtime_version}")
details.extend(
[
f"marketplace: {marketplace_status}",
f"mcp registration: {mcp_status}",
f"legacy reply hook: {hook_status}",
]
)
details.extend(runtime_details)
return AdapterInstallResult(
name=self.name,
status=_install_status_from_codex_statuses(runtime_status, marketplace_status, mcp_status, hook_status),
message=runtime_status,
details=tuple(details),
changed=_codex_statuses_changed(runtime_status, marketplace_status, mcp_status, hook_status),
)

def health(self, *, cwd: str) -> AdapterHealth:
version = get_codex_runtime_version(cwd=cwd)
if version:
return AdapterHealth(
name=self.name,
status=AdapterHealthStatus.PASS,
message="codex-channels runtime installed",
details=(f"runtime version: {version}",),
)
return AdapterHealth(
name=self.name,
status=AdapterHealthStatus.WARN,
message="codex-channels runtime missing — run `hermit install` or use fix=True adapter setup",
)

def submit_task(self, request: TaskRequest) -> TaskHandle:
raise NotImplementedError("Codex task submission still uses the existing Codex channel/MCP runtime paths")

def emit_event(self, task_id: str, event: TaskEvent) -> None:
raise NotImplementedError("Codex event delivery still uses the existing Codex channel/MCP runtime paths")

def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None:
raise NotImplementedError("Codex reply delivery still uses the existing Codex channel/MCP runtime paths")

def cancel(self, task_id: str) -> None:
raise NotImplementedError("Codex cancellation still uses the existing Codex channel/MCP runtime paths")


def _codex_statuses_changed(runtime_status: str, marketplace_status: str, mcp_status: str, hook_status: str) -> bool:
return any(
status in {"installed", "registered", "removed"}
for status in (runtime_status, marketplace_status, mcp_status, hook_status)
)


def _install_status_from_codex_statuses(
runtime_status: str,
marketplace_status: str,
mcp_status: str,
hook_status: str,
) -> AdapterInstallStatus:
if any(status.startswith("failed") for status in (runtime_status, marketplace_status, mcp_status, hook_status)):
return AdapterInstallStatus.FAILED
if _codex_statuses_changed(runtime_status, marketplace_status, mcp_status, hook_status):
return AdapterInstallStatus.REGISTERED
return AdapterInstallStatus.UNCHANGED
Loading