"""Session adapter for codex app-server runtime. Owns one Codex thread per Hermes session: drives ``turn/start``, consumes streaming notifications via CodexEventProjector, bridges server-initiated approval requests, translates cancellation, and returns a TurnResult that AIAgent.run_conversation() splices into ``messages``. Synchronous from the caller's view: the client's reader threads feed blocking-with-timeout queues that this adapter polls, matching AIAgent's 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.codex_responses_adapter import _format_responses_error from agent.redact import redact_sensitive_text from agent.transports.codex_app_server import ( CodexAppServerClient, CodexAppServerError, ) from agent.transports.codex_event_projector import CodexEventProjector, ProjectionResult logger = logging.getLogger(__name__) # Tail of codex stderr attached to generic user-facing errors: small enough to # stay legible, large enough to surface a config/provider/auth diagnostic. _STDERR_TAIL_LINES = 12 # Hermes' tools.terminal.security_mode -> Codex permissions profile id. # Missing config -> workspace-write (Codex's own default). _HERMES_TO_CODEX_PERMISSION_PROFILE = { "auto": "workspace-write", "approval-required": "read-only-with-approval", "unrestricted": "full-access", "yolo": "full-access", # yolo: backstop alias used by some skills/tests } @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 token_usage_last: Optional[dict[str, Any]] = None model_context_window: Optional[int] = None compacted: bool = False # Codex subprocess is likely wedged (turn timeout, post-tool watchdog, token # refresh failure): caller should retire the session so the next turn respawns. should_retire: bool = False # Some codex versions stream ```` as raw agentMessage text when an # interrupt/upstream error tears the turn down without emitting turn/completed. _TURN_ABORTED_MARKERS = ("", "") def _notification_scope_ids(note: dict) -> tuple[Optional[str], Optional[str]]: """Extract the thread/turn identity carried by a notification (top-level, then turn/item).""" params = (note.get("params") or {}) if isinstance(note, dict) else None if not isinstance(params, dict): return None, None turn, item = params.get("turn") or {}, params.get("item") or {} def first(*lookups: tuple[Any, str, str]) -> Any: """``src.get(a) or src.get(b)`` over successive dict sources until one is not None.""" for src, primary, fallback in lookups: if isinstance(src, dict): observed = src.get(primary) or src.get(fallback) if observed is not None: return observed return None return ( first((params, "threadId", "thread_id"), (turn, "threadId", "thread_id"), (item, "threadId", "thread_id")), first((params, "turnId", "turn_id"), (turn, "id", "turnId"), (item, "turnId", "turn_id")), ) def _notification_belongs_to_turn(note: dict, *, thread_id: Optional[str], turn_id: Optional[str]) -> bool: """Whether a multiplexed notification belongs to this turn. One JSON-RPC connection can carry parent and hosted subagent threads; an explicitly foreign thread/turn event must not mutate this transcript. Unscoped notifications remain accepted for protocol compatibility. """ if not isinstance(note, dict): return False observed_thread_id, observed_turn_id = _notification_scope_ids(note) if thread_id is not None and observed_thread_id is not None and str(observed_thread_id) != str(thread_id): return False return not (turn_id is not None and observed_turn_id is not None and str(observed_turn_id) != str(turn_id)) def _coerce_turn_input_text(user_input: Any) -> str: """Collapse Hermes/OpenAI rich content parts into app-server text input. ``turn/start`` sends text items only; TUI image attachments arrive as content parts, so keep text and replace image payloads with a marker. """ if isinstance(user_input, str): return user_input if not isinstance(user_input, list): return "" if user_input is None else str(user_input) parts: list[str] = [] for item in user_input: if not isinstance(item, dict): keep = item.strip() if isinstance(item, str) else item is not None if keep: parts.append(str(item)) continue item_type = item.get("type") if item_type in {"text", "input_text"}: text = item.get("text") or item.get("content") or "" if text: parts.append(str(text)) elif item_type in {"image", "image_url", "input_image"}: parts.append("[image attached]") text = "\n\n".join(p for p in parts if p).strip() return text or "What do you see in this image?" # Substrings in codex stderr / JSON-RPC errors signalling expired OAuth creds. # Conservative on purpose: only redirect to `codex login` on a strong signal, # otherwise the original error surfaces verbatim. _OAUTH_REFRESH_FAILURE_HINTS = ( "invalid_grant", "invalid grant", "refresh token", "refresh_token", "token refresh", "token_refresh", "token has expired", "expired_token", "expired token", "not authenticated", "unauthenticated", "unauthorized", "401 unauthorized", "re-authenticate", "reauthenticate", "please log in", "please login", "auth profile", "no auth profile", "oauth", ) _OAUTH_REAUTH_HINT = ( "Codex authentication failed — your ChatGPT/Codex login looks expired or invalid. Run `codex login` to refresh, " "then retry. (Fall back to default runtime with `/codex-runtime auto` if the issue persists.)" ) def _classify_oauth_failure(*parts: str) -> Optional[str]: """Re-auth hint if any part looks like a codex OAuth/token-refresh failure, else None.""" haystack = " ".join(p for p in parts if p).lower() if haystack and any(needle in haystack for needle in _OAUTH_REFRESH_FAILURE_HINTS): return _OAUTH_REAUTH_HINT return None @dataclass class _ServerRequestRouting: """Default approval policies when no interactive callback is wired in. Used by tests + cron / non-interactive contexts; the live CLI 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 (projector, thread_id and turn state are owned by the caller thread), like AIAgent.run_conversation(). """ 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() self._active_turn_id: Optional[str] = None self._active_turn_lock = threading.Lock() # In-progress fileChange items by id (item/started -> item/completed): # approval params don't carry the changeset, so this feeds the prompt summary. self._pending_file_changes: dict[str, str] = {} self._closed = False def ensure_started(self) -> str: """Spawn, handshake, and ``thread/start``; idempotent, returns the codex 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()) # Permissions are intentionally NOT sent on thread/start: on codex 0.130 # ``thread/start.permissions`` is gated behind experimentalApi and also # requires a matching ``[permissions]`` table in ~/.codex/config.toml. # Users configure a write-capable profile there, as for any codex use. result = self._client.request("thread/start", {"cwd": self._cwd}, timeout=15) # Different codex versions serialize the id under thread.id / sessionId / threadId. thread_obj = result.get("thread") or {} thread_id = ( thread_obj.get("id") or thread_obj.get("sessionId") or result.get("sessionId") or result.get("threadId") ) if not thread_id: raise CodexAppServerError( code=-32603, message=f"codex thread/start returned no thread id (payload keys: {sorted(result.keys())})", ) self._thread_id = thread_id logger.info( "codex app-server thread started: id=%s profile=%s cwd=%s", thread_id[:8], self._permission_profile, self._cwd ) return thread_id def close(self) -> None: if self._closed: return self._closed = True with self._active_turn_lock: self._active_turn_id = None 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() def request_interrupt(self) -> None: """Idempotent: signal the active turn loop to issue turn/interrupt and unwind.""" self._interrupt_event.set() def request_steer(self, text: str) -> bool: """Append user guidance to the active Codex turn via ``turn/steer``.""" cleaned = str(text or "").strip() if not cleaned: return False with self._active_turn_lock: turn_id = self._active_turn_id thread_id = self._thread_id client = self._client if not turn_id or not thread_id or client is None: return False try: response = client.request( "turn/steer", {"threadId": thread_id, "input": [{"type": "text", "text": cleaned}], "expectedTurnId": turn_id}, timeout=10, ) except (CodexAppServerError, TimeoutError): logger.debug("turn/steer rejected for active Codex turn", exc_info=True) return False accepted_turn_id = response.get("turnId") if isinstance(response, dict) else None return accepted_turn_id in {None, turn_id} def _format_error_with_stderr(self, prefix: str, exc: Any = "", *, tail_lines: int = _STDERR_TAIL_LINES) -> str: """User-facing error string for generic codex failures. Appends the redacted (force=True) stderr tail so provider/auth secrets never leak into chat output, while codex's opaque 'Internal error' text becomes diagnosable. Specific classifications (OAuth, wedge watchdog) produce their own clean hint instead. """ exc_str = str(exc) if exc != "" and exc is not None else "" base = f"{prefix}: {exc_str}" if exc_str else prefix try: tail = self._client.stderr_tail(tail_lines) if self._client is not None else [] except Exception: # pragma: no cover - diagnostic best-effort return base joined = "\n".join(line.rstrip() for line in tail if line) if not joined.strip(): return base redacted = redact_sensitive_text(joined, force=True) return f"{base}\ncodex stderr (last {len(tail)} lines):\n{redacted}" def _stderr_blob(self, n: int) -> str: return "\n".join(self._client.stderr_tail(n)) @staticmethod def _retire(result: TurnResult, error: str) -> None: """Record a terminal error and flag the session for respawn on the next turn.""" result.error = error result.should_retire = True def _set_classified_error(self, result: TurnResult, prefix: str, classify_text: str, detail: Any) -> None: """OAuth failures become the re-auth hint AND retire the session (token store is broken even though JSON-RPC is fine); everything else gets the stderr tail.""" hint = _classify_oauth_failure(classify_text, self._stderr_blob(40)) if hint is not None: self._retire(result, hint) else: result.error = self._format_error_with_stderr(prefix, detail) def _start_for(self, result: TurnResult) -> bool: """ensure_started() into ``result``; startup failures surface as TurnResult.error (retiring the session) instead of raw codex exceptions reaching AIAgent.""" try: self.ensure_started() except (CodexAppServerError, TimeoutError) as exc: self._retire(result, self._format_error_with_stderr("codex app-server startup failed", exc)) return False assert self._client is not None and self._thread_id is not None result.thread_id = self._thread_id return True def _request_for(self, result: TurnResult, method: str, params: dict, label: str) -> Optional[dict]: """Issue ``method``; on failure fill ``result.error`` and return None. A timeout is a strong wedge signal and always retires the session.""" try: return self._client.request(method, params, timeout=10) except CodexAppServerError as exc: self._set_classified_error(result, f"{label} failed", exc.message, exc) except TimeoutError as exc: hint = _classify_oauth_failure(self._stderr_blob(40)) self._retire(result, hint or self._format_error_with_stderr(f"{label} timed out", exc)) return None def _subprocess_died(self, result: TurnResult) -> bool: """Bail out early (rather than waiting on the deadline) when codex exited.""" if self._client.is_alive(): return False hint = _classify_oauth_failure(self._stderr_blob(60)) self._retire( result, hint or self._format_error_with_stderr("codex app-server subprocess exited unexpectedly", tail_lines=20), ) return True def _absorb_notification( self, result: TurnResult, projector: CodexEventProjector, note: dict ) -> tuple[ProjectionResult, bool]: """Fan one in-scope notification out to display, accounting, file-change tracking and the projector. Returns (projection, aborted) where aborted means the agent text carried a ```` marker (terminal).""" 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) _apply_token_usage_notification(result, note) _apply_compaction_notification(result, note) self._track_pending_file_change(note) projection = projector.project(note) if projection.messages: result.projected_messages.extend(projection.messages) if projection.is_tool_iteration: result.tool_iterations += 1 aborted = False if projection.final_text is not None: # Multiple agentMessage items per turn: the last one is canonical. result.final_text = projection.final_text if _has_turn_aborted_marker(projection.final_text): aborted = True result.interrupted = True result.error = result.error or "codex reported turn_aborted" return projection, aborted def run_turn( self, user_input: Any, *, turn_timeout: float = 600.0, notification_poll_timeout: float = 0.25, post_tool_quiet_timeout: float = 90.0, ) -> TurnResult: """Send a user message and block until turn/completed, bridging approvals and projecting items into Hermes' messages shape. post_tool_quiet_timeout: if codex completes a tool and then stays silent this long, fast-fail and retire instead of burning the full turn deadline. """ result = TurnResult() if not self._start_for(result): self._interrupt_event.clear() return result # Do not clear first: a hard stop arriving during ensure_started() must # be honored before launching a Codex turn. if self._interrupt_event.is_set(): result.interrupted = True self._interrupt_event.clear() return result projector = CodexEventProjector() ts = self._request_for( result, "turn/start", {"threadId": self._thread_id, "input": [{"type": "text", "text": _coerce_turn_input_text(user_input)}]}, "turn/start", ) if ts is None: self._interrupt_event.clear() return result result.turn_id = (ts.get("turn") or {}).get("id") with self._active_turn_lock: self._active_turn_id = result.turn_id # Post-tool watchdog: armed on each tool completion, cleared by any other activity. last_tool_completion_at: Optional[float] = None def watchdog_tripped() -> bool: if ( last_tool_completion_at is not None and (time.monotonic() - last_tool_completion_at) > post_tool_quiet_timeout ): self._issue_interrupt(result.turn_id) result.interrupted = True self._retire( result, f"codex went silent for {post_tool_quiet_timeout:.0f}s after a tool result; " f"retiring app-server session.", ) return True return False def on_server_request(sreq: dict) -> bool: nonlocal last_tool_completion_at # Drain pending notifications first (bounded) so per-turn state such # as _pending_file_changes is current for the approval decision, and # so display events around approvals still reach on_event. turn_complete = False for _ in range(8): pending = self._client.take_notification(timeout=0) if pending is None: break if not _notification_belongs_to_turn( pending, thread_id=self._thread_id, turn_id=result.turn_id ): logger.debug( "ignoring foreign codex notification while draining " "server request: method=%s", pending.get("method"), ) continue proj, aborted = self._absorb_notification(result, projector, pending) if proj.is_tool_iteration: last_tool_completion_at = time.monotonic() if aborted: turn_complete = True self._handle_server_request(sreq) # An approval round-trip is live signal — don't let it trip the watchdog. last_tool_completion_at = None return turn_complete def on_note(note: dict, method: str) -> bool: nonlocal last_tool_completion_at projection, aborted = self._absorb_notification(result, projector, note) if projection.is_tool_iteration: last_tool_completion_at = time.monotonic() elif projection.messages or projection.final_text is not None: last_tool_completion_at = None if method != "turn/completed": return aborted turn_obj = (note.get("params") or {}).get("turn") or {} turn_status = turn_obj.get("status") if turn_status and turn_status not in {"completed", "interrupted"}: err_obj = turn_obj.get("error") if err_obj: err_msg = _format_responses_error(err_obj, str(turn_status)) self._set_classified_error( result, f"turn ended status={turn_status}", err_msg, err_msg ) return True self._drive_turn( result, turn_timeout=turn_timeout, notification_poll_timeout=notification_poll_timeout, timeout_label="turn", before_poll=watchdog_tripped, on_server_request=on_server_request, on_note=on_note, accept_final_text_at_deadline=True, ) with self._active_turn_lock: self._active_turn_id = None self._interrupt_event.clear() return result def _drive_turn( self, result: TurnResult, *, turn_timeout: float, notification_poll_timeout: float, timeout_label: str, on_server_request: Callable[[dict], bool], on_note: Callable[[dict, str], bool], before_poll: Optional[Callable[[], bool]] = None, pre_scope_filter: Optional[Callable[[dict, str], bool]] = None, accept_final_text_at_deadline: bool = False, ) -> None: """Shared poll loop for run_turn / compact_thread until turn/completed or deadline. Per iteration: interrupt -> subprocess death -> ``before_poll`` (watchdog) -> server requests (answered before notifications so codex isn't blocked) -> one notification, filtered by ``pre_scope_filter`` then by turn scope, handed to ``on_note``. Hooks return True to mark the turn complete / stop the loop. A deadline without completion interrupts and retires the session — a turn that never finished is a strong sign the next turn shouldn't inherit this codex. """ deadline = time.monotonic() + turn_timeout turn_complete = False while time.monotonic() < deadline and not turn_complete: if self._interrupt_event.is_set(): self._issue_interrupt(result.turn_id) result.interrupted = True break if self._subprocess_died(result): break if before_poll is not None and before_poll(): break sreq = self._client.take_server_request(timeout=0) if sreq is not None: if on_server_request(sreq): turn_complete = True continue note = self._client.take_notification(timeout=notification_poll_timeout) if note is None: continue method = note.get("method", "") if pre_scope_filter is not None and not pre_scope_filter(note, method): continue if not _notification_belongs_to_turn( note, thread_id=self._thread_id, turn_id=result.turn_id ): logger.debug("ignoring foreign codex notification: method=%s", method) continue if on_note(note, method): turn_complete = True if ( accept_final_text_at_deadline and not turn_complete and not result.interrupted and result.final_text and result.error is None ): logger.warning( "codex app-server turn reached deadline after a completed " "assistant message but before turn/completed; accepting " "the assistant text as the terminal response" ) turn_complete = True if not turn_complete and not result.interrupted: self._issue_interrupt(result.turn_id) result.interrupted = True if not result.error: result.error = self._format_error_with_stderr(f"{timeout_label} timed out after {turn_timeout}s") result.should_retire = True def compact_thread( self, *, turn_timeout: float = 600.0, notification_poll_timeout: float = 0.25, ) -> TurnResult: """Trigger Codex-native history compaction for the current thread. ``thread/compact/start`` returns immediately (with no turn id); progress streams through normal turn/item notifications, so we wait for the matching ``turn/completed`` to treat the return as a compaction boundary. """ result = TurnResult() if not self._start_for(result): return result self._interrupt_event.clear() projector = CodexEventProjector() if self._request_for( result, "thread/compact/start", {"threadId": self._thread_id}, "thread/compact/start" ) is None: return result def pre_scope_filter(note: dict, method: str) -> bool: if result.turn_id is not None: return True observed_thread_id, observed_turn_id = _notification_scope_ids(note) if method == "turn/started": if observed_thread_id is not None and str(observed_thread_id) != str(self._thread_id): logger.debug("ignoring foreign compact turn/started: thread=%s", observed_thread_id) return False if observed_turn_id is None: logger.debug("ignoring compact turn/started without a turn id") return False result.turn_id = str(observed_turn_id) elif observed_turn_id is not None or method in {"item/completed", "turn/completed"}: # Until the new turn/started arrives, terminal/projectable # events are stale or can't be attributed to this compaction. logger.debug("ignoring codex notification before compact turn start: method=%s", method) return False return True def on_note(note: dict, method: str) -> bool: _, aborted = self._absorb_notification(result, projector, note) if method not in {"turn/started", "turn/completed"}: return aborted turn_obj = (note.get("params") or {}).get("turn") or {} result.turn_id = turn_obj.get("id") or result.turn_id if method == "turn/started": return aborted turn_status = turn_obj.get("status") if turn_status == "interrupted": result.interrupted = True result.error = result.error or "compact turn interrupted" elif turn_status and turn_status != "completed": err_msg = _format_responses_error(turn_obj.get("error"), str(turn_status)) self._set_classified_error( result, f"compact turn ended status={turn_status}", err_msg, err_msg ) return True def on_server_request(sreq: dict) -> bool: self._handle_server_request(sreq) return False self._drive_turn( result, turn_timeout=turn_timeout, notification_poll_timeout=notification_poll_timeout, timeout_label="compact turn", on_server_request=on_server_request, on_note=on_note, pre_scope_filter=pre_scope_filter, ) return result 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: """Answer a codex server request (approval / elicitation) via Hermes' approval flow. Method names verified live against codex 0.130.0. Permission escalations are always declined: the user chose their profile in ~/.codex/config.toml. Unknown methods get a clean JSON-RPC error so codex doesn't hang. """ if self._client is None: return method = req.get("method", "") rid = req.get("id") params = req.get("params") or {} handler = self._SERVER_REQUEST_HANDLERS.get(method) if handler is None: logger.warning("Unknown codex server request: %s", method) self._client.respond_error(rid, code=-32601, message=f"Unsupported method: {method}") return self._client.respond(rid, handler(self, params)) def _respond_elicitation(self, params: dict) -> dict: """MCP elicitation (tool confirmation, OAuth, form data): auto-accept for our own hermes-tools server (the user opted in by enabling the runtime; it exposes nothing codex's shell can't already do); decline others so the user opts in via codex's own auth flow.""" action = "accept" if (params.get("serverName") or "") == "hermes-tools" else "decline" return {"action": action, "content": None, "_meta": None} _SERVER_REQUEST_HANDLERS: dict[str, Callable[..., dict]] = { "item/commandExecution/requestApproval": lambda self, p: {"decision": self._decide_exec_approval(p)}, "item/fileChange/requestApproval": lambda self, p: {"decision": self._decide_apply_patch_approval(p)}, "item/permissions/requestApproval": lambda self, p: {"decision": "decline"}, "mcpServer/elicitation/request": _respond_elicitation, } def _run_approval_callback(self, command: str, description: str, log_label: str) -> str: 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 %s", log_label) return "decline" def _decide_exec_approval(self, params: dict) -> str: """Decide a Codex exec approval request — protocol-level routing only. Hermes approval mode/timeout resolution lives upstream: codex_runtime.py derives ``auto_approve_exec`` from tools.approval, and the callback runs the shared approval gate. Do not re-read approval config here. """ if self._routing.auto_approve_exec: return "accept" if self._approval_callback is None: return "decline" # fail-closed when no callback wired # ``cwd`` is Optional on codex's side; fall back so the prompt is never empty. description = f"Codex requests exec in {params.get('cwd') or self._cwd or ''}" if params.get("reason"): description += f" — {params['reason']}" return self._run_approval_callback(params.get("command") or "", description, "exec request") def _decide_apply_patch_approval(self, params: dict) -> str: """Decide a Codex apply_patch approval request (routing only; see _decide_exec_approval).""" if self._routing.auto_approve_apply_patch: return "accept" if self._approval_callback is None: return "decline" # Params carry reason + grantRoot only; the changeset comes from the # fileChange item cached by _track_pending_file_change. reason = params.get("reason") grant_root = params.get("grantRoot") change_summary = self._pending_file_changes.get(params.get("itemId") or "") or None description_parts = [p for p in (reason, change_summary) if p] 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" ) return self._run_approval_callback(command_label, description, "apply_patch") def _track_pending_file_change(self, note: dict) -> None: """Maintain _pending_file_changes from item/started + item/completed so the apply_patch approval prompt can show what's actually changing.""" method = note.get("method", "") item = (note.get("params") or {}).get("item") or {} item_id = item.get("id") or "" if item.get("type") != "fileChange" or not item_id: return if method == "item/completed": self._pending_file_changes.pop(item_id, None) elif method == "item/started": raw_changes = item.get("changes") or [] if not raw_changes: self._pending_file_changes[item_id] = "1 change pending" return changes = [ch for ch in raw_changes if isinstance(ch, dict)] kinds: dict[str, int] = {} for ch in changes: kind = (ch.get("kind") or {}).get("type") or "update" kinds[kind] = kinds.get(kind, 0) + 1 paths: list[str] = [ch["path"] for ch in changes if ch.get("path")] 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 def _apply_token_usage_notification(result: TurnResult, note: dict) -> None: """Capture token usage: codex emits it as a separate thread/tokenUsage/updated notification (cumulative totals + last-turn breakdown), not on turn/completed.""" if not isinstance(note, dict) or note.get("method") != "thread/tokenUsage/updated": return token_usage = (note.get("params") or {}).get("tokenUsage") or {} if not isinstance(token_usage, dict): return last = token_usage.get("last") if isinstance(last, dict): result.token_usage_last = dict(last) window = token_usage.get("modelContextWindow") if isinstance(window, int) and window > 0: result.model_context_window = window def _apply_compaction_notification(result: TurnResult, note: dict) -> None: """Capture Codex-native compaction boundaries: a contextCompaction item (recent builds) or the deprecated thread/compacted notification (older builds).""" if not isinstance(note, dict): return method = note.get("method") or "" params = note.get("params") or {} if not isinstance(params, dict): return if method != "thread/compacted": item = params.get("item") if method in {"item/started", "item/completed"} else None if not isinstance(item, dict) or item.get("type") != "contextCompaction": return result.compacted = True result.thread_id = params.get("threadId") or result.thread_id result.turn_id = params.get("turnId") or result.turn_id # Hermes 'once'/'session'/'always'/'deny'(/'timeout') -> codex approval decisions # (codex-rs app-server-protocol v2). Only the wire translation lives here; the # approval mode/timeout resolution stays in tools/approval.py. "deny" and # "timeout" both decline — codex has no "prompt expired" wire value. _APPROVAL_CHOICE_TO_DECISION = {"once": "accept", "session": "acceptForSession", "always": "acceptForSession"} def _approval_choice_to_codex_decision(choice: str) -> str: """Map a Hermes approval choice onto codex's approval decision wire value.""" return _APPROVAL_CHOICE_TO_DECISION.get(choice, "decline") def _has_turn_aborted_marker(text: str) -> bool: """True if ``text`` carries a raw ```` marker (terminal without turn/completed).""" return bool(text) and any(marker in text for marker in _TURN_ABORTED_MARKERS) 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"