"""Session adapter for codex app-server runtime. Owns one Codex thread per Hermes session. Drives `turn/start`, consumes streaming notifications via CodexEventProjector, handles server-initiated approval requests (apply_patch, exec command), translates cancellation, and returns a clean turn result that AIAgent.run_conversation() can splice into its `messages` list. Lifecycle: session = CodexAppServerSession(cwd="/home/x/proj") session.ensure_started() # spawns + handshake + thread/start result = session.run_turn(user_input="hello") # blocks until turn/completed # result.final_text → assistant text returned to caller # result.projected_messages → list of {role, content, ...} for messages list # result.tool_iterations → how many tool-shaped items completed (skill nudge counter) # result.interrupted → True if Ctrl+C / interrupt_requested fired mid-turn session.close() # tears down subprocess Threading model: the adapter is single-threaded from the caller's perspective. The underlying CodexAppServerClient owns its own reader threads but exposes blocking-with-timeout queues that this adapter polls in a loop, so the run_turn call is synchronous and behaves like AIAgent's existing chat_completions loop. """ from __future__ import annotations import logging import os import threading import time from dataclasses import dataclass, field from typing import Any, Callable, Optional from agent.transports.codex_app_server import ( CodexAppServerClient, CodexAppServerError, ) from agent.transports.codex_event_projector import CodexEventProjector logger = logging.getLogger(__name__) # Permission profile mapping mirrors the docstring in PR proposal: # Hermes' tools.terminal.security_mode → Codex's permissions profile id. # Defaults if config is missing → workspace-write (matches Codex's own default). _HERMES_TO_CODEX_PERMISSION_PROFILE = { "auto": "workspace-write", "approval-required": "read-only-with-approval", "unrestricted": "full-access", # Backstop alias used by some skills/tests. "yolo": "full-access", } @dataclass class TurnResult: """Result of one user→assistant→tool turn through the codex app-server.""" final_text: str = "" projected_messages: list[dict] = field(default_factory=list) tool_iterations: int = 0 interrupted: bool = False error: Optional[str] = None # Set if turn ended in a non-recoverable error turn_id: Optional[str] = None thread_id: Optional[str] = None @dataclass class _ServerRequestRouting: """Default policies for codex-side approval requests when no interactive callback is wired in. These are only used by tests + cron / non-interactive contexts; the live CLI path passes an approval_callback that defers to tools.approval.prompt_dangerous_approval().""" auto_approve_exec: bool = False auto_approve_apply_patch: bool = False class CodexAppServerSession: """One Codex thread per Hermes session, lifetime owned by AIAgent. Not thread-safe — one caller drives it at a time, matching how AIAgent's run_conversation() loop is structured today. The codex client itself can handle interleaved reads/writes via its own threads, but the adapter's state (projector, thread_id, turn counter) is owned by the caller thread. """ def __init__( self, *, cwd: Optional[str] = None, codex_bin: str = "codex", codex_home: Optional[str] = None, permission_profile: Optional[str] = None, approval_callback: Optional[Callable[..., str]] = None, on_event: Optional[Callable[[dict], None]] = None, request_routing: Optional[_ServerRequestRouting] = None, client_factory: Optional[Callable[..., CodexAppServerClient]] = None, ) -> None: self._cwd = cwd or os.getcwd() self._codex_bin = codex_bin self._codex_home = codex_home self._permission_profile = ( permission_profile or _HERMES_TO_CODEX_PERMISSION_PROFILE.get( os.environ.get("HERMES_TERMINAL_SECURITY_MODE", "auto"), "workspace-write", ) ) self._approval_callback = approval_callback self._on_event = on_event # Display hook (kawaii spinner ticks etc.) self._routing = request_routing or _ServerRequestRouting() self._client_factory = client_factory or CodexAppServerClient self._client: Optional[CodexAppServerClient] = None self._thread_id: Optional[str] = None self._interrupt_event = threading.Event() # Pending file-change items, keyed by item id. Populated on # item/started for fileChange items; consumed by the approval # bridge when codex sends item/fileChange/requestApproval. The # approval params don't carry the changeset, so we cache here # to surface a real summary in the approval prompt (quirk #4). self._pending_file_changes: dict[str, str] = {} self._closed = False # ---------- lifecycle ---------- def ensure_started(self) -> str: """Spawn the subprocess, do the initialize handshake, and start a thread. Returns the codex thread id. Idempotent — repeated calls return the same thread id.""" if self._thread_id is not None: return self._thread_id if self._client is None: self._client = self._client_factory( codex_bin=self._codex_bin, codex_home=self._codex_home ) self._client.initialize( client_name="hermes", client_title="Hermes Agent", client_version=_get_hermes_version(), ) # Permission selection is intentionally NOT sent on thread/start. # Two reasons (live-tested against codex 0.130.0): # 1. `thread/start.permissions` is gated behind the experimentalApi # capability on this codex version — we'd have to opt in during # initialize and accept the unstable surface. # 2. Even with experimentalApi declared and the correct shape # (`{"type": "profile", "id": "..."}`, not `{"profileId": ...}`), # codex requires a matching `[permissions]` table in # ~/.codex/config.toml or it fails the request with # 'default_permissions requires a [permissions] table'. # Letting codex pick its default (`:read-only` unless the user has # configured otherwise in their codex config.toml) is the standard # codex CLI workflow and avoids fighting codex's own validation. # Users who want a write-capable profile configure it in their # ~/.codex/config.toml the same way they would for any codex usage. params: dict[str, Any] = {"cwd": self._cwd} result = self._client.request("thread/start", params, timeout=15) self._thread_id = result["thread"]["id"] logger.info( "codex app-server thread started: id=%s profile=%s cwd=%s", self._thread_id[:8], self._permission_profile, self._cwd, ) return self._thread_id def close(self) -> None: if self._closed: return self._closed = True if self._client is not None: try: self._client.close() except Exception: # pragma: no cover - best-effort cleanup pass self._client = None self._thread_id = None def __enter__(self) -> "CodexAppServerSession": return self def __exit__(self, *exc: Any) -> None: self.close() # ---------- interrupt ---------- def request_interrupt(self) -> None: """Idempotent: signal the active turn loop to issue turn/interrupt and unwind. Called by AIAgent's _interrupt_requested path.""" self._interrupt_event.set() # ---------- per-turn ---------- def run_turn( self, user_input: str, *, turn_timeout: float = 600.0, notification_poll_timeout: float = 0.25, ) -> TurnResult: """Send a user message and block until turn/completed, while forwarding server-initiated approval requests and projecting items into Hermes' messages shape.""" self.ensure_started() assert self._client is not None and self._thread_id is not None self._interrupt_event.clear() projector = CodexEventProjector() result = TurnResult(thread_id=self._thread_id) # Send turn/start with the user input. Text-only for now (codex # supports rich content but Hermes' text path is the common case). try: ts = self._client.request( "turn/start", { "threadId": self._thread_id, "input": [{"type": "text", "text": user_input}], }, timeout=10, ) except CodexAppServerError as exc: result.error = f"turn/start failed: {exc}" return result result.turn_id = (ts.get("turn") or {}).get("id") deadline = time.time() + turn_timeout turn_complete = False while time.time() < deadline and not turn_complete: if self._interrupt_event.is_set(): self._issue_interrupt(result.turn_id) result.interrupted = True break # Drain any server-initiated requests (approvals) before # reading notifications, so the codex side isn't blocked. sreq = self._client.take_server_request(timeout=0) if sreq is not None: # Drain any pending notifications first so per-turn state # (e.g. _pending_file_changes for fileChange approvals) is # up to date when we make the approval decision. Bounded # to avoid starving the server-request response. for _ in range(8): pending = self._client.take_notification(timeout=0) if pending is None: break self._track_pending_file_change(pending) proj = projector.project(pending) if proj.messages: result.projected_messages.extend(proj.messages) if proj.is_tool_iteration: result.tool_iterations += 1 if proj.final_text is not None: result.final_text = proj.final_text self._handle_server_request(sreq) continue note = self._client.take_notification( timeout=notification_poll_timeout ) if note is None: continue method = note.get("method", "") if self._on_event is not None: try: self._on_event(note) except Exception: # pragma: no cover - display callback logger.debug("on_event callback raised", exc_info=True) # Track in-progress fileChange items so the approval bridge # can surface a real change summary when codex requests # approval (the approval params themselves don't carry the # changeset). Quirk #4 fix. self._track_pending_file_change(note) # Project into messages projection = projector.project(note) if projection.messages: result.projected_messages.extend(projection.messages) if projection.is_tool_iteration: result.tool_iterations += 1 if projection.final_text is not None: # Codex can emit multiple agentMessage items in one turn # (e.g. partial then final). Take the last one as canonical. result.final_text = projection.final_text if method == "turn/completed": turn_complete = True turn_status = ( (note.get("params") or {}).get("turn") or {} ).get("status") if turn_status and turn_status not in ("completed", "interrupted"): err_obj = ( (note.get("params") or {}).get("turn") or {} ).get("error") if err_obj: result.error = ( f"turn ended status={turn_status}: " f"{err_obj.get('message') or err_obj}" ) if not turn_complete and not result.interrupted: # Hit the deadline. Issue interrupt to stop wasted compute. self._issue_interrupt(result.turn_id) result.interrupted = True result.error = result.error or f"turn timed out after {turn_timeout}s" return result # ---------- internals ---------- def _issue_interrupt(self, turn_id: Optional[str]) -> None: if self._client is None or self._thread_id is None or turn_id is None: return try: self._client.request( "turn/interrupt", {"threadId": self._thread_id, "turnId": turn_id}, timeout=5, ) except CodexAppServerError as exc: # "no active turn to interrupt" is fine — already done. logger.debug("turn/interrupt non-fatal: %s", exc) except TimeoutError: logger.warning("turn/interrupt timed out") def _handle_server_request(self, req: dict) -> None: """Translate a codex server request (approval) into Hermes' approval flow, then send the response. Method names verified live against codex 0.130.0 (Apr 2026): item/commandExecution/requestApproval — exec approvals item/fileChange/requestApproval — apply_patch approvals item/permissions/requestApproval — permissions changes (we decline; user controls permission profile in ~/.codex/config.toml). """ if self._client is None: return method = req.get("method", "") rid = req.get("id") params = req.get("params") or {} if method == "item/commandExecution/requestApproval": decision = self._decide_exec_approval(params) self._client.respond(rid, {"decision": decision}) elif method == "item/fileChange/requestApproval": decision = self._decide_apply_patch_approval(params) self._client.respond(rid, {"decision": decision}) elif method == "item/permissions/requestApproval": # Codex sometimes asks to escalate permissions mid-turn. We # always decline — the user already chose their permission # profile in ~/.codex/config.toml and surprise escalations # shouldn't be silently accepted. self._client.respond(rid, {"decision": "decline"}) elif method == "mcpServer/elicitation/request": # Codex's MCP layer asks the user for structured input on # behalf of an MCP server (e.g. tool-call confirmation, # OAuth, form data). For our own hermes-tools callback we # auto-accept — the user already approved Hermes' tools # by enabling the runtime, and we never expose anything # codex's built-in shell can't already do. For other MCP # servers we decline so the user explicitly opts in via # codex's own auth flow. server_name = params.get("serverName") or "" if server_name == "hermes-tools": self._client.respond( rid, {"action": "accept", "content": None, "_meta": None}, ) else: self._client.respond( rid, {"action": "decline", "content": None, "_meta": None}, ) else: # Unknown server request — codex can extend this surface. Reject # cleanly so codex doesn't hang waiting for us. logger.warning("Unknown codex server request: %s", method) self._client.respond_error( rid, code=-32601, message=f"Unsupported method: {method}" ) def _decide_exec_approval(self, params: dict) -> str: if self._routing.auto_approve_exec: return "accept" command = params.get("command") or "" # Codex's CommandExecutionRequestApprovalParams has cwd as Optional — # fall back to the session's cwd when codex doesn't include it so the # approval prompt is never empty (quirk #10 fix). cwd = params.get("cwd") or self._cwd or "" reason = params.get("reason") description = f"Codex requests exec in {cwd}" if reason: description += f" — {reason}" if self._approval_callback is not None: try: choice = self._approval_callback( command, description, allow_permanent=False ) return _approval_choice_to_codex_decision(choice) except Exception: logger.exception("approval_callback raised on exec request") return "decline" return "decline" # fail-closed when no callback wired def _decide_apply_patch_approval(self, params: dict) -> str: if self._routing.auto_approve_apply_patch: return "accept" if self._approval_callback is not None: # FileChangeRequestApprovalParams gives us reason + grantRoot. # The actual changeset lives on the corresponding fileChange # item which the projector has already cached for us — look it # up by item_id so the user sees what's actually changing. reason = params.get("reason") grant_root = params.get("grantRoot") item_id = params.get("itemId") or "" change_summary = self._lookup_pending_file_change(item_id) description_parts = [] if reason: description_parts.append(reason) if change_summary: description_parts.append(change_summary) if grant_root: description_parts.append(f"grants write to {grant_root}") description = ( "; ".join(description_parts) if description_parts else "Codex requests to apply a patch" ) command_label = ( f"apply_patch: {change_summary}" if change_summary else f"apply_patch: {reason}" if reason else "apply_patch" ) try: choice = self._approval_callback( command_label, description, allow_permanent=False, ) return _approval_choice_to_codex_decision(choice) except Exception: logger.exception("approval_callback raised on apply_patch") return "decline" return "decline" def _track_pending_file_change(self, note: dict) -> None: """Maintain self._pending_file_changes from item/started + item/completed notifications. Lets the apply_patch approval prompt show what's actually changing — codex's approval params don't carry the data.""" method = note.get("method", "") params = note.get("params") or {} item = params.get("item") or {} if item.get("type") != "fileChange": return item_id = item.get("id") or "" if not item_id: return if method == "item/started": changes = item.get("changes") or [] if not changes: self._pending_file_changes[item_id] = "1 change pending" return kinds: dict[str, int] = {} paths: list[str] = [] for ch in changes: if not isinstance(ch, dict): continue kind = (ch.get("kind") or {}).get("type") or "update" kinds[kind] = kinds.get(kind, 0) + 1 p = ch.get("path") or "" if p: paths.append(p) counts = ", ".join(f"{n} {k}" for k, n in sorted(kinds.items())) preview = ", ".join(paths[:3]) if len(paths) > 3: preview += f", +{len(paths) - 3} more" self._pending_file_changes[item_id] = ( f"{counts}: {preview}" if preview else counts ) elif method == "item/completed": self._pending_file_changes.pop(item_id, None) def _lookup_pending_file_change(self, item_id: str) -> Optional[str]: """Look up an in-progress fileChange item by id and summarize its changes for the approval prompt. Returns None when we don't have the item cached (e.g. approval arrived before item/started, or fileChange item content not tracked yet).""" if not item_id: return None cached = self._pending_file_changes.get(item_id) if not cached: return None return cached def _approval_choice_to_codex_decision(choice: str) -> str: """Map Hermes approval choices onto codex's CommandExecutionApprovalDecision / FileChangeApprovalDecision wire values. Hermes returns 'once', 'session', 'always', or 'deny'. Codex expects 'accept', 'acceptForSession', 'decline', or 'cancel' (verified against codex-rs/app-server-protocol/src/protocol/v2/item.rs on codex 0.130.0). """ if choice in ("once",): return "accept" if choice in ("session", "always"): return "acceptForSession" return "decline" def _get_hermes_version() -> str: """Best-effort Hermes version string for codex's userAgent line.""" try: from importlib.metadata import version return version("hermes-agent") except Exception: # pragma: no cover return "0.0.0"