Files
2026-07-13 13:12:00 +08:00

6810 lines
260 KiB
Python

"""Forward Codex app-server notifications into Omnigent sessions."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from collections.abc import Callable
from contextvars import ContextVar
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import httpx
from omnigent._native_forwarder_health import (
note_post_success as note_native_post_success,
)
from omnigent._native_forwarder_health import (
record_post_failure as record_native_post_failure,
)
from omnigent._native_post_delivery import (
RepostResult,
append_dead_letter,
post_may_have_been_delivered,
replay_dead_letters,
)
from omnigent.claude_native_bridge import url_component
from omnigent.codex_native_app_server import (
CodexAppServerClient,
CodexMessage,
client_for_transport,
)
from omnigent.codex_native_bridge import (
CODEX_NATIVE_BRIDGE_ID_LABEL_KEY,
MCP_STARTUP_STARTING,
MCP_STARTUP_STATES,
CodexNativeBridgeState,
clear_active_turn_id_if_matches,
codex_home_for_bridge_dir,
pending_mcp_servers,
read_bridge_state,
read_codex_config_model,
read_mcp_startup,
settle_pending_mcp_startup,
update_active_turn_id,
update_mcp_server_startup,
update_thread_id,
write_bridge_state,
)
from omnigent.codex_native_elicitation import (
codex_elicitation_id,
)
from omnigent.codex_native_elicitation import (
is_codex_request_id as _is_codex_request_id,
)
from omnigent.entities.session_resources import terminal_resource_id
_logger = logging.getLogger(__name__)
_AGENT_NAME = "codex-native-ui"
_SUBSCRIBE_RETRY_DELAY_SECONDS = 0.2
# How long to wait for a freshly launched Codex TUI to create its
# app-server thread (emit ``thread/started``) before giving up. Generous
# because a host-spawned TUI cold-starts over the runner.
_THREAD_START_TIMEOUT_SECONDS = 30.0
_NO_ROLLOUT_FRAGMENT = "no rollout found for thread id"
# A freshly created thread passes through a second transient state: its rollout
# file exists but is still empty (the TUI created the thread but no turn has
# populated it yet), and ``thread/resume`` then fails with a thread-store
# "... rollout ... is empty" error. Treated as the same retryable not-ready
# state as a missing rollout. Acute with the fresh-launch host auto-create,
# whose listener races the TUI's just-created empty rollout.
_EMPTY_ROLLOUT_FRAGMENT = "is empty"
_POST_MAX_ATTEMPTS = 3
_POST_RETRY_DELAY_SECONDS = 0.1
_POST_RETRY_STATUS_CODES = frozenset({408, 409, 425, 429, 500, 502, 503, 504})
# Startup dead-letter replay budget (#1579). Bounded so a large dead-letter file
# or a slow/hung server cannot stall forwarder startup: each re-POST is a single
# attempt (its natural retry is the next startup) with a short timeout (vs the
# 30s live client default) so a hung server fails fast; at most
# ``_REPLAY_MAX_RECORDS`` are sent and the whole drain is abandoned after
# ``_REPLAY_DEADLINE_SECONDS``. Leftovers are deferred to a later startup.
_REPLAY_MAX_RECORDS = 500
_REPLAY_POST_TIMEOUT_SECONDS = 5.0
_REPLAY_DEADLINE_SECONDS = 30.0
_DELTA_FLUSH_INTERVAL_SECONDS = 0.05
_DELTA_FLUSH_CHAR_THRESHOLD = 64
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE = "external_reasoning_effort_change"
# Context-compaction progress edge. Publishes the same
# ``response.compaction.in_progress`` / ``response.compaction.completed`` SSE
# the AP-side compaction path emits, so the web UI shows its "Compacting
# conversation…" spinner while Codex compacts. Payload: ``{"status": ...}``.
_EXTERNAL_COMPACTION_STATUS_TYPE = "external_compaction_status"
# Codex ThreadItem type for a context compaction, and the thread-level
# notification Codex emits when compaction finishes. (Codex 5.1-Codex-Max+
# auto-compacts mid-turn.) Sourced from the Codex app-server protocol enums;
# handlers are harmless no-ops if a build spells these differently.
_CODEX_COMPACTION_ITEM_TYPE = "contextCompaction"
_CODEX_THREAD_COMPACTED_METHOD = "thread/compacted"
# Transient reasoning (chain-of-thought) delta — the reasoning analogue of
# ``external_output_text_delta``. Nothing is persisted; it publishes
# ``response.reasoning_text.delta`` (preceded by ``response.reasoning.started``
# when ``data.started`` is true) so the web UI paints a live reasoning block.
_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE = "external_output_reasoning_delta"
_EXTERNAL_CODEX_COLLABORATION_MODE_CHANGE_TYPE = "external_codex_collaboration_mode_change"
# Per-attempt client budget for the elicitation long-poll, slightly above
# the server-side wait (``_CODEX_NATIVE_ELICITATION_HOOK_TIMEOUT_S``) so
# the server's own timeout (empty-body fail-ask) wins over a client cut.
# Also reused as the total re-POST budget across severed long-polls.
_CODEX_ELICITATION_REQUEST_TIMEOUT_SECONDS = 86405.0
# Fail unreachable-server connects fast into the backoff loop instead of
# inheriting the day-long read budget.
_CODEX_ELICITATION_CONNECT_TIMEOUT_SECONDS = 30.0
# First retry must land inside the server's re-park grace (proxies sever
# idle long-polls); later retries back off.
_CODEX_ELICITATION_RETRY_INITIAL_BACKOFF_SECONDS = 1.0
_CODEX_ELICITATION_RETRY_MAX_BACKOFF_SECONDS = 30.0
_CODEX_MCP_ELICITATION_REQUEST_METHOD = "mcpServer/elicitation/request"
# Per-server MCP startup progress (issue #2058). Codex runs an MCP
# startup round when a thread starts, but delivers the per-server
# ``mcpServer/startupStatus/updated`` edges ONLY to the connection that
# owns the thread (the TUI) — verified against codex 0.142.5 — so this
# observer connection cannot passively mirror them. Instead the round is
# SYNTHESIZED: at forwarder start the config-declared servers are
# recorded as ``starting`` (true — codex boots them all at thread start)
# in the bridge dir and posted to Omnigent as ``external_mcp_startup``; the
# round is settled (unresolved entries dropped) when the thread goes
# idle after a turn — codex defers turn execution until startup ends, so
# an idle edge proves the round is over — or when the config-derived
# startup window elapses. ``cancelled`` states are recorded locally by
# the Stop path. The notification handler is kept as a zero-cost path
# for any delivery codex broadens later (it fully supersedes synthesis
# when edges do arrive).
_CODEX_MCP_STARTUP_STATUS_METHOD = "mcpServer/startupStatus/updated"
_CODEX_THREAD_STATUS_CHANGED_METHOD = "thread/status/changed"
_EXTERNAL_MCP_STARTUP_TYPE = "external_mcp_startup"
# Codex bounds each MCP server's spawn+handshake by its per-server
# ``startup_timeout_sec`` (codex default 10s); the round cannot outlive
# the slowest server's budget. The synthesis settle timer mirrors that
# bound, with floor/grace/cap keeping a misconfigured value sane.
_MCP_STARTUP_DEFAULT_TIMEOUT_SECONDS = 10.0
_MCP_STARTUP_SETTLE_GRACE_SECONDS = 15.0
_MCP_STARTUP_SETTLE_MAX_SECONDS = 240.0
_CODEX_TOOL_REQUEST_USER_INPUT_METHOD = "item/tool/requestUserInput"
_CODEX_COMMAND_EXECUTION_REQUEST_APPROVAL_METHOD = "item/commandExecution/requestApproval"
_CODEX_FILE_CHANGE_REQUEST_APPROVAL_METHOD = "item/fileChange/requestApproval"
_CODEX_PERMISSIONS_REQUEST_APPROVAL_METHOD = "item/permissions/requestApproval"
_CODEX_EXEC_COMMAND_APPROVAL_METHOD = "execCommandApproval"
_CODEX_APPLY_PATCH_APPROVAL_METHOD = "applyPatchApproval"
_CODEX_SERVER_REQUEST_RESOLVED_METHOD = "serverRequest/resolved"
_EXTERNAL_SESSION_INTERRUPTED_TYPE = "external_session_interrupted"
_EXTERNAL_ELICITATION_RESOLVED_TYPE = "external_elicitation_resolved"
# Codex AgentControl collab-agent spawn event fields.
_CODEX_COLLAB_AGENT_ITEM_TYPE = "collabAgentToolCall"
_CODEX_COLLAB_SPAWN_TOOL = "spawnAgent"
_CODEX_COLLAB_RUNNING_STATUSES = frozenset({"pendingInit", "running"})
_CODEX_COLLAB_FAILED_STATUSES = frozenset({"errored", "notFound"})
# Omnigent control event type sent when a Codex child thread is discovered.
_EXTERNAL_CODEX_SUBAGENT_START_TYPE = "external_codex_subagent_start"
_PLAN_IMPLEMENTATION_QUESTION_ID = "plan_implementation"
_PLAN_IMPLEMENTATION_TITLE = "Implement this plan?"
_PLAN_IMPLEMENTATION_YES = "Yes, implement this plan"
_PLAN_IMPLEMENTATION_CLEAR_CONTEXT = "Yes, clear context and implement"
_PLAN_IMPLEMENTATION_NO = "No, stay in Plan mode"
_PLAN_IMPLEMENTATION_CODING_MESSAGE = "Implement the plan."
_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX = (
"A previous agent produced the plan below to accomplish the user's task. "
"Implement the plan in a fresh context. Treat the plan as the source of "
"user intent, re-read files as needed, and carry the work through "
"implementation and verification."
)
_CODEX_ELICITATION_REQUEST_METHODS = frozenset(
{
_CODEX_MCP_ELICITATION_REQUEST_METHOD,
_CODEX_TOOL_REQUEST_USER_INPUT_METHOD,
_CODEX_COMMAND_EXECUTION_REQUEST_APPROVAL_METHOD,
_CODEX_FILE_CHANGE_REQUEST_APPROVAL_METHOD,
_CODEX_PERMISSIONS_REQUEST_APPROVAL_METHOD,
_CODEX_EXEC_COMMAND_APPROVAL_METHOD,
_CODEX_APPLY_PATCH_APPROVAL_METHOD,
}
)
# Turn-error surfacing. A failed Codex turn arrives as ``turn/completed``
# (or ``turn/failed``) with ``turn.status == "failed"`` and a ``turn.error``
# object ``{message, codexErrorInfo?, additionalDetails?}``; keying status off
# the method alone mapped such turns to ``idle`` — a "silent success". The
# forwarder inspects ``turn.status``/``turn.error``, forces ``failed``, and
# surfaces the reason. As a fallback it also catches an ``error`` ThreadItem in
# ``turn.items``: both shapes exist in the app-server type system and the wire
# shape varies by version, so detecting either keeps the fix robust.
#
# ``codexErrorInfo`` is the app-server's structured classification (e.g.
# ``unauthorized``, ``usage_limit_exceeded``); auth-class values get a re-auth
# hint. httpStatusCode 401/403 is treated as auth too. Values are stored and
# compared case-insensitively: the app-server enum serializes as lowercase
# snake_case (``unauthorized``), but older/alternate spellings (``Unauthorized``)
# are matched too.
_CODEX_ERROR_ITEM_TYPE = "error"
_CODEX_AUTH_ERROR_INFO = frozenset({"unauthorized"})
_CODEX_AUTH_HTTP_STATUS = frozenset({401, 403})
# Message-substring fallback for app-server versions that omit codexErrorInfo.
# Surface-only, so recall is favored over precision: a false positive only
# appends a re-auth hint to an already-failed turn.
_CODEX_AUTH_ERROR_FRAGMENTS = (
"401",
"403",
"unauthorized",
"authentication",
"not logged in",
"not authenticated",
"log in",
"login",
"sign in",
"re-authenticate",
"reauthenticate",
"credentials",
"access token",
"token expired",
"expired token",
"session expired",
"api key",
)
_CODEX_ERROR_KIND_AUTH = "auth"
_CODEX_ERROR_KIND_GENERIC = "generic"
_CODEX_REAUTH_HINT = "Codex needs you to re-authenticate. Run `codex login` and retry."
@dataclass
class _ForwarderTarget:
"""
Mutable AP/Codex target currently owned by the forwarder.
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
:param thread_id: Codex app-server thread id, e.g.
``"0196..."``.
:param delta_coalescer: Text-delta coalescer posting to
``session_id``.
:param usage_coalescer: Token-usage coalescer posting to
``session_id``.
:param elicitation_tracker: Background Codex elicitation hook
tracker posting to ``session_id``.
"""
session_id: str
thread_id: str
delta_coalescer: _OutputTextDeltaCoalescer
usage_coalescer: _SessionUsageCoalescer
elicitation_tracker: _CodexElicitationTaskTracker
@dataclass(frozen=True)
class _CodexToolCall:
"""
Normalized view of one completed Codex built-in tool call.
:param call_id: Codex item id reused as the Omnigent call id, e.g.
``"call_abc"``.
:param name: Omnigent function-call name, e.g. ``"shell"``.
:param arguments: Tool arguments dict, e.g. ``{"command": "pwd"}``.
:param output: Tool result text rendered as the
``function_call_output``, e.g. ``"/repo\n"``.
"""
call_id: str
name: str
arguments: dict[str, Any]
output: str
@dataclass
class _PartialTextBuffer:
"""
In-memory visible text collected from one streaming Codex item.
:param item_type: Codex item type, e.g. ``"agentMessage"``.
:param item_id: Codex item id, e.g. ``"item_abc123"``, or ``None``
when a delta omitted it.
:param parts: Ordered text fragments emitted for this item.
"""
item_type: str
item_id: str | None
parts: list[str] = field(default_factory=list)
def append(self, delta: str) -> None:
"""
Append one text fragment to the item buffer.
:param delta: Text fragment, e.g. ``"hel"``.
:returns: None.
"""
self.parts.append(delta)
def text(self) -> str:
"""
Return the concatenated item text.
:returns: Joined text fragments.
"""
return "".join(self.parts)
@dataclass
class _CodexForwarderState:
"""
Mutable state for one long-lived Codex forwarder connection.
:param model: Latest known Codex model for this thread, e.g.
``"gpt-5.2-codex"``.
:param posted_model: Last model already mirrored to Omnigent via an
``external_model_change`` post (the dedupe baseline). Seeded from
the resume/startup model so the spawn default is not echoed back as
a change; only a later in-TUI ``/model`` switch is mirrored. ``None``
until seeded.
:param effort: Latest known Codex reasoning effort for this thread, e.g.
``"medium"``. ``None`` means Codex is using its model/default effort.
:param posted_effort: Last reasoning effort already mirrored to Omnigent
via ``external_reasoning_effort_change``. ``None`` is a valid mirrored
value, so ``posted_effort_known`` tracks whether the baseline has been
seeded.
:param posted_effort_known: Whether ``posted_effort`` has been mirrored at
least once. Without this, the initial ``None`` default would be
indistinguishable from "not yet posted".
:param collaboration_mode: Latest known Codex collaboration mode kind, e.g.
``"plan"`` or ``"default"``.
:param posted_collaboration_mode: Last collaboration mode kind already
mirrored to Omnigent via
``external_codex_collaboration_mode_change``.
:param parent_session_id: Omnigent parent session id, e.g.
``"conv_parent"``. Set by ``supervise_forwarder`` so collab-agent
helpers can register child sessions without extra parameter
threading.
:param codex_client: Connected Codex app-server client. Set by
``supervise_forwarder`` so child backfill can issue
``thread/resume`` requests.
:param subagents_by_thread: Maps Codex child thread ids to Omnigent child
session ids, e.g. ``{"thread_child": "conv_child"}``.
:param pending_child_threads: Codex child thread ids announced by
``thread/started`` but not yet mapped to AP child sessions,
mapped to their spawning parent thread id when known, e.g.
``{"thread_child": "thread_parent"}``.
:param subscribed_child_threads: Codex child thread ids whose backlog
has been replayed for this connection (guards against re-replay
if the same collab item is observed multiple times).
:param synced_item_keys: Stable item keys already posted to Omnigent this
connection, e.g. ``{"thread_c:turn_c:item-1"}``. In-memory only;
guards replay-vs-live overlap within one forwarder lifetime.
:param posted_user_turns: Turn ids whose ``userMessage`` has been
posted to Omnigent this connection, e.g. ``{"turn_123"}``. Used to
enforce user-before-assistant ordering: before posting a turn's
assistant reply, the forwarder recovers and posts the turn's user
message if the live stream missed it (see
:func:`_ensure_user_message_posted`).
:param partial_text_by_turn: Visible assistant/plan text fragments keyed
by turn id, e.g. ``{"turn_123": [_PartialTextBuffer(...)]}``.
Normal completed items remain the durable source of truth; this
buffer is only consumed when Codex reports an interrupted turn with no
completed item for the streamed text.
:param _anon_item_counters: Per-(thread, turn) counters used to
assign deterministic positional keys to items that lack a stable
``id`` field.
:param completed_plan_text_by_turn: Completed proposed-plan text
keyed by turn id.
:param plan_thread_by_turn: Codex thread id keyed by plan turn id.
:param prompted_plan_turns: Turn ids that already exposed the
implementation prompt, either natively or through the Omnigent bridge.
:param turn_diff_by_turn: Latest aggregated working-tree unified diff
seen for a turn, keyed by turn id. Codex emits ``turn/diff/updated``
repeatedly as edits land; only the newest diff is kept and it is
flushed once at the terminal turn boundary (see
:func:`_handle_turn_diff_updated` / :func:`_flush_turn_diff`).
"""
model: str | None = None
posted_model: str | None = None
effort: str | None = None
posted_effort: str | None = None
posted_effort_known: bool = False
collaboration_mode: str | None = None
posted_collaboration_mode: str | None = None
parent_session_id: str | None = None
codex_client: CodexAppServerClient | None = None
subagents_by_thread: dict[str, str] = field(default_factory=dict)
pending_child_threads: dict[str, str | None] = field(default_factory=dict)
subscribed_child_threads: set[str] = field(default_factory=set)
synced_item_keys: set[str] = field(default_factory=set)
posted_user_turns: set[str] = field(default_factory=set)
partial_text_by_turn: dict[str, list[_PartialTextBuffer]] = field(default_factory=dict)
_anon_item_counters: dict[tuple[str, str], int] = field(default_factory=dict)
completed_plan_text_by_turn: dict[str, str] = field(default_factory=dict)
plan_thread_by_turn: dict[str, str] = field(default_factory=dict)
prompted_plan_turns: set[str] = field(default_factory=set)
# Last context-compaction status mirrored to Omnigent
# (``"in_progress"`` / ``"completed"``), used to dedupe consecutive
# identical posts when Codex signals completion via both a
# ``contextCompaction`` item and a ``thread/compacted`` notification.
compaction_status_posted: str | None = None
# Whether the compaction item has already been persisted for the current
# compaction boundary. Reset to ``False`` when a new ``"in_progress"``
# status is posted.
compaction_item_persisted: bool = False
# Codex reasoning item id whose live deltas are currently being mirrored.
# When a delta arrives for a different item, it opens a new reasoning
# block (``started=True`` → ``response.reasoning.started``). Reset at each
# ``turn/started`` so the next turn's first reasoning delta opens a fresh
# block. Reasoning is transient — it has no completed conversation item;
# the block finalizes when the turn's assistant message arrives.
reasoning_stream_item_id: str | None = None
turn_diff_by_turn: dict[str, str] = field(default_factory=dict)
def note_resume_response(self, response: CodexMessage) -> None:
"""
Record thread settings returned by ``thread/resume``.
:param response: Codex JSON-RPC response envelope.
:returns: None.
"""
result = response.get("result")
if not isinstance(result, dict):
return
self._note_model_fields(result)
# Do NOT seed ``posted_model`` here. Omnigent must learn the session's
# ACTUAL model — including the spawn default — because the cost-budget
# gate resolves the model as ``conv.model_override or spec.llm.model``,
# and for codex the spawn model (read from ``config.toml`` / the
# ``--model`` flag) is frequently NOT ``spec.llm.model``. If we seeded
# the baseline to the spawn model, an unchanged session would never
# post ``external_model_change``, ``model_override`` would stay
# ``None``, and the gate would mis-resolve a cheap session as the
# (possibly expensive/absent) spec model and wrongly DENY it. Leaving
# ``posted_model`` ``None`` makes the first ``_sync_model_change``
# mirror the real model; the dedupe still suppresses re-posts after.
def note_thread_settings_updated(self, params: dict[str, Any]) -> None:
"""
Record thread settings from a ``thread/settings/updated`` notification.
:param params: Codex notification params.
:returns: None.
"""
settings = params.get("threadSettings")
if isinstance(settings, dict):
self._note_model_fields(settings)
self._note_effort_fields(settings)
self._note_collaboration_mode_fields(settings)
def record_completed_plan(self, params: dict[str, Any]) -> None:
"""
Remember a completed Codex proposed-plan item for its terminal prompt.
:param params: Codex ``item/completed`` params.
:returns: None.
"""
item = params.get("item")
if not isinstance(item, dict) or item.get("type") != "plan":
return
turn_id = _turn_id_from_payload(params)
thread_id = params.get("threadId")
text = item.get("text")
if not (
isinstance(turn_id, str)
and turn_id
and isinstance(thread_id, str)
and thread_id
and isinstance(text, str)
and text.strip()
):
return
self.completed_plan_text_by_turn[turn_id] = text
self.plan_thread_by_turn[turn_id] = thread_id
def mark_prompted(self, turn_id: str) -> None:
"""
Mark a plan turn as having exposed its implementation prompt.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:returns: None.
"""
self.prompted_plan_turns.add(turn_id)
def plan_prompt_context(self, turn_id: str) -> tuple[str, str] | None:
"""
Return plan text and thread id for a not-yet-prompted turn.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:returns: ``(thread_id, plan_text)`` or ``None``.
"""
if turn_id in self.prompted_plan_turns:
return None
plan_text = self.completed_plan_text_by_turn.get(turn_id)
thread_id = self.plan_thread_by_turn.get(turn_id)
if not plan_text or not thread_id:
return None
return thread_id, plan_text
def session_for_child_thread(self, thread_id: str) -> str | None:
"""
Return the Omnigent child session id for a known Codex child thread.
:param thread_id: Codex child thread id, e.g. ``"thread_child"``.
:returns: Omnigent child session id, e.g. ``"conv_child"``, or ``None``
when the thread is unknown.
"""
return self.subagents_by_thread.get(thread_id)
def note_child_thread(self, thread_id: str, session_id: str) -> None:
"""
Record the Omnigent child session id for a Codex child thread.
:param thread_id: Codex child thread id, e.g. ``"thread_child"``.
:param session_id: Omnigent child session id, e.g. ``"conv_child"``.
:returns: None.
"""
self.subagents_by_thread[thread_id] = session_id
self.pending_child_threads.pop(thread_id, None)
def note_parent_rotation(self, session_id: str) -> None:
"""
Record that the forwarder moved to a new parent AP session.
:param session_id: New parent AP session id, e.g.
``"conv_new_parent"``.
:returns: None.
"""
self.parent_session_id = session_id
self.pending_child_threads.clear()
def note_pending_child_thread(
self,
thread_id: str,
parent_thread_id: str | None,
) -> None:
"""
Record a Codex child thread before its AP child session exists.
:param thread_id: Codex child thread id announced by
``thread/started``, e.g. ``"thread_child"``.
:param parent_thread_id: Codex parent thread id recorded in
``source.subAgent.thread_spawn.parent_thread_id``, e.g.
``"thread_parent"``. ``None`` when Codex omitted it.
:returns: None.
"""
if thread_id not in self.subagents_by_thread:
self.pending_child_threads[thread_id] = parent_thread_id
def is_pending_child_thread(
self,
thread_id: str,
parent_thread_id: str | None,
) -> bool:
"""
Return whether a thread is an announced-but-unregistered child.
:param thread_id: Codex thread id, e.g. ``"thread_child"``.
:param parent_thread_id: Active parent thread id to match, e.g.
``"thread_parent"``.
:returns: ``True`` when the thread was proven to be a child
by ``source.subAgent.thread_spawn`` metadata but has no AP
child session mapping yet, and the recorded parent matches.
"""
recorded_parent_thread_id = self.pending_child_threads.get(thread_id)
if recorded_parent_thread_id is None:
return thread_id in self.pending_child_threads
return recorded_parent_thread_id == parent_thread_id
def needs_child_thread_backfill(self, thread_id: str) -> bool:
"""
Return whether a child thread's backlog should be replayed.
:param thread_id: Codex child thread id, e.g. ``"thread_child"``.
:returns: ``True`` until the child has been subscribed this connection.
"""
return thread_id not in self.subscribed_child_threads
def note_child_thread_subscribed(self, thread_id: str) -> None:
"""
Record that a child thread's backlog was replayed this connection.
:param thread_id: Codex child thread id, e.g. ``"thread_child"``.
:returns: None.
"""
self.subscribed_child_threads.add(thread_id)
def note_user_message_posted(self, turn_id: str) -> None:
"""
Record that a turn's user message has been posted to AP.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:returns: None.
"""
self.posted_user_turns.add(turn_id)
def has_posted_user_message(self, turn_id: str) -> bool:
"""
Return whether a turn's user message was already posted to AP.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:returns: ``True`` when the turn's user message has been posted.
"""
return turn_id in self.posted_user_turns
def record_partial_text_delta(
self,
*,
turn_id: str,
item_type: str,
item_id: str | None,
delta: str,
) -> None:
"""
Remember one visible text delta for possible interrupted-turn durability.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:param item_type: Codex item type, e.g. ``"agentMessage"``.
:param item_id: Codex item id, e.g. ``"item_abc123"``, or
``None`` when omitted.
:param delta: Text fragment, e.g. ``"hel"``.
:returns: None.
"""
buffers = self.partial_text_by_turn.setdefault(turn_id, [])
for buffer in buffers:
if buffer.item_type == item_type and buffer.item_id == item_id:
buffer.append(delta)
return
buffer = _PartialTextBuffer(item_type=item_type, item_id=item_id)
buffer.append(delta)
buffers.append(buffer)
def discard_partial_text_item(
self,
*,
turn_id: str,
item_type: str,
item_id: str | None,
) -> None:
"""
Drop buffered deltas for an item whose completed record was observed.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:param item_type: Codex item type, e.g. ``"agentMessage"``.
:param item_id: Codex item id, e.g. ``"item_abc123"``, or
``None`` when omitted.
:returns: None.
"""
buffers = self.partial_text_by_turn.get(turn_id)
if not buffers:
return
remaining = [
buffer
for buffer in buffers
if not (buffer.item_type == item_type and buffer.item_id == item_id)
]
if remaining:
self.partial_text_by_turn[turn_id] = remaining
else:
self.partial_text_by_turn.pop(turn_id, None)
def consume_partial_text_for_turn(self, turn_id: str) -> list[_PartialTextBuffer]:
"""
Remove and return buffered visible text for one turn.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:returns: Ordered partial-text buffers for the turn.
"""
return self.partial_text_by_turn.pop(turn_id, [])
def note_turn_diff(self, turn_id: str, diff: str) -> None:
"""
Record the latest aggregated working-tree diff for a turn.
Codex emits ``turn/diff/updated`` repeatedly as a turn's edits
accumulate, each carrying the full diff so far. Only the newest
diff is retained; an empty diff clears any stored value so a turn
whose edits were reverted does not flush a stale diff.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:param diff: Aggregated unified diff for the turn so far.
:returns: None.
"""
if diff:
self.turn_diff_by_turn[turn_id] = diff
else:
self.turn_diff_by_turn.pop(turn_id, None)
def consume_turn_diff(self, turn_id: str) -> str | None:
"""
Remove and return the stored aggregated diff for one turn.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:returns: The newest aggregated diff for the turn, or ``None`` when
none was recorded.
"""
return self.turn_diff_by_turn.pop(turn_id, None)
def claim_item_key(self, item_key: str) -> bool:
"""
Claim a transcript item key for Omnigent posting.
Returns ``True`` when the caller should post the item. Returns
``False`` when the key was already posted this connection, so the
caller should skip it and avoid a duplicate write.
:param item_key: Stable dedup key, e.g.
``"thread_c:turn_c:item-1"``.
:returns: ``True`` when the item should be posted.
"""
if item_key in self.synced_item_keys:
_logger.info("Codex forwarder skipped duplicate item: key=%s", item_key)
return False
self.synced_item_keys.add(item_key)
return True
def peek_anon_item_key(self, thread_id: str, turn_id: str) -> str:
"""
Return the current positional key for an anonymous (no-id) item.
Reads but does NOT advance the counter. Use ``advance_anon_counter``
after a successful ``claim_item_key`` to mark the slot consumed.
Two calls without an intervening advance return the same key, which
is what dedup requires: replay and live deliveries of the same
anonymous item must produce the same key so the second delivery
is correctly dropped.
:param thread_id: Codex thread id, e.g. ``"thread_123"``.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:returns: Positional dedup key, e.g.
``"thread_123:turn_123:anon-0"``.
"""
scope = (thread_id, turn_id)
idx = self._anon_item_counters.get(scope, 0)
return f"{thread_id}:{turn_id}:anon-{idx}"
def advance_anon_counter(self, thread_id: str, turn_id: str) -> None:
"""
Advance the anonymous item counter for a (thread, turn) scope.
Called after ``claim_item_key`` succeeds for an anonymous item so
the next anonymous item in the same turn gets a fresh key.
:param thread_id: Codex thread id, e.g. ``"thread_123"``.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:returns: None.
"""
scope = (thread_id, turn_id)
self._anon_item_counters[scope] = self._anon_item_counters.get(scope, 0) + 1
def _note_model_fields(self, payload: dict[str, Any]) -> None:
"""
Record model from a Codex settings-like payload.
:param payload: Payload with ``model``.
:returns: None.
"""
model = payload.get("model")
if isinstance(model, str) and model:
self.model = model
def _note_effort_fields(self, payload: dict[str, Any]) -> None:
"""
Record reasoning effort from a Codex settings-like payload.
App-server's public ``ThreadSettings`` wire field is ``effort``. The
other two names are accepted because upstream docs and lower-level
snapshots use ``reasoningEffort`` / ``reasoning_effort`` when referring
to the same concept.
:param payload: Settings payload with ``effort`` or an equivalent
reasoning-effort key, e.g. ``{"effort": "medium"}``.
:returns: None.
"""
for key in ("effort", "reasoningEffort", "reasoning_effort"):
if key not in payload:
continue
effort = payload[key]
if effort is None or (isinstance(effort, str) and effort):
self.effort = effort
return
def _note_collaboration_mode_fields(self, payload: dict[str, Any]) -> None:
"""
Record Codex collaboration mode from a settings-like payload.
:param payload: Settings payload with ``collaborationMode``, e.g.
``{"collaborationMode": {"mode": "plan", "settings": {...}}}``.
:returns: None.
"""
raw_mode = payload.get("collaborationMode")
if not isinstance(raw_mode, dict):
raw_mode = payload.get("collaboration_mode")
if not isinstance(raw_mode, dict):
return
mode = raw_mode.get("mode")
if isinstance(mode, str) and mode:
self.collaboration_mode = mode
@dataclass(frozen=True)
class _CodexTerminalError:
"""
A turn-level failure surfaced from a Codex turn.
Produced by :func:`_terminal_error_from_turn` from ``turn.error`` or an
``error`` ThreadItem. Forces the turn's Omnigent status to ``failed`` and
lets :func:`_post_turn_status_edge` surface the reason (and a re-auth hint
for auth-classified errors).
:param message: Human-readable error text, e.g.
``"401 Unauthorized: ChatGPT login expired"``.
:param kind: Classification, either ``"auth"`` or ``"generic"``.
"""
message: str
kind: str
@property
def is_auth(self) -> bool:
""":returns: ``True`` when the error was classified as auth-related."""
return self.kind == _CODEX_ERROR_KIND_AUTH
def _classify_codex_error(error: dict[str, Any], message: str) -> str:
"""
Classify a Codex ``turn.error`` / ``error`` item as auth-related or generic.
Prefers the structured ``codexErrorInfo`` (an ``unauthorized`` variant,
case-insensitive, or an httpStatusCode of 401/403); falls back to substring
matching against :data:`_CODEX_AUTH_ERROR_FRAGMENTS` for versions/shapes
that omit it.
:param error: The ``turn.error`` object.
:param message: Its already-extracted message text.
:returns: :data:`_CODEX_ERROR_KIND_AUTH` or
:data:`_CODEX_ERROR_KIND_GENERIC`.
"""
info = error.get("codexErrorInfo")
variant: str | None = None
http_status: Any = None
if isinstance(info, str):
variant = info
elif isinstance(info, dict):
variant = info.get("type") or info.get("kind") or info.get("variant")
http_status = info.get("httpStatusCode")
variant_is_auth = variant is not None and variant.lower() in _CODEX_AUTH_ERROR_INFO
if variant_is_auth or http_status in _CODEX_AUTH_HTTP_STATUS:
return _CODEX_ERROR_KIND_AUTH
lowered = message.lower()
if any(fragment in lowered for fragment in _CODEX_AUTH_ERROR_FRAGMENTS):
return _CODEX_ERROR_KIND_AUTH
return _CODEX_ERROR_KIND_GENERIC
def _error_payload_message(payload: dict[str, Any]) -> str:
"""
Extract a non-empty message from a Codex ``turn.error`` or ``error`` item.
Both shapes have surfaced the text under a few keys across app-server
versions; reads the first non-empty one, falling back to a stable string
so the surfaced error is never blank.
:param payload: A ``turn.error`` object or an ``error`` ThreadItem.
:returns: Non-empty error text.
"""
for key in ("message", "error", "text", "detail"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return "Codex turn ended with an unspecified error."
def _error_item_from_turn(turn: dict[str, Any]) -> dict[str, Any] | None:
"""
Return the first ``error`` ThreadItem in ``turn.items``, if any.
:param turn: A Codex turn object.
:returns: The first item whose ``type`` is :data:`_CODEX_ERROR_ITEM_TYPE`,
or ``None``.
"""
items = turn.get("items")
if not isinstance(items, list):
return None
for item in items:
if isinstance(item, dict) and item.get("type") == _CODEX_ERROR_ITEM_TYPE:
return item
return None
def _terminal_error_from_turn(params: dict[str, Any]) -> _CodexTerminalError | None:
"""
Return the turn-level failure carried by a Codex turn, if any.
Prefers ``turn.error`` (the protocol's ``TurnError`` on a failed turn) and
falls back to an ``error`` ThreadItem in ``turn.items`` — both shapes exist
in the app-server type system and the wire shape varies by version. Single
source of truth reused by the live terminal edge and the ``thread/resume``
parity path.
:param params: Codex turn params, e.g. a ``turn/completed`` payload or a
single ``thread/resume`` turn wrapped as ``{"turn": <turn>}``.
:returns: The classified terminal error, or ``None`` when the turn did not
fail.
"""
turn = params.get("turn")
if not isinstance(turn, dict):
return None
payload = turn.get("error")
if not isinstance(payload, dict):
payload = _error_item_from_turn(turn)
if payload is None:
return None
message = _error_payload_message(payload)
return _CodexTerminalError(message=message, kind=_classify_codex_error(payload, message))
@dataclass(frozen=True)
class _CodexTurnStatusEdge:
"""
Omnigent session-status edge derived from Codex turn lifecycle state.
:param status: Omnigent session status, e.g. ``"running"`` or ``"idle"``.
:param turn_id: Codex turn id that caused the edge, e.g.
``"turn_abc123"``.
:param source: Lifecycle source that produced the edge, e.g.
``"turn/started"``.
:param error: Turn-level error forcing this edge to ``failed``,
or ``None`` for ordinary lifecycle edges. Surfaced as the status
output by :func:`_post_turn_status_edge`.
"""
status: str
turn_id: str | None
source: str
error: _CodexTerminalError | None = None
# Codex ``item/completed`` item types that represent a built-in tool call.
# Each maps to a builder that extracts a normalized :class:`_CodexToolCall`.
# ``_TOOL_ITEM_BUILDERS`` is populated after the builders are defined.
_ToolItemBuilder = Callable[[str, dict[str, Any]], "_CodexToolCall | None"]
@dataclass(frozen=True)
class _DeltaChunk:
"""
One queued text delta with optional stream identity.
:param message_id: Stable native message stream id, e.g.
``"codex:thread_123:turn_123:agentMessage:item_agent"``, or
``None`` for generic unscoped deltas.
:param delta: Text fragment, e.g. ``"hel"``.
"""
message_id: str | None
delta: str
@dataclass(frozen=True)
class _DeltaFlushBarrier:
"""
Queue marker that asks the delta worker to flush buffered text.
:param done: Future completed after all preceding buffered deltas
have been posted to AP.
"""
done: asyncio.Future[None]
@dataclass(frozen=True)
class _DeltaFlushStop:
"""
Queue marker that asks the delta worker to flush and exit.
:param done: Future completed after the worker has flushed all
buffered deltas and stopped.
"""
done: asyncio.Future[None]
class _OutputTextDeltaCoalescer:
"""
Coalesce high-frequency Codex text deltas before posting to AP.
Codex can emit many tiny ``item/agentMessage/delta`` notifications.
Posting each one through Omnigent as an awaited HTTP request makes the
forwarder drain behind Codex. This worker keeps event ingestion
cheap while preserving the order of flushed text relative to
explicit flush barriers.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param flush_interval_seconds: Maximum time to hold the first
buffered delta before posting it.
:param flush_char_threshold: Maximum buffered character count before
posting immediately.
"""
def __init__(
self,
client: httpx.AsyncClient,
session_id: str,
*,
flush_interval_seconds: float = _DELTA_FLUSH_INTERVAL_SECONDS,
flush_char_threshold: int = _DELTA_FLUSH_CHAR_THRESHOLD,
) -> None:
"""
Initialize the coalescer.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param flush_interval_seconds: Maximum buffering delay in
seconds, e.g. ``0.05``.
:param flush_char_threshold: Character threshold that triggers
an immediate flush, e.g. ``64``.
"""
self._client = client
self._session_id = session_id
self._flush_interval_seconds = flush_interval_seconds
self._flush_char_threshold = flush_char_threshold
self._queue: asyncio.Queue[_DeltaChunk | _DeltaFlushBarrier | _DeltaFlushStop] = (
asyncio.Queue()
)
self._worker_task: asyncio.Task[None] | None = None
self._next_index_by_message_id: dict[str, int] = {}
async def append(self, delta: str, *, message_id: str | None = None) -> None:
"""
Queue one assistant text delta for coalesced delivery.
:param delta: Assistant text fragment, e.g. ``"hel"``.
:param message_id: Optional stable native message stream id,
e.g. ``"codex:thread_123:turn_123:agentMessage:item_agent"``.
:returns: None.
"""
if not delta:
return
self._ensure_worker()
self._queue.put_nowait(_DeltaChunk(message_id=message_id, delta=delta))
async def flush(self) -> None:
"""
Flush all deltas queued before this call.
:returns: None after all earlier deltas have been posted.
"""
if self._worker_task is None:
return
loop = asyncio.get_running_loop()
done: asyncio.Future[None] = loop.create_future()
self._queue.put_nowait(_DeltaFlushBarrier(done=done))
await done
async def close(self) -> None:
"""
Flush pending deltas and stop the background worker.
:returns: None after the worker has stopped.
"""
if self._worker_task is None:
return
loop = asyncio.get_running_loop()
done: asyncio.Future[None] = loop.create_future()
self._queue.put_nowait(_DeltaFlushStop(done=done))
await done
await self._worker_task
self._worker_task = None
def _ensure_worker(self) -> None:
"""
Start the background worker if it is not already running.
:returns: None.
"""
if self._worker_task is None:
self._worker_task = asyncio.create_task(
self._run(),
name="codex-native-delta-coalescer",
)
async def _run(self) -> None:
"""
Drain queued deltas and flush barriers in FIFO order.
:returns: None after a stop marker is processed.
"""
buffer: list[str] = []
buffer_message_id: str | None = None
buffered_chars = 0
flush_deadline: float | None = None
loop = asyncio.get_running_loop()
while True:
timeout = None
if buffer and flush_deadline is not None:
timeout = max(0.0, flush_deadline - loop.time())
try:
item = await asyncio.wait_for(self._queue.get(), timeout=timeout)
except TimeoutError:
await self._flush_buffer(buffer, message_id=buffer_message_id)
buffer = []
buffer_message_id = None
buffered_chars = 0
flush_deadline = None
continue
if isinstance(item, _DeltaChunk):
if buffer and item.message_id != buffer_message_id:
await self._flush_buffer(buffer, message_id=buffer_message_id)
buffer = []
buffer_message_id = None
buffered_chars = 0
flush_deadline = None
if not buffer:
flush_deadline = loop.time() + self._flush_interval_seconds
buffer_message_id = item.message_id
buffer.append(item.delta)
buffered_chars += len(item.delta)
if "\n" in item.delta or buffered_chars >= self._flush_char_threshold:
await self._flush_buffer(buffer, message_id=buffer_message_id)
buffer = []
buffer_message_id = None
buffered_chars = 0
flush_deadline = None
continue
if isinstance(item, _DeltaFlushBarrier):
await self._flush_buffer(buffer, message_id=buffer_message_id)
buffer = []
buffer_message_id = None
buffered_chars = 0
flush_deadline = None
item.done.set_result(None)
continue
await self._flush_buffer(buffer, message_id=buffer_message_id)
item.done.set_result(None)
return
async def _flush_buffer(self, buffer: list[str], *, message_id: str | None) -> None:
"""
Post a non-empty coalesced delta buffer to AP.
:param buffer: Buffered text fragments, e.g. ``["hel", "lo"]``.
:param message_id: Stable native message stream id for the
buffer, e.g. ``"codex:thread_123:turn_123:agentMessage:item"``.
:returns: None.
"""
if not buffer:
return
delta = "".join(buffer)
index: int | None = None
final: bool | None = None
if message_id is not None:
index = self._next_index_by_message_id.get(message_id, 0)
self._next_index_by_message_id[message_id] = index + 1
final = False
try:
await _post_output_text_delta(
self._client,
self._session_id,
delta,
message_id=message_id,
index=index,
final=final,
)
except Exception: # noqa: BLE001 - preserve the long-lived forwarder.
_logger.warning("Codex forwarder delta flush failed", exc_info=True)
class _SessionUsageCoalescer:
"""
Coalesce Codex token-usage updates before posting to AP.
Codex can emit ``thread/tokenUsage/updated`` while assistant text
is still streaming. This coalescer records only the latest values
(latest-only, deduped) so repeated frames collapse to one post. The
caller flushes it per usage frame (so the web UI cost badge updates
live mid-turn) and again at turn/session boundaries (a no-op when
nothing changed).
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
"""
def __init__(
self,
client: httpx.AsyncClient,
session_id: str,
model: str | None = None,
) -> None:
"""
Initialize the usage coalescer.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param model: Model name to attach to token posts, e.g. ``"gpt-5.5"``.
Needed for child coalescers, created where ``forwarder_state`` is
``None`` and ``record()`` receives no model — without it the server
cannot price the child's cumulative tokens. ``None`` for the parent
coalescer, which learns its model via :meth:`record`.
:returns: None.
"""
self._client = client
self._session_id = session_id
self._pending: dict[str, int] = {}
self._last_posted: dict[str, int] = {}
self._model: str | None = model
def record(self, params: dict[str, Any], model: str | None = None) -> None:
"""
Record the latest usage values from one Codex notification.
:param params: Codex ``thread/tokenUsage/updated`` params.
:param model: Latest known Codex model for this thread, e.g.
``"gpt-5.1-codex"``. Retained so :meth:`flush` can attach it
to every token post; the server needs it to price cumulative
tokens into ``total_cost_usd``. ``None`` leaves the prior
value unchanged (Codex sends usage and settings separately,
so a usage frame on its own carries no model).
:returns: None.
"""
if model:
self._model = model
data = _session_usage_data_from_params(params)
if data is None:
return
self._pending.update(data)
async def flush(self) -> None:
"""
Post changed pending usage values to AP.
:returns: None after the pending usage update has been
attempted.
"""
if not self._pending:
return
data = {
key: value
for key, value in self._pending.items()
if self._last_posted.get(key) != value
}
if not data:
self._pending.clear()
return
# Attach the model to every token-bearing post (not via the
# changed-keys dedup, so it rides along even when only token
# counts changed) — the server reprices cumulative tokens into
# ``total_cost_usd`` per turn and needs the model each time.
payload: dict[str, Any] = dict(data)
if self._model:
payload["model"] = self._model
response = await _post_session_event(
self._client,
self._session_id,
event_type="external_session_usage",
data=payload,
)
_log_failed_session_event_post("external_session_usage", response)
if response is not None and response.status_code < 400:
self._last_posted.update(data)
self._pending.clear()
async def close(self) -> None:
"""
Flush pending usage updates.
:returns: None after the final usage flush has been attempted.
"""
await self.flush()
@dataclass(frozen=True)
class _PendingCodexElicitation:
"""
Background Omnigent hook wait for one Codex server-to-client request.
:param thread_id: Codex thread id from the request params, e.g.
``"thread_abc123"``. ``None`` when the request did not carry
thread scope.
:param turn_id: Codex turn id from the request params, e.g.
``"turn_abc123"``. ``None`` when the request did not carry turn
scope.
:param request_id: Codex JSON-RPC request id, e.g. ``12``.
:param elicitation_id: Omnigent elicitation id, e.g.
``"elicit_codex_abc123"``.
"""
thread_id: str | None
turn_id: str | None
request_id: int | str
elicitation_id: str
class _CodexElicitationTaskTracker:
"""
Run Codex elicitation hook waits off the event-drain path.
A real Codex TUI can answer a server-to-client request before the
Omnigent web/REPL hook does. If the forwarder awaits the Omnigent hook inline,
it stops draining app-server events and the web UI sees a stuck
approval card until the hook timeout. This tracker lets the hook
wait in the background and resolves it once the app-server emits the
exact ``serverRequest/resolved`` notification for the same request id.
"""
def __init__(self) -> None:
"""
Initialize an empty pending-task tracker.
:returns: None.
"""
self._pending: dict[asyncio.Task[None], _PendingCodexElicitation] = {}
self._posted_resolutions: set[str] = set()
def start(
self,
client: httpx.AsyncClient,
codex_client: CodexAppServerClient,
*,
session_id: str,
event: CodexMessage,
) -> None:
"""
Start one Omnigent hook bridge in the background.
:param client: HTTP client for Omnigent hook posts.
:param codex_client: Connected Codex app-server client used
to send JSON-RPC results.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param event: Codex JSON-RPC request envelope.
:returns: None.
"""
params = event.get("params")
params = params if isinstance(params, dict) else {}
method = event.get("method")
request_id = event.get("id")
if not isinstance(method, str) or not _is_codex_request_id(request_id):
_logger.warning("Codex forwarder cannot track malformed elicitation request")
return
task = asyncio.create_task(
self._run_one(
client,
codex_client,
session_id=session_id,
event=event,
),
name="codex-native-elicitation-hook",
)
self._pending[task] = _PendingCodexElicitation(
thread_id=_thread_id_from_params(params),
turn_id=_turn_id_from_payload(params.get("turn")) or _turn_id_from_payload(params),
request_id=request_id,
elicitation_id=codex_elicitation_id(
session_id,
method,
request_id,
),
)
task.add_done_callback(self._discard_done)
async def resolve_by_server_notification(
self,
client: httpx.AsyncClient,
*,
session_id: str,
params: dict[str, Any],
) -> None:
"""
Mark the hook wait resolved by Codex's explicit notification.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: ``serverRequest/resolved`` params, e.g.
``{"threadId": "thread_abc", "requestId": 12}``.
:returns: None.
"""
request_id = params.get("requestId")
thread_id = _thread_id_from_params(params)
for _task, pending in list(self._pending.items()):
if _pending_elicitation_matches_resolution(
pending,
request_id=request_id,
thread_id=thread_id,
):
await self._post_resolved_once(client, session_id, pending)
return
async def resolve_by_terminal_turn_event(
self,
client: httpx.AsyncClient,
*,
session_id: str,
params: dict[str, Any],
) -> None:
"""
Clear pending hook waits after Codex accepts a terminal turn.
This is a conservative fallback for a missed
``serverRequest/resolved`` notification. Codex documents
``turn/completed`` as the terminal lifecycle event, including
interrupted and failed turns, and terminal cleanup implies the
app-server no longer has live server-to-client requests for that
turn.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex ``turn/completed`` params, e.g.
``{"threadId": "thread_abc", "turn": {"id": "turn_abc"}}``.
:returns: None.
"""
thread_id = _thread_id_from_params(params)
turn_id = _turn_id_from_payload(params.get("turn")) or _turn_id_from_payload(params)
for _task, pending in list(self._pending.items()):
if _pending_elicitation_matches_terminal_turn(
pending,
thread_id=thread_id,
turn_id=turn_id,
):
await self._post_resolved_once(client, session_id, pending)
async def drain(self) -> None:
"""
Wait for currently pending hook waits without cancelling them.
:returns: None after every task that was pending at entry has
reached a terminal state.
"""
if not self._pending:
return
await asyncio.gather(*list(self._pending), return_exceptions=True)
async def close(self) -> None:
"""
Cancel all pending hook waits and wait for their cleanup.
:returns: None after all background hook tasks have finished.
"""
if not self._pending:
return
tasks = list(self._pending)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
self._pending.clear()
self._posted_resolutions.clear()
async def _run_one(
self,
client: httpx.AsyncClient,
codex_client: CodexAppServerClient,
*,
session_id: str,
event: CodexMessage,
) -> None:
"""
Run one hook bridge and log non-cancellation failures.
:param client: HTTP client for Omnigent hook posts.
:param codex_client: Connected Codex app-server client.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param event: Codex JSON-RPC request envelope.
:returns: None.
"""
try:
await _handle_codex_elicitation_request(
client,
codex_client,
session_id=session_id,
event=event,
)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - keep the long-lived forwarder alive.
_logger.warning(
"Codex forwarder elicitation hook task failed: method=%s",
event.get("method"),
exc_info=True,
)
def _discard_done(self, task: asyncio.Task[None]) -> None:
"""
Remove a completed task and consume its terminal state.
:param task: Completed hook task.
:returns: None.
"""
pending = self._pending.pop(task, None)
if task.cancelled():
if pending is not None:
self._posted_resolutions.discard(pending.elicitation_id)
return
task.exception()
if pending is not None:
self._posted_resolutions.discard(pending.elicitation_id)
async def _post_resolved_once(
self,
client: httpx.AsyncClient,
session_id: str,
pending: _PendingCodexElicitation,
) -> None:
"""
Post one Omnigent resolution signal, suppressing duplicates.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param pending: Pending hook wait metadata to resolve.
:returns: None.
"""
if pending.elicitation_id in self._posted_resolutions:
return
posted = await _post_external_elicitation_resolved(
client,
session_id,
elicitation_id=pending.elicitation_id,
)
if posted:
self._posted_resolutions.add(pending.elicitation_id)
def _pending_elicitation_matches_resolution(
pending: _PendingCodexElicitation,
*,
request_id: Any,
thread_id: str | None,
) -> bool:
"""
Return whether a Codex resolution targets a pending hook wait.
:param pending: Pending hook wait metadata.
:param request_id: Codex ``serverRequest/resolved.requestId``,
e.g. ``12``.
:param thread_id: Codex ``serverRequest/resolved.threadId``, e.g.
``"thread_abc"``, or ``None`` if absent.
:returns: ``True`` when the notification matches the same request id
and, when present, the same thread id.
"""
if pending.request_id != request_id:
return False
return pending.thread_id is None or thread_id is None or pending.thread_id == thread_id
def _pending_elicitation_matches_terminal_turn(
pending: _PendingCodexElicitation,
*,
thread_id: str | None,
turn_id: str | None,
) -> bool:
"""
Return whether a terminal Codex turn clears a pending hook wait.
:param pending: Pending hook wait metadata.
:param thread_id: Codex terminal event thread id, e.g.
``"thread_abc"``, or ``None`` if absent.
:param turn_id: Codex terminal event turn id, e.g.
``"turn_abc"``, or ``None`` if absent.
:returns: ``True`` when the terminal event shares a concrete turn
or thread scope with the pending request.
"""
if pending.thread_id is not None and thread_id is not None and pending.thread_id != thread_id:
return False
if pending.turn_id is not None and turn_id is not None:
return pending.turn_id == turn_id
if pending.thread_id is not None and thread_id is not None:
return pending.thread_id == thread_id
return False
async def _sleep(seconds: float) -> None:
"""
Stubbable indirection for Codex forwarder sleeps.
Exists so tests can stub retry delays without patching
``asyncio.sleep`` through the imported module singleton.
:param seconds: Delay in seconds.
:returns: None after the sleep completes.
"""
await asyncio.sleep(seconds)
async def supervise_forwarder(
*,
base_url: str,
headers: dict[str, str],
session_id: str,
bridge_dir: Path,
app_server_url: str,
thread_id: str,
client: CodexAppServerClient | None = None,
auth: httpx.Auth | None = None,
ap_transport: httpx.AsyncBaseTransport | None = None,
) -> None:
"""
Mirror Codex app-server notifications into an Omnigent session.
:param base_url: Omnigent server base URL, e.g.
``"http://127.0.0.1:6767"``.
:param headers: Static HTTP headers for Omnigent requests.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param app_server_url: Codex app-server transport, e.g.
``"ws://127.0.0.1:9876"``. Used to (re)connect a fallback
client when ``client`` is ``None`` and persisted to bridge
state on thread rotation, so the executor keeps reaching the
live app-server after a native ``/clear``.
:param thread_id: Codex thread id to subscribe to.
:param client: Optional already-connected client. Fresh Codex
sessions pass the listener that observed ``thread/started``;
the forwarder still calls ``thread/resume`` once the id is
known so that connection receives turn/item notifications.
:param auth: Optional HTTP auth for long-lived remote sessions.
:param ap_transport: Optional HTTP transport for the Omnigent client,
e.g. ``httpx.MockTransport(...)`` for tests.
:returns: None. Runs until cancelled or the app-server connection
closes.
"""
# Bind bridge dir so failed durable-event posts can be dead-lettered (#1120).
_dead_letter_dir.set(bridge_dir)
if client is None:
client = client_for_transport(app_server_url, client_name="omnigent-codex-forwarder")
await client.connect()
async with httpx.AsyncClient(
base_url=base_url,
headers=headers,
auth=auth,
timeout=httpx.Timeout(30.0),
transport=ap_transport,
) as ap_client:
# Recover proven-undelivered dead-lettered forwards now that the
# server may be reachable again (host/server returned after an
# outage or restart). Runs before live forwarding begins, so no
# other writer races the dead-letter files (#1579).
await _replay_dead_letters_on_startup(ap_client, bridge_dir)
# Synthesize the thread's MCP startup round (see the comment on
# _CODEX_MCP_STARTUP_STATUS_METHOD): the fresh-launch forwarder
# starts right at thread creation, which is when codex boots its
# configured MCP servers. Skipped when the bridge already carries
# round state (forwarder reconnect mid-session).
mcp_settle_timer = await _seed_mcp_startup_round(
ap_client, session_id=session_id, bridge_dir=bridge_dir
)
target = _ForwarderTarget(
session_id=session_id,
thread_id=thread_id,
delta_coalescer=_OutputTextDeltaCoalescer(ap_client, session_id),
usage_coalescer=_SessionUsageCoalescer(ap_client, session_id),
elicitation_tracker=_CodexElicitationTaskTracker(),
)
forwarder_state = _CodexForwarderState(
parent_session_id=session_id,
codex_client=client,
)
# Released when the live event stream shows the thread became
# active (its first turn materializes the rollout). Lets the
# subscribe task park instead of blind-polling ``thread/resume``
# for a fresh, still-empty thread. Recreated per thread on rotation.
thread_active = asyncio.Event()
subscribe_task = asyncio.create_task(
_subscribe_until_ready(
client,
ap_client,
session_id=target.session_id,
bridge_dir=bridge_dir,
thread_id=target.thread_id,
usage_coalescer=target.usage_coalescer,
elicitation_tracker=target.elicitation_tracker,
forwarder_state=forwarder_state,
ready_signal=thread_active,
),
name="codex-native-forwarder-subscribe",
)
await _sleep(0)
try:
async for event in client.iter_events():
try:
rotated = await _maybe_rotate_session_on_thread_started(
ap_client=ap_client,
target=target,
bridge_dir=bridge_dir,
app_server_url=app_server_url,
event=event,
)
if rotated:
forwarder_state.note_parent_rotation(target.session_id)
subscribe_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await subscribe_task
# Fresh thread after a /clear rotation — start its
# own active signal so the new subscription parks
# until the rotated thread's first turn.
thread_active = asyncio.Event()
subscribe_task = asyncio.create_task(
_subscribe_until_ready(
client,
ap_client,
session_id=target.session_id,
bridge_dir=bridge_dir,
thread_id=target.thread_id,
usage_coalescer=target.usage_coalescer,
elicitation_tracker=target.elicitation_tracker,
forwarder_state=forwarder_state,
ready_signal=thread_active,
),
name="codex-native-forwarder-subscribe",
)
continue
# Release the subscribe task as soon as the thread shows
# activity (rollout now exists), so it resumes instead of
# waiting forever on an idle fresh thread.
if not thread_active.is_set() and _event_indicates_thread_active(event):
thread_active.set()
await _handle_event(
ap_client,
session_id=target.session_id,
bridge_dir=bridge_dir,
event=event,
delta_coalescer=target.delta_coalescer,
usage_coalescer=target.usage_coalescer,
elicitation_tracker=target.elicitation_tracker,
expected_thread_id=target.thread_id,
codex_client=client,
forwarder_state=forwarder_state,
)
except Exception: # noqa: BLE001 - keep the long-lived mirror alive.
_logger.warning("Codex forwarder event handling failed", exc_info=True)
finally:
if mcp_settle_timer is not None:
mcp_settle_timer.cancel()
with contextlib.suppress(asyncio.CancelledError):
await mcp_settle_timer
await target.delta_coalescer.close()
await target.usage_coalescer.close()
await target.elicitation_tracker.close()
subscribe_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await subscribe_task
await client.close()
async def _maybe_rotate_session_on_thread_started(
*,
ap_client: httpx.AsyncClient,
target: _ForwarderTarget,
bridge_dir: Path,
app_server_url: str,
event: CodexMessage,
) -> bool:
"""
Rotate Omnigent ownership when Codex starts a new native thread.
Native Codex ``/clear`` starts a fresh app-server thread in the
existing terminal. The forwarder must move the Omnigent session binding
to a fresh conversation and then subscribe this same app-server
connection to the new thread; otherwise web messages keep targeting
the old thread and streaming appears to end.
:param ap_client: Omnigent HTTP client used for session rotation.
:param target: Mutable current AP/Codex target.
:param bridge_dir: Native Codex bridge directory.
:param app_server_url: Codex app-server transport, e.g.
``"ws://127.0.0.1:9876"``. Persisted to bridge state for the
replacement session.
:param event: Codex app-server notification envelope.
:returns: ``True`` when rotation occurred.
"""
new_thread_id = _thread_id_from_started_event(event)
if new_thread_id is None or new_thread_id == target.thread_id:
return False
# A Codex AgentControl child thread emits ``thread/started`` when it
# begins. That event must not rotate the parent Omnigent session — the child
# is discovered later via a ``collabAgentToolCall`` item and routed to
# its own Omnigent child session by ``_handle_event``.
if _thread_started_is_subagent(event):
return False
old_delta_coalescer = target.delta_coalescer
await old_delta_coalescer.flush()
old_usage_coalescer = target.usage_coalescer
await old_usage_coalescer.flush()
old_elicitation_tracker = target.elicitation_tracker
old_session_id = target.session_id
new_session_id = await _create_thread_replacement_session(
client=ap_client,
old_session_id=old_session_id,
bridge_dir=bridge_dir,
app_server_url=app_server_url,
new_thread_id=new_thread_id,
)
target.session_id = new_session_id
target.thread_id = new_thread_id
target.delta_coalescer = _OutputTextDeltaCoalescer(ap_client, new_session_id)
target.usage_coalescer = _SessionUsageCoalescer(ap_client, new_session_id)
target.elicitation_tracker = _CodexElicitationTaskTracker()
await old_delta_coalescer.close()
await old_usage_coalescer.close()
await old_elicitation_tracker.close()
_logger.info(
"Codex forwarder rotated Omnigent session after native thread switch: "
"old_session=%s new_session=%s new_thread=%s",
old_session_id,
new_session_id,
new_thread_id,
)
return True
async def _create_thread_replacement_session(
*,
client: httpx.AsyncClient,
old_session_id: str,
bridge_dir: Path,
app_server_url: str,
new_thread_id: str,
) -> str:
"""
Create and activate the Omnigent session for a new native Codex thread.
:param client: Omnigent HTTP client.
:param old_session_id: Session being rotated away from, e.g.
``"conv_old"``.
:param bridge_dir: Native Codex bridge directory.
:param app_server_url: Codex app-server transport, e.g.
``"ws://127.0.0.1:9876"``. Written to the replacement session's
bridge state so the executor reaches the live app-server after
rotation (a unix path here would clobber the ws:// URL).
:param new_thread_id: Newly started Codex thread id, e.g.
``"thread_new"``.
:returns: New Omnigent session id, e.g. ``"conv_new"``.
:raises httpx.HTTPStatusError: If Omnigent rejects the create, bind,
external-session update, or terminal transfer calls.
:raises RuntimeError: If the old session snapshot or create
response is malformed.
"""
old = await _fetch_session_snapshot(client, old_session_id)
agent_id = old.get("agent_id")
if not isinstance(agent_id, str) or not agent_id:
raise RuntimeError(f"session {old_session_id!r} has no agent_id")
runner_id = old.get("runner_id")
labels = old.get("labels") if isinstance(old.get("labels"), dict) else {}
labels = {str(key): str(value) for key, value in labels.items()}
state = read_bridge_state(bridge_dir)
if CODEX_NATIVE_BRIDGE_ID_LABEL_KEY not in labels:
labels[CODEX_NATIVE_BRIDGE_ID_LABEL_KEY] = old_session_id
create_resp = await client.post(
"/v1/sessions",
json={
"agent_id": agent_id,
"labels": labels,
},
)
create_resp.raise_for_status()
created = create_resp.json()
new_session_id = created.get("id")
if not isinstance(new_session_id, str) or not new_session_id:
raise RuntimeError("Codex thread replacement response did not include id")
if isinstance(runner_id, str) and runner_id:
bind_resp = await client.patch(
f"/v1/sessions/{url_component(new_session_id)}",
json={"runner_id": runner_id},
)
bind_resp.raise_for_status()
external_resp = await client.patch(
f"/v1/sessions/{url_component(new_session_id)}",
json={"external_session_id": new_thread_id},
)
external_resp.raise_for_status()
terminal_id = terminal_resource_id("codex", "main")
transfer_resp = await client.post(
(
f"/v1/sessions/{url_component(old_session_id)}"
f"/resources/terminals/{url_component(terminal_id)}/transfer"
),
json={"target_session_id": new_session_id},
)
transfer_resp.raise_for_status()
write_bridge_state(
bridge_dir,
CodexNativeBridgeState(
session_id=new_session_id,
socket_path=app_server_url,
thread_id=new_thread_id,
codex_home=(
state.codex_home
if state is not None
else str(codex_home_for_bridge_dir(bridge_dir))
),
),
)
clear_resp = await client.patch(
f"/v1/sessions/{url_component(old_session_id)}",
json={"runner_id": ""},
)
if clear_resp.status_code >= 400:
_logger.warning(
"Failed to clear old codex-native runner binding after thread switch; "
"old_session=%s new_session=%s status=%s body=%s",
old_session_id,
new_session_id,
clear_resp.status_code,
clear_resp.text,
)
return new_session_id
async def _fetch_session_snapshot(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
"""
Fetch an Omnigent session snapshot for Codex session rotation.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
:returns: Decoded JSON session snapshot.
:raises httpx.HTTPStatusError: If Omnigent rejects the request.
:raises RuntimeError: If the response is not a JSON object.
"""
resp = await client.get(f"/v1/sessions/{url_component(session_id)}")
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, dict):
raise RuntimeError("Codex session snapshot response was not an object")
return payload
async def _subscribe_until_ready(
client: CodexAppServerClient,
ap_client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
thread_id: str,
usage_coalescer: _SessionUsageCoalescer,
elicitation_tracker: _CodexElicitationTaskTracker,
forwarder_state: _CodexForwarderState | None = None,
ready_signal: asyncio.Event | None = None,
) -> None:
"""
Subscribe this app-server connection to a Codex thread.
A resume session's thread already has a persisted rollout, so the
first ``thread/resume`` succeeds and any prior message items are
replayed immediately.
A fresh TUI-created thread, however, has *no* rollout until its first
turn runs — Codex defers materialization for a new thread, so
``thread/resume`` rejects it with ``no rollout found``. Rather than
blind-poll that state (which hammers the app-server for the entire
idle window before the user's first turn), this parks on
*ready_signal* and only retries once the caller observes the thread
become active on the live event stream (its first turn, which
materializes the rollout). ``thread/status/changed``/turn/item events
reach the connection without a successful resume, so the caller can
detect activity and set the signal. A short poll still covers the
brief window between "thread active" and the rollout being flushed.
:param client: Codex app-server client.
:param ap_client: Omnigent HTTP client used for replayed items.
:param session_id: Omnigent conversation id.
:param bridge_dir: Native Codex bridge directory.
:param thread_id: Codex thread id.
:param usage_coalescer: Token-usage coalescer for replayed
app-server events.
:param elicitation_tracker: Background Codex elicitation tracker.
:param forwarder_state: Optional mutable forwarder state that
receives thread metadata from the resume response.
:param ready_signal: Set by the caller when it observes the thread
become active (rollout now exists). While unset, a not-ready
thread parks here instead of polling. ``None`` falls back to the
fixed-interval retry (used where no live event stream drives the
signal).
:returns: None.
"""
saw_not_ready = False
while True:
try:
params: dict[str, Any] = {"threadId": thread_id}
if not saw_not_ready:
params["excludeTurns"] = True
response = await client.request("thread/resume", params)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - app-server error envelopes are surfaced as RuntimeError.
if _is_thread_not_ready_error(exc):
if not saw_not_ready:
_logger.info(
"Codex thread %s is not ready yet (no/empty rollout); "
"retrying subscription",
thread_id,
)
saw_not_ready = True
if ready_signal is not None and not ready_signal.is_set():
# Idle fresh thread: no rollout until the first turn, and
# no reason to poll meanwhile. Park until the caller's
# event loop observes the thread go active.
await ready_signal.wait()
else:
# No signal wired (fallback), or the thread is active but
# its rollout isn't flushed yet (brief race) — a short
# poll covers that window.
await _sleep(_SUBSCRIBE_RETRY_DELAY_SECONDS)
continue
_logger.warning("failed to subscribe to Codex thread %s", thread_id, exc_info=True)
return
if forwarder_state is not None:
forwarder_state.note_resume_response(response)
# Source of truth for the cost policy is config.toml's model (what
# /model writes). Read it now so model_override reflects it from
# the first tool call, not a turn later. Falls back to the resume
# response's model when config.toml has none.
_refresh_model_from_config(bridge_dir, forwarder_state)
await _sync_model_change(
ap_client, session_id=session_id, forwarder_state=forwarder_state
)
await _replay_resume_response(
ap_client,
session_id=session_id,
bridge_dir=bridge_dir,
response=response,
usage_coalescer=usage_coalescer,
elicitation_tracker=elicitation_tracker,
forwarder_state=forwarder_state,
)
return
def _event_indicates_thread_active(event: CodexMessage) -> bool:
"""
Return whether an app-server notification implies the thread is now active.
A fresh thread's rollout is only materialized once its first turn
starts, so the subscription's ``thread/resume`` keeps failing until
then. These notifications all imply a turn has begun (hence the
rollout now exists), and — crucially — they reach a connection
*without* a successful resume, so the forwarder's main loop can use
them to release :func:`_subscribe_until_ready` from its parked wait:
- any ``turn/*`` or ``item/*`` notification, and
- ``thread/status/changed`` transitioning to an ``active`` status.
:param event: A Codex JSON-RPC notification envelope.
:returns: ``True`` if the event implies the thread became active.
"""
method = event.get("method")
if not isinstance(method, str):
return False
if method.startswith(("turn/", "item/")):
return True
if method == "thread/status/changed":
params = event.get("params")
status = params.get("status") if isinstance(params, dict) else None
return isinstance(status, dict) and status.get("type") == "active"
return False
def _is_thread_not_ready_error(exc: Exception) -> bool:
"""
Return whether a subscription failure is Codex's fresh-thread not-ready gap.
Covers the two transient states a freshly created thread passes through
before its first turn populates the rollout: the rollout file is missing
(``no rollout found for thread id``) or present-but-empty
(``... rollout ... is empty``). Both are retryable — once a turn writes
the rollout, ``thread/resume`` succeeds.
:param exc: Exception raised by ``thread/resume``.
:returns: ``True`` for either retryable not-ready state.
"""
message = str(exc)
if _NO_ROLLOUT_FRAGMENT in message:
return True
return "rollout" in message and _EMPTY_ROLLOUT_FRAGMENT in message
async def _replay_resume_response(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
response: CodexMessage,
usage_coalescer: _SessionUsageCoalescer,
elicitation_tracker: _CodexElicitationTaskTracker,
forwarder_state: _CodexForwarderState | None = None,
) -> None:
"""
Mirror message items returned by ``thread/resume``.
Passes ``forwarder_state`` into each replayed event so the dedup gate
in ``_handle_completed_item`` can skip items that the live stream
already delivered.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id.
:param bridge_dir: Native Codex bridge directory.
:param response: Codex ``thread/resume`` response envelope.
:param usage_coalescer: Token-usage coalescer for replayed
app-server events.
:param elicitation_tracker: Background Codex elicitation tracker.
:param forwarder_state: Optional mutable state for dedup and
sub-agent registration.
:returns: None.
"""
result = response.get("result")
if not isinstance(result, dict):
return
thread = result.get("thread")
if not isinstance(thread, dict):
return
turns = thread.get("turns")
if not isinstance(turns, list):
return
thread_id = thread.get("id")
thread_id = thread_id if isinstance(thread_id, str) and thread_id else None
for turn in turns:
if not isinstance(turn, dict):
continue
turn_id = _turn_id_from_payload(turn)
items = turn.get("items")
if not turn_id or not isinstance(items, list):
continue
for item in items:
if not isinstance(item, dict):
continue
await _handle_event(
client,
session_id=session_id,
bridge_dir=bridge_dir,
event={
"method": "item/completed",
"params": {
"threadId": thread_id,
"turnId": turn_id,
"item": item,
},
},
usage_coalescer=usage_coalescer,
elicitation_tracker=elicitation_tracker,
expected_thread_id=thread_id,
forwarder_state=forwarder_state,
)
await _post_resume_terminal_status(
client,
session_id=session_id,
bridge_dir=bridge_dir,
thread_id=thread_id,
turns=turns,
)
async def _post_resume_terminal_status(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
thread_id: str | None,
turns: list[Any],
) -> None:
"""
Publish a missing terminal status edge from ``thread/resume`` data.
A reconnect can miss the live ``turn/started`` and
``turn/completed`` / ``turn/failed`` notifications. When the resume
payload explicitly says the latest turn on the current thread is
terminal, the forwarder can close the Omnigent session status even though no
live terminal boundary was observed. It deliberately does not infer
terminal state from transcript items alone.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param thread_id: Codex thread id from the resume payload, e.g.
``"thread_123"``.
:param turns: Raw Codex resume turn list.
:returns: None.
"""
if thread_id is None:
return
edge = _resume_terminal_status_edge_for_latest_turn(bridge_dir, thread_id, turns)
await _post_turn_status_edge(client, session_id, edge)
def _resume_terminal_status_edge_for_latest_turn(
bridge_dir: Path,
thread_id: str,
turns: list[Any],
) -> _CodexTurnStatusEdge | None:
"""
Return the Omnigent terminal status represented by the latest resume turn.
:param bridge_dir: Native Codex bridge directory.
:param thread_id: Codex thread id from the resume payload, e.g.
``"thread_123"``.
:param turns: Raw Codex resume turn list.
:returns: Terminal status edge when the latest turn is terminal and
belongs to the bridge's current thread; otherwise ``None``.
"""
state = read_bridge_state(bridge_dir)
if state is None or state.thread_id != thread_id:
return None
for turn in reversed(turns):
if not isinstance(turn, dict):
continue
turn_id = _turn_id_from_payload(turn)
if turn_id is None:
return None
if state.active_turn_id is not None and state.active_turn_id != turn_id:
return None
status = _omnigent_status_from_resume_turn(turn)
if status is None:
return None
update_active_turn_id(bridge_dir, None)
# Parity with the live path — surface ``turn.error`` (if any) that
# forced this resume turn to ``failed``.
error = _terminal_error_from_turn({"turn": turn})
return _CodexTurnStatusEdge(
status=status,
turn_id=turn_id,
source="thread/resume:turn-error" if error is not None else "thread/resume",
error=error,
)
return None
def _omnigent_status_from_resume_turn(turn: dict[str, Any]) -> str | None:
"""
Convert an explicit Codex resume turn status to Omnigent session status.
Applies the same ``turn.error`` check as the live terminal path
(:func:`_terminal_turn_status_edge`) so a resumed turn that carried an
error maps to ``failed`` even if its recorded status is not — the
resume-path side of the "silent success" fix.
:param turn: Codex resume turn object, e.g.
``{"id": "turn_123", "status": "completed"}``.
:returns: Omnigent status literal for terminal turns, or ``None`` for active
or unrecognized statuses.
"""
# A ``turn.error`` forces ``failed`` regardless of the recorded status.
if _terminal_error_from_turn({"turn": turn}) is not None:
return "failed"
status = turn.get("status")
if isinstance(status, dict):
status = status.get("type") or status.get("status")
if status in {"completed", "interrupted", "cancelled", "canceled"}:
return "idle"
if status in {"failed", "errored"}:
return "failed"
return None
async def _handle_event(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
event: CodexMessage,
usage_coalescer: _SessionUsageCoalescer,
elicitation_tracker: _CodexElicitationTaskTracker,
delta_coalescer: _OutputTextDeltaCoalescer | None = None,
expected_thread_id: str | None = None,
codex_client: CodexAppServerClient | None = None,
forwarder_state: _CodexForwarderState | None = None,
) -> None:
"""
Forward one Codex app-server notification.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param event: Codex notification envelope.
:param usage_coalescer: Coalescer for high-frequency usage
notifications.
:param elicitation_tracker: Background Codex elicitation tracker.
:param delta_coalescer: Optional coalescer for high-frequency
assistant text deltas.
:param expected_thread_id: Current Codex thread id. When provided,
events carrying a different ``threadId`` are stale and ignored.
:param codex_client: Optional Codex app-server client. Required
when ``event`` is a server-to-client request that needs a
JSON-RPC response.
:param forwarder_state: Optional mutable state for Plan-mode prompt
synthesis and thread setting tracking.
:returns: None.
"""
method = event.get("method")
params = event.get("params")
if not isinstance(method, str) or not isinstance(params, dict):
return
if forwarder_state is not None and _thread_started_is_subagent(event):
child_thread_id = _thread_id_from_started_event(event)
if child_thread_id is not None:
forwarder_state.note_pending_child_thread(
child_thread_id,
_parent_thread_id_from_started_event(event),
)
return
if method == _CODEX_MCP_STARTUP_STATUS_METHOD:
# MCP startup is bridge-level state, surfaced on the parent
# session. The notification's ``threadId`` is nullable; a child
# thread's startup (different id) is not mirrored.
event_thread_id = _thread_id_from_params(params)
if (
event_thread_id is None
or expected_thread_id is None
or event_thread_id == expected_thread_id
):
parent_session_id = (
forwarder_state.parent_session_id
if forwarder_state is not None and forwarder_state.parent_session_id is not None
else session_id
)
await _handle_mcp_startup_status(
client,
session_id=parent_session_id,
bridge_dir=bridge_dir,
params=params,
)
return
if _is_thread_idle_status_event(method, params) and _thread_id_from_params(params) in {
None,
expected_thread_id,
}:
# A completed turn proves MCP startup settled (codex defers turn
# execution until the round ends) — resolve the synthesized round.
# Not an exclusive handler: idle status also feeds the subscribe
# release below, so fall through.
await _settle_mcp_startup(
client, session_id=session_id, bridge_dir=bridge_dir, reason="thread went idle"
)
# Resolve routing: parent thread, known child thread, or stale/ignored.
route_session_id, is_child = _resolve_event_session(
params, method, expected_thread_id, forwarder_state, fallback_session_id=session_id
)
if route_session_id is None:
return
# item/started: register collab-agent children early (before item/completed)
# so live child events can be routed to the child session immediately.
# Only meaningful for the parent thread; children don't spawn grandchildren here.
if method == "item/started" and not is_child and forwarder_state is not None:
item = params.get("item")
if isinstance(item, dict) and item.get("type") == _CODEX_COLLAB_AGENT_ITEM_TYPE:
await _handle_collab_item(client, params, item, forwarder_state)
elif isinstance(item, dict) and item.get("type") == _CODEX_COMPACTION_ITEM_TYPE:
# Compaction started mid-turn — show the spinner.
await _post_compaction_status(
client, route_session_id, "in_progress", forwarder_state=forwarder_state
)
elif isinstance(item, dict) and item.get("type") == "agentMessage":
# Post the turn's user message NOW — before the assistant's text
# deltas start streaming. The live ``userMessage`` event can be
# missed on a fresh thread (subscription lands after it fires);
# if recovery waited until the assistant's ``item/completed``,
# the deltas would stream into a transient assistant bubble that
# renders ABOVE the still-pending user bubble until the turn
# reconciles. Recovering at assistant-start commits the user
# message first (it has already materialized in the rollout by
# now), so the web UI renders the question above the reply. The
# ``item/completed`` guard below remains the backstop for the
# resume-backfill path, which replays only ``item/completed``.
await _ensure_user_message_posted(client, route_session_id, params, forwarder_state)
return
if method == _CODEX_SERVER_REQUEST_RESOLVED_METHOD:
# Resolve on the session the elicitation was published on (a child
# thread when is_child), not the parent — otherwise a child-thread
# approval card never flips for the web user watching the child.
await elicitation_tracker.resolve_by_server_notification(
client,
session_id=route_session_id,
params=params,
)
return
if await _maybe_handle_codex_request(
client,
session_id=route_session_id,
event=event,
method=method,
delta_coalescer=delta_coalescer if not is_child else None,
elicitation_tracker=elicitation_tracker,
codex_client=codex_client,
forwarder_state=forwarder_state,
):
return
# Child token-usage events must post to the child session, not the
# parent's coalescer. A fresh coalescer is created per-event for
# children and flushed immediately so accumulated data is not lost.
# Seed it with the session model so the server can price the child's tokens.
child_coalescer = (
_SessionUsageCoalescer(
client,
route_session_id,
model=forwarder_state.model if forwarder_state is not None else None,
)
if is_child
else None
)
if await _maybe_handle_turn_event(
client,
session_id=route_session_id,
bridge_dir=bridge_dir if not is_child else Path(),
method=method,
params=params,
usage_coalescer=child_coalescer if is_child else usage_coalescer,
delta_coalescer=delta_coalescer if not is_child else None,
elicitation_tracker=elicitation_tracker,
codex_client=codex_client,
forwarder_state=forwarder_state if not is_child else None,
):
if child_coalescer is not None:
await child_coalescer.flush()
return
if not is_child and await _maybe_handle_delta_event(
client,
session_id=route_session_id,
bridge_dir=bridge_dir,
method=method,
params=params,
delta_coalescer=delta_coalescer,
forwarder_state=forwarder_state,
):
return
if method == "item/completed":
await _handle_completed_event(
client,
session_id=route_session_id,
params=params,
delta_coalescer=delta_coalescer if not is_child else None,
forwarder_state=forwarder_state,
bridge_dir=bridge_dir,
)
def _resolve_event_session(
params: dict[str, Any],
method: str,
expected_thread_id: str | None,
forwarder_state: _CodexForwarderState | None,
*,
fallback_session_id: str,
) -> tuple[str | None, bool]:
"""
Resolve which Omnigent session should receive a Codex event.
Returns ``(session_id, is_child)`` where ``session_id`` is ``None``
when the event should be silently dropped (stale or unrecognized
thread). ``is_child`` is ``True`` when the event belongs to a known
Codex child thread rather than the parent.
:param params: Codex notification params.
:param method: Codex method value, e.g. ``"item/completed"``.
:param expected_thread_id: Active parent Codex thread id, e.g.
``"thread_parent"``.
:param forwarder_state: Optional state holding child-thread mappings.
:param fallback_session_id: Parent session id used when
``forwarder_state`` has no ``parent_session_id`` (e.g. in tests
that call ``_handle_event`` directly).
:returns: ``(route_session_id, is_child)`` tuple.
"""
event_thread_id = _thread_id_from_params(params)
# Route to a known child session when the event targets a child thread.
if forwarder_state is not None and event_thread_id is not None:
child_session_id = forwarder_state.session_for_child_thread(event_thread_id)
if child_session_id is not None:
return child_session_id, True
parent_session_id = (
forwarder_state.parent_session_id
if forwarder_state is not None and forwarder_state.parent_session_id is not None
else fallback_session_id
)
# Approval requests from announced child threads must not be dropped just
# because AP child-session registration is racing behind the request
# frame. Unknown non-parent threads still hit the stale-thread guard below;
# only ``thread/started`` events with ``source.subAgent.thread_spawn`` earn
# this temporary parent routing.
targets_unregistered_thread = (
expected_thread_id is not None
and event_thread_id is not None
and event_thread_id != expected_thread_id
)
targets_pending_child_thread = (
forwarder_state is not None
and event_thread_id is not None
and forwarder_state.is_pending_child_thread(event_thread_id, expected_thread_id)
)
if (
method in _CODEX_ELICITATION_REQUEST_METHODS
and targets_unregistered_thread
and targets_pending_child_thread
):
_logger.info(
"Codex forwarder routed unregistered child-thread elicitation to parent: "
"method=%s event_thread=%s active_thread=%s",
method,
event_thread_id,
expected_thread_id,
)
return parent_session_id, False
# Drop stale events for threads that are neither the parent nor a child.
if _event_targets_different_thread(params, method, expected_thread_id):
return None, False
return parent_session_id, False
def _event_targets_different_thread(
params: dict[str, Any],
method: str,
expected_thread_id: str | None,
) -> bool:
"""
Return whether an event belongs to a stale Codex thread.
:param params: Codex notification params.
:param method: Codex method value, e.g. ``"item/completed"``.
:param expected_thread_id: Active Codex thread id, e.g.
``"thread_123"``.
:returns: ``True`` when the event should be ignored as stale.
"""
event_thread_id = _thread_id_from_params(params)
if expected_thread_id is None or event_thread_id is None:
return False
if event_thread_id == expected_thread_id:
return False
_logger.info(
"Codex forwarder ignored stale thread event: method=%s event_thread=%s active_thread=%s",
method,
event_thread_id,
expected_thread_id,
)
return True
async def _maybe_handle_codex_request(
client: httpx.AsyncClient,
*,
session_id: str,
event: CodexMessage,
method: str,
delta_coalescer: _OutputTextDeltaCoalescer | None,
elicitation_tracker: _CodexElicitationTaskTracker,
codex_client: CodexAppServerClient | None,
forwarder_state: _CodexForwarderState | None,
) -> bool:
"""
Handle Codex server-to-client requests if this event is one.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param event: Codex notification/request envelope.
:param method: Codex method value, e.g.
``"item/tool/requestUserInput"``.
:param delta_coalescer: Optional text coalescer to flush before a
blocking request.
:param elicitation_tracker: Background Codex elicitation tracker.
:param codex_client: Optional app-server client used to answer
JSON-RPC requests.
:param forwarder_state: Optional Plan-mode prompt state.
:returns: ``True`` when the event was a request and needs no
further dispatch.
"""
if _is_codex_elicitation_request(event):
if codex_client is None:
_logger.warning(
"Codex forwarder cannot answer elicitation request without app-server client: "
"method=%s",
method,
)
return True
if delta_coalescer is not None:
await delta_coalescer.flush()
if forwarder_state is not None:
_note_native_plan_implementation_prompt(forwarder_state, event)
elicitation_tracker.start(
client,
codex_client,
session_id=session_id,
event=event,
)
return True
if isinstance(event.get("id"), int | str) and isinstance(method, str):
_logger.warning("Codex forwarder ignored unsupported server request: method=%s", method)
return True
return False
def _refresh_model_from_config(bridge_dir: Path, forwarder_state: _CodexForwarderState) -> None:
"""
Update the forwarder's known model from this session's ``config.toml``.
Reads the source-of-truth model via the shared
:func:`~omnigent.codex_native_bridge.read_codex_config_model` (the
``model`` key an in-TUI ``/model`` writes — see that function for why
config.toml is the source of truth and its caveats) and stores it on
``forwarder_state.model`` so a following ``_sync_model_change`` mirrors
it to Omnigent as ``model_override``. This mirror is a fallback to the codex
hook, which stamps the live model onto the evaluation request at gate
time; the gate prefers the hook's value. No-op when the model can't be
determined, leaving the prior value.
:param bridge_dir: The session's native-Codex bridge directory.
:param forwarder_state: Mutable forwarder state whose ``model`` is
updated in place.
:returns: None.
"""
model = read_codex_config_model(bridge_dir)
if model:
forwarder_state.model = model
async def _sync_model_change(
client: httpx.AsyncClient,
*,
session_id: str,
forwarder_state: _CodexForwarderState,
) -> None:
"""
Mirror a Codex TUI ``/model`` switch to Omnigent (web picker + cost gate).
The active model is recorded on ``forwarder_state.model`` by
``_refresh_model_from_config`` (read from ``config.toml``, the source of
truth for codex — see ``read_codex_config_model``) at subscription and at
each ``turn/started``, and also by ``thread/settings/updated`` when Codex
emits one. When that differs from the last-mirrored ``posted_model``
baseline, POST an
``external_model_change`` event so the Omnigent server persists
``conv.model_override`` — which keeps the web model dropdown in sync and
lets the cost-budget policy re-evaluate against the new model. Codex
model ids are stable per model (unlike Claude's per-turn concrete id),
so the raw id is posted as-is. Best-effort: a failed post leaves the
baseline unchanged so the next settings update retries.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param forwarder_state: Mutable forwarder state carrying the current
model and the last-mirrored baseline.
:returns: None.
"""
model = forwarder_state.model
if not model or model == forwarder_state.posted_model:
return
response = await _post_session_event(
client,
session_id,
event_type="external_model_change",
data={"model": model},
)
_log_failed_session_event_post("external_model_change", response)
if response is not None and response.status_code < 400:
forwarder_state.posted_model = model
async def _sync_reasoning_effort_change(
client: httpx.AsyncClient,
*,
session_id: str,
forwarder_state: _CodexForwarderState,
) -> None:
"""
Mirror Codex's active reasoning effort to Omnigent session metadata.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param forwarder_state: Mutable forwarder state carrying the current
Codex effort and last-mirrored baseline.
:returns: None.
"""
effort = forwarder_state.effort
if forwarder_state.posted_effort_known and effort == forwarder_state.posted_effort:
return
response = await _post_session_event(
client,
session_id,
event_type=_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
data={"reasoning_effort": effort},
)
_log_failed_session_event_post(_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE, response)
if response is not None and response.status_code < 400:
forwarder_state.posted_effort = effort
forwarder_state.posted_effort_known = True
async def _sync_codex_collaboration_mode_change(
client: httpx.AsyncClient,
*,
session_id: str,
forwarder_state: _CodexForwarderState,
) -> None:
"""
Mirror Codex's active collaboration mode kind to Omnigent labels.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param forwarder_state: Mutable forwarder state carrying the current
Codex collaboration mode and last-mirrored baseline.
:returns: None.
"""
mode = forwarder_state.collaboration_mode
if not mode or mode == forwarder_state.posted_collaboration_mode:
return
response = await _post_session_event(
client,
session_id,
event_type=_EXTERNAL_CODEX_COLLABORATION_MODE_CHANGE_TYPE,
data={"mode": mode},
)
_log_failed_session_event_post(_EXTERNAL_CODEX_COLLABORATION_MODE_CHANGE_TYPE, response)
if response is not None and response.status_code < 400:
forwarder_state.posted_collaboration_mode = mode
async def _maybe_handle_turn_event(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
method: str,
params: dict[str, Any],
usage_coalescer: _SessionUsageCoalescer,
delta_coalescer: _OutputTextDeltaCoalescer | None,
elicitation_tracker: _CodexElicitationTaskTracker,
codex_client: CodexAppServerClient | None,
forwarder_state: _CodexForwarderState | None,
) -> bool:
"""
Handle turn/thread-level Codex events.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param method: Codex method value, e.g. ``"turn/started"``.
:param params: Codex notification params.
:param usage_coalescer: Token-usage coalescer.
:param delta_coalescer: Optional text-delta coalescer.
:param elicitation_tracker: Background Codex elicitation tracker.
:param codex_client: Optional app-server client for Plan prompts.
:param forwarder_state: Optional forwarder state.
:returns: ``True`` when this event was handled.
"""
if method == "turn/started":
if delta_coalescer is not None:
await delta_coalescer.flush()
await _handle_turn_started(client, session_id, bridge_dir, params)
if forwarder_state is not None:
# A new turn opens a fresh reasoning block: the next reasoning
# delta must emit ``response.reasoning.started`` again.
forwarder_state.reasoning_stream_item_id = None
# An in-TUI ``/model`` switch writes config.toml (the cost-policy
# source of truth) but emits no notification. Re-read it at turn
# start so a switch made since the last turn lands ``model_override``
# on Omnigent before this turn's first tool call reaches the cost gate.
_refresh_model_from_config(bridge_dir, forwarder_state)
await _sync_model_change(
client, session_id=session_id, forwarder_state=forwarder_state
)
return True
if method in {"turn/completed", "turn/failed"}:
await _handle_terminal_turn_boundary(
client,
session_id=session_id,
bridge_dir=bridge_dir,
method=method,
params=params,
usage_coalescer=usage_coalescer,
delta_coalescer=delta_coalescer,
elicitation_tracker=elicitation_tracker,
codex_client=codex_client,
forwarder_state=forwarder_state,
)
return True
if method == "thread/tokenUsage/updated":
_handle_usage_update(usage_coalescer, params, forwarder_state)
# Flush immediately so the web UI cost badge updates live mid-turn.
# Codex emits these only every few seconds; the coalescer dedups, so the
# turn-boundary flush becomes a cheap no-op.
await usage_coalescer.flush()
return True
if method == "thread/settings/updated":
if forwarder_state is not None:
forwarder_state.note_thread_settings_updated(params)
await _sync_model_change(
client, session_id=session_id, forwarder_state=forwarder_state
)
await _sync_reasoning_effort_change(
client, session_id=session_id, forwarder_state=forwarder_state
)
await _sync_codex_collaboration_mode_change(
client, session_id=session_id, forwarder_state=forwarder_state
)
return True
if method == "turn/plan/updated":
if delta_coalescer is not None:
await delta_coalescer.flush()
await _handle_turn_plan_updated(client, session_id, params)
return True
if method == _CODEX_THREAD_COMPACTED_METHOD:
# Codex finished compacting the thread's context window.
await _post_compaction_status(
client, session_id, "completed", forwarder_state=forwarder_state
)
if forwarder_state is None or not forwarder_state.compaction_item_persisted:
try:
await _persist_codex_compaction_item(
client, session_id=session_id, bridge_dir=bridge_dir
)
except Exception: # noqa: BLE001
_logger.warning(
"Failed to persist codex compaction item for %s", session_id, exc_info=True
)
else:
if forwarder_state is not None:
forwarder_state.compaction_item_persisted = True
return True
if method == "turn/diff/updated":
_handle_turn_diff_updated(params, forwarder_state)
return True
return False
async def _maybe_handle_delta_event(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
method: str,
params: dict[str, Any],
delta_coalescer: _OutputTextDeltaCoalescer | None,
forwarder_state: _CodexForwarderState | None,
) -> bool:
"""
Handle Codex streaming text/plan delta events.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param method: Codex method value, e.g.
``"item/agentMessage/delta"``.
:param params: Codex notification params.
:param delta_coalescer: Text-delta coalescer required for delta
events.
:param forwarder_state: Optional forwarder state used to recover a
missed user message before streaming recovered assistant deltas.
:returns: ``True`` when this event was a delta event.
:raises RuntimeError: If a delta event arrives without a text
coalescer.
"""
if method == "item/agentMessage/delta":
if delta_coalescer is None:
raise RuntimeError("Codex assistant delta handling requires a text-delta coalescer")
await _handle_agent_message_delta(
client,
session_id,
bridge_dir,
params,
delta_coalescer,
forwarder_state,
)
return True
if method == "item/plan/delta":
if delta_coalescer is None:
raise RuntimeError("Codex plan delta handling requires a text-delta coalescer")
await _handle_plan_delta(
client,
session_id,
bridge_dir,
params,
delta_coalescer,
forwarder_state,
)
return True
if method in {"item/reasoning/textDelta", "item/reasoning/summaryTextDelta"}:
# Flush any buffered assistant text first so a reasoning delta never
# jumps ahead of earlier-streamed answer text in arrival order.
if delta_coalescer is not None:
await delta_coalescer.flush()
await _handle_reasoning_delta(client, session_id, params, forwarder_state)
return True
return False
async def _handle_completed_event(
client: httpx.AsyncClient,
*,
session_id: str,
params: dict[str, Any],
delta_coalescer: _OutputTextDeltaCoalescer | None,
forwarder_state: _CodexForwarderState | None,
bridge_dir: Path | None = None,
) -> None:
"""
Flush pending text and mirror one completed Codex item.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex ``item/completed`` params.
:param delta_coalescer: Optional text-delta coalescer to flush
before the completed item.
:param forwarder_state: Optional state that records completed
Plan-mode items.
:returns: None.
"""
if delta_coalescer is not None:
await delta_coalescer.flush()
if forwarder_state is not None:
forwarder_state.record_completed_plan(params)
await _handle_completed_item(
client, session_id, params, forwarder_state=forwarder_state, bridge_dir=bridge_dir
)
async def _handle_terminal_turn_boundary(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
method: str,
params: dict[str, Any],
usage_coalescer: _SessionUsageCoalescer,
delta_coalescer: _OutputTextDeltaCoalescer | None,
elicitation_tracker: _CodexElicitationTaskTracker,
codex_client: CodexAppServerClient | None,
forwarder_state: _CodexForwarderState | None,
) -> None:
"""
Handle a Codex terminal turn completion/failure boundary.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param method: Codex method, e.g. ``"turn/completed"``.
:param params: Codex notification params.
:param usage_coalescer: Coalescer holding latest token usage.
:param delta_coalescer: Optional text-delta coalescer to flush
before terminal status and usage.
:param elicitation_tracker: Background Codex elicitation tracker.
:param codex_client: Optional app-server client used for
synthesized Plan-mode implementation prompts.
:param forwarder_state: Optional Plan-mode prompt state.
:returns: None.
"""
if delta_coalescer is not None:
await delta_coalescer.flush()
# Safety net: if a compaction was reported in progress but Codex never
# emitted a completion signal we recognize (e.g. a protocol-spelling
# drift), force the spinner closed at the turn boundary so it can't hang.
if forwarder_state is not None and forwarder_state.compaction_status_posted == "in_progress":
await _post_compaction_status(
client, session_id, "completed", forwarder_state=forwarder_state
)
await _maybe_persist_interrupted_partial_text(
client,
session_id=session_id,
method=method,
params=params,
forwarder_state=forwarder_state,
)
await _flush_turn_diff(
client,
session_id=session_id,
params=params,
forwarder_state=forwarder_state,
)
handled = await _handle_terminal_turn_event(client, session_id, bridge_dir, method, params)
if handled:
await elicitation_tracker.resolve_by_terminal_turn_event(
client,
session_id=session_id,
params=params,
)
if (
handled
and method == "turn/completed"
and codex_client is not None
and forwarder_state is not None
):
await _maybe_handle_plan_implementation_prompt(
client,
codex_client,
session_id=session_id,
bridge_dir=bridge_dir,
params=params,
forwarder_state=forwarder_state,
)
if handled:
await usage_coalescer.flush()
def _handle_usage_update(
usage_coalescer: _SessionUsageCoalescer,
params: dict[str, Any],
forwarder_state: _CodexForwarderState | None = None,
) -> None:
"""
Record a Codex usage notification without blocking visible output.
:param usage_coalescer: Coalescer receiving latest token usage.
:param params: Codex ``thread/tokenUsage/updated`` params.
:param forwarder_state: Optional forwarder state; its ``model`` is
attached to the post so the server can price cumulative tokens.
:returns: None.
"""
model = forwarder_state.model if forwarder_state is not None else None
usage_coalescer.record(params, model=model)
async def _handle_turn_plan_updated(
client: httpx.AsyncClient,
session_id: str,
params: dict[str, Any],
) -> None:
"""
Mirror a Codex plan update as a visible assistant message.
Codex emits plan changes as app-server notifications rather than
ordinary assistant text. Omnigent web currently renders persisted message
items, not a dedicated plan item type, so the native bridge converts
the structured plan into a compact assistant message.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex ``turn/plan/updated`` params.
:returns: None.
"""
text = _plan_text_from_update(params)
if not text:
return
await _post_external_item(
client,
session_id,
item_type="message",
item_data={
"role": "assistant",
"agent": _AGENT_NAME,
"content": [{"type": "output_text", "text": text}],
},
response_id=_response_id(params),
)
def _handle_turn_diff_updated(
params: dict[str, Any],
forwarder_state: _CodexForwarderState | None,
) -> None:
"""
Record the latest aggregated working-tree diff for the active turn.
Codex emits ``turn/diff/updated`` repeatedly as a turn's edits land,
each carrying the full unified diff so far. Posting every update would
spam the transcript with a growing diff, so the forwarder only stashes
the newest diff here and flushes it once at the terminal turn boundary
(:func:`_flush_turn_diff`). A no-op without ``forwarder_state`` (child
threads / tests that bypass ``supervise_forwarder``).
:param params: Codex ``turn/diff/updated`` params, e.g.
``{"threadId": "thread_1", "turnId": "turn_1", "diff": "--- a/x\\n..."}``.
:param forwarder_state: Optional mutable state holding per-turn diffs.
:returns: None.
"""
if forwarder_state is None:
return
turn_id = _turn_id_from_payload(params)
if turn_id is None:
return
diff = params.get("diff")
forwarder_state.note_turn_diff(turn_id, diff if isinstance(diff, str) else "")
async def _handle_mcp_startup_status(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
params: dict[str, Any],
) -> None:
"""
Mirror one Codex MCP-server startup update.
Records the update into the bridge dir (the Stop path and turn-error
text read it) and republishes the full per-server map to Omnigent so the
web session shows startup progress. In practice codex delivers these
edges only to the thread-owning connection (see the comment on
:data:`_CODEX_MCP_STARTUP_STATUS_METHOD`); when they do arrive they
carry real terminal states and supersede the synthesized round.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param params: Codex ``mcpServer/startupStatus/updated`` params, e.g.
``{"name": "safe", "status": "failed", "error": "..."}``.
:returns: None.
"""
name = params.get("name")
status = params.get("status")
if not (isinstance(name, str) and name and status in MCP_STARTUP_STATES):
_logger.info("Codex forwarder ignored malformed MCP startup update: %r", params)
return
error = params.get("error")
servers = update_mcp_server_startup(
bridge_dir,
name,
status,
error=error if isinstance(error, str) and error else None,
)
await _post_mcp_startup(client, session_id, servers)
def _expected_mcp_servers_from_config(bridge_dir: Path) -> list[str]:
"""
Read the enabled MCP server names from the session's Codex config.
The per-session ``config.toml`` (private ``CODEX_HOME``) is what the
app-server loads, so its ``[mcp_servers.*]`` tables are exactly the
servers codex boots at thread start — including the injected
``omnigent`` relay server. Codex-internal servers that are not
config-declared (e.g. ``codex_apps``) are not visible here and are
simply absent from the synthesized round.
:param bridge_dir: Native Codex bridge directory.
:returns: Sorted enabled server names, e.g. ``["omnigent", "safe"]``.
Empty when the config is missing or unparsable.
"""
import tomllib
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
try:
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError):
return []
servers = config.get("mcp_servers")
if not isinstance(servers, dict):
return []
return sorted(
name
for name, table in servers.items()
if isinstance(name, str)
and name
and isinstance(table, dict)
and table.get("enabled") is not False
)
def _mcp_startup_settle_timeout_seconds(bridge_dir: Path) -> float:
"""
Derive the synthesized round's settle window from the session config.
Codex bounds each server's spawn+handshake by its per-server
``startup_timeout_sec`` (default
:data:`_MCP_STARTUP_DEFAULT_TIMEOUT_SECONDS`), so the round cannot
outlive the slowest server's budget; a grace period absorbs spawn
overhead and the cap keeps a misconfigured budget from pinning the
band for many minutes.
:param bridge_dir: Native Codex bridge directory.
:returns: Settle timeout in seconds, e.g. ``135.0`` for a config whose
slowest server declares ``startup_timeout_sec = 120``.
"""
import tomllib
slowest = _MCP_STARTUP_DEFAULT_TIMEOUT_SECONDS
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
try:
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError):
config = {}
servers = config.get("mcp_servers")
if isinstance(servers, dict):
for table in servers.values():
# Same enabled filter as _expected_mcp_servers_from_config:
# codex never boots a disabled server, so its budget must not
# stretch the window for a round it is not part of.
if not isinstance(table, dict) or table.get("enabled") is False:
continue
timeout = table.get("startup_timeout_sec")
if isinstance(timeout, (int, float)) and timeout > slowest:
slowest = float(timeout)
return min(slowest + _MCP_STARTUP_SETTLE_GRACE_SECONDS, _MCP_STARTUP_SETTLE_MAX_SECONDS)
def _arm_mcp_settle_timer(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
) -> asyncio.Task[None]:
"""
Arm the bounded settle window for an in-flight MCP startup round.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:returns: The settle-timer task.
"""
timeout = _mcp_startup_settle_timeout_seconds(bridge_dir)
async def settle_after_window() -> None:
"""Settle the synthesized round once the startup window elapses."""
await _sleep(timeout)
await _settle_mcp_startup(
client, session_id=session_id, bridge_dir=bridge_dir, reason="startup window elapsed"
)
return asyncio.create_task(settle_after_window(), name="codex-native-mcp-settle")
async def _seed_mcp_startup_round(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
) -> asyncio.Task[None] | None:
"""
Record the config-declared MCP servers as ``starting`` and post them.
Seeds once per app-server launch: ``clear_bridge_state`` wipes the
recorded map before each launch, and an existing map means a
forwarder reconnect mid-session — reseeding then would flash a false
"starting" band for servers that finished booting long ago. A
reconnect that finds the round still pending does re-arm the settle
window, though: the previous forwarder's timer died with it, and
without a replacement a missed idle edge would leave the band stuck
on "starting" for the rest of the session.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:returns: The armed settle-timer task, or ``None`` when the recorded
round has already settled.
"""
existing = read_mcp_startup(bridge_dir)
if existing:
if not pending_mcp_servers(existing):
return None
_logger.info("Codex MCP startup round still pending after reconnect; re-arming settle")
return _arm_mcp_settle_timer(client, session_id=session_id, bridge_dir=bridge_dir)
expected = _expected_mcp_servers_from_config(bridge_dir)
if not expected:
return None
servers: dict[str, dict[str, str | None]] = {}
for name in expected:
servers = update_mcp_server_startup(bridge_dir, name, MCP_STARTUP_STARTING)
_logger.info("Codex MCP startup round synthesized: %s", ", ".join(expected))
await _post_mcp_startup(client, session_id, servers)
return _arm_mcp_settle_timer(client, session_id=session_id, bridge_dir=bridge_dir)
async def _settle_mcp_startup(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
reason: str,
) -> None:
"""
Settle the synthesized MCP startup round, if any of it is unresolved.
Drops still-``starting`` entries from the bridge map (their real
terminal states are only ever delivered to the thread-owning
connection) and posts the settled map so the web band clears.
Locally-recorded terminal states — ``cancelled`` from a Stop — are
preserved. Idempotent: a fully settled map is left untouched.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param reason: Settle trigger for logs, e.g. ``"thread went idle"``.
:returns: None.
"""
servers, changed = settle_pending_mcp_startup(bridge_dir)
if not changed:
return
_logger.info("Codex MCP startup round settled (%s)", reason)
await _post_mcp_startup(client, session_id, servers)
def _is_thread_idle_status_event(method: str, params: dict[str, Any]) -> bool:
"""
Return whether an event reports the thread going idle.
Codex defers turn execution until MCP startup settles, so a thread
reaching ``idle`` after a turn proves the startup round is over. This
is one of the few notifications codex broadcasts to non-owning
connections, making it the natural live settle signal for the
synthesized round.
:param method: Codex method value, e.g. ``"thread/status/changed"``.
:param params: Codex notification params.
:returns: ``True`` for an idle ``thread/status/changed``.
"""
if method != _CODEX_THREAD_STATUS_CHANGED_METHOD:
return False
status = params.get("status")
return isinstance(status, dict) and status.get("type") == "idle"
async def _post_mcp_startup(
client: httpx.AsyncClient,
session_id: str,
servers: dict[str, dict[str, str | None]],
) -> None:
"""
Post the current per-MCP-server startup map to Omnigent.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param servers: Full startup map, e.g.
``{"safe": {"status": "starting", "error": None}}``.
:returns: None.
"""
response = await _post_session_event(
client,
session_id,
event_type=_EXTERNAL_MCP_STARTUP_TYPE,
data={"servers": servers},
)
_log_failed_session_event_post(_EXTERNAL_MCP_STARTUP_TYPE, response)
def _is_codex_elicitation_request(event: CodexMessage) -> bool:
"""
Return whether an app-server frame asks this client for input.
:param event: Codex app-server envelope.
:returns: ``True`` for supported server-to-client request methods
that include a JSON-RPC id.
"""
return (
_is_codex_request_id(event.get("id"))
and isinstance(event.get("method"), str)
and event["method"] in _CODEX_ELICITATION_REQUEST_METHODS
)
async def _handle_codex_elicitation_request(
client: httpx.AsyncClient,
codex_client: CodexAppServerClient,
*,
session_id: str,
event: CodexMessage,
) -> None:
"""
Forward one Codex input request to Omnigent and reply to app-server.
The Omnigent hook publishes the web elicitation and blocks until the
user answers or the wait budget expires. Non-empty 2xx responses
are Codex JSON-RPC ``result`` payloads and are sent back to the
app-server with the original request id. Empty 2xx responses mean
Omnigent timed out or saw the upstream disconnect, so the forwarder
leaves the request unanswered for the native Codex UI path.
:param client: HTTP client for Omnigent hook posts.
:param codex_client: Connected Codex app-server client used to
send JSON-RPC results.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param event: Codex JSON-RPC request envelope.
:returns: None.
"""
request_id = event.get("id")
result = await _codex_elicitation_hook_result(
client,
session_id,
event=event,
)
if result is None:
return
await codex_client.respond(request_id, result)
async def _codex_elicitation_hook_result(
client: httpx.AsyncClient,
session_id: str,
*,
event: CodexMessage,
) -> dict[str, Any] | None:
"""
POST a Codex-shaped elicitation request and parse its result body.
Empty 2xx responses mean Omnigent timed out or saw the upstream
disconnect, so the caller should leave the native Codex request
unanswered or drop a synthetic prompt.
:param client: HTTP client for Omnigent hook posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param event: Codex JSON-RPC request envelope.
:returns: Parsed JSON-RPC result payload, or ``None``.
"""
method = event.get("method")
request_id = event.get("id")
response = await _post_codex_elicitation_request(
client,
session_id,
event=event,
)
if response is None:
return None
if response.status_code >= 400:
_logger.warning(
"Codex elicitation hook rejected request: method=%s status=%s body=%s",
method,
response.status_code,
response.text[:512],
)
return None
if not response.content:
_logger.info(
"Codex elicitation hook returned empty body; leaving app-server request pending: "
"method=%s request_id=%r",
method,
request_id,
)
return None
try:
result = response.json()
except ValueError:
_logger.warning(
"Codex elicitation hook returned non-JSON body: method=%s body=%s",
method,
response.text[:512],
)
return None
if not isinstance(result, dict):
_logger.warning(
"Codex elicitation hook returned non-object result: method=%s result=%r",
method,
result,
)
return None
return result
async def _elicitation_retry_sleep(seconds: float) -> None:
"""
Indirection over :func:`asyncio.sleep` for the elicitation re-POST
backoff, so tests can stub it without clobbering the process-global
``asyncio.sleep``.
:param seconds: Seconds to sleep, e.g. ``1.0``.
:returns: None.
"""
await asyncio.sleep(seconds)
async def _post_codex_elicitation_request(
client: httpx.AsyncClient,
session_id: str,
*,
event: CodexMessage,
) -> httpx.Response | None:
"""
POST a Codex server-to-client request to the Omnigent hook endpoint,
re-POSTing across severed long-polls.
This is deliberately separate from ``_post_session_event``:
elicitation hook posts are long-poll request/reply calls, not
idempotent event writes. Proxies sever long-polls and the server can
restart mid-wait; a single failed POST used to abandon the prompt to
the native-TUI path — invisible for a headless sub-agent session.
Codex elicitation ids are deterministic per (session, method, rpc id),
so a re-POST of the same envelope re-parks the SAME elicitation
server-side (keeping the approval card alive) and can collect a
verdict that landed between attempts via the server's pre-resolved
tombstone. Retries transport errors and 5xx responses within the
``_CODEX_ELICITATION_REQUEST_TIMEOUT_SECONDS`` budget; 2xx and 4xx
responses are final.
:param client: HTTP client for Omnigent hook posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param event: Codex JSON-RPC request envelope.
:returns: The final hook response, or ``None`` when the retry budget
ran out — the caller leaves the native request unanswered, as
before.
"""
url = f"/v1/sessions/{url_component(session_id)}/hooks/codex-elicitation-request"
timeout = httpx.Timeout(
_CODEX_ELICITATION_REQUEST_TIMEOUT_SECONDS,
connect=_CODEX_ELICITATION_CONNECT_TIMEOUT_SECONDS,
)
loop = asyncio.get_running_loop()
deadline = loop.time() + _CODEX_ELICITATION_REQUEST_TIMEOUT_SECONDS
backoff_s = _CODEX_ELICITATION_RETRY_INITIAL_BACKOFF_SECONDS
while True:
response: httpx.Response | None = None
try:
response = await client.post(url, json=event, timeout=timeout)
except httpx.HTTPError:
_logger.warning(
"Codex elicitation hook POST failed; retrying: method=%s",
event.get("method"),
exc_info=True,
)
if response is not None and response.status_code < 500:
return response
if response is not None:
# 5xx = proxy gateway error on a severed long-poll, or a
# restarting server — the verdict may still be pending.
_logger.warning(
"Codex elicitation hook returned %s; retrying: method=%s",
response.status_code,
event.get("method"),
)
if loop.time() + backoff_s >= deadline:
_logger.warning(
"Codex elicitation hook retry budget exhausted: method=%s",
event.get("method"),
)
return None
await _elicitation_retry_sleep(backoff_s)
backoff_s = min(backoff_s * 2, _CODEX_ELICITATION_RETRY_MAX_BACKOFF_SECONDS)
def _note_native_plan_implementation_prompt(
forwarder_state: _CodexForwarderState,
event: CodexMessage,
) -> None:
"""
Dedupe against Codex builds that emit the Plan prompt natively.
The current Codex TUI owns the final Plan-mode picker locally, but
if a future app-server starts emitting it as ``requestUserInput``,
the Omnigent bridge should relay that native request and skip its
synthetic fallback for the same turn.
:param forwarder_state: Mutable forwarder state.
:param event: Codex server-to-client request envelope.
:returns: None.
"""
if event.get("method") != _CODEX_TOOL_REQUEST_USER_INPUT_METHOD:
return
params = event.get("params")
if not isinstance(params, dict):
return
if not _is_plan_implementation_request_user_input(params):
return
turn_id = _turn_id_from_payload(params)
if turn_id is not None:
forwarder_state.mark_prompted(turn_id)
def _is_plan_implementation_request_user_input(params: dict[str, Any]) -> bool:
"""
Return whether ``requestUserInput`` is the Plan implementation picker.
:param params: Codex ``item/tool/requestUserInput`` params.
:returns: ``True`` for the final Plan-mode implementation prompt.
"""
questions = params.get("questions")
if not isinstance(questions, list):
return False
for question in questions:
if not isinstance(question, dict):
continue
if question.get("id") == _PLAN_IMPLEMENTATION_QUESTION_ID:
return True
if question.get("question") == _PLAN_IMPLEMENTATION_TITLE:
return True
return False
async def _maybe_handle_plan_implementation_prompt(
client: httpx.AsyncClient,
codex_client: CodexAppServerClient,
*,
session_id: str,
bridge_dir: Path,
params: dict[str, Any],
forwarder_state: _CodexForwarderState,
) -> None:
"""
Publish and resolve the Plan-mode implementation prompt in Omnigent Web.
Codex's terminal UI asks ``Implement this plan?`` after a completed
Plan-mode turn, but that picker is local to the TUI. The app-server
does emit the completed ``plan`` item, so the forwarder synthesizes
the same user-facing question through the existing Codex
``requestUserInput`` hook and starts the selected follow-up turn.
:param client: HTTP client for Omnigent hook posts.
:param codex_client: Connected Codex app-server client.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param params: Codex ``turn/completed`` params.
:param forwarder_state: Mutable forwarder state.
:returns: None.
"""
turn_id = _turn_id_from_payload(params.get("turn")) or _turn_id_from_payload(params)
if turn_id is None:
return
context = forwarder_state.plan_prompt_context(turn_id)
if context is None:
return
thread_id, plan_text = context
forwarder_state.mark_prompted(turn_id)
result = await _codex_elicitation_hook_result(
client,
session_id,
event=_plan_implementation_request_event(thread_id, turn_id),
)
selected = _selected_plan_implementation_answer(result)
if selected == _PLAN_IMPLEMENTATION_NO or selected is None:
return
if selected == _PLAN_IMPLEMENTATION_YES:
await _start_plan_implementation_turn(
codex_client,
bridge_dir=bridge_dir,
thread_id=thread_id,
text=_PLAN_IMPLEMENTATION_CODING_MESSAGE,
forwarder_state=forwarder_state,
)
return
if selected == _PLAN_IMPLEMENTATION_CLEAR_CONTEXT:
await _start_clear_context_plan_implementation_turn(
codex_client,
bridge_dir=bridge_dir,
plan_text=plan_text,
forwarder_state=forwarder_state,
)
def _plan_implementation_request_event(thread_id: str, turn_id: str) -> CodexMessage:
"""
Build a Codex ``requestUserInput`` request for the Plan prompt.
:param thread_id: Codex thread id, e.g. ``"thread_123"``.
:param turn_id: Codex turn id that produced the plan, e.g.
``"turn_123"``.
:returns: Codex JSON-RPC request envelope.
"""
return {
"id": f"plan_implementation:{turn_id}",
"method": _CODEX_TOOL_REQUEST_USER_INPUT_METHOD,
"params": {
"threadId": thread_id,
"turnId": turn_id,
"itemId": f"{turn_id}:plan_implementation",
"questions": [
{
"id": _PLAN_IMPLEMENTATION_QUESTION_ID,
"header": "Plan",
"question": _PLAN_IMPLEMENTATION_TITLE,
"isOther": False,
"isSecret": False,
"options": [
{
"label": _PLAN_IMPLEMENTATION_YES,
"description": "Switch to Default and start coding.",
},
{
"label": _PLAN_IMPLEMENTATION_CLEAR_CONTEXT,
"description": "Fresh thread with this plan.",
},
{
"label": _PLAN_IMPLEMENTATION_NO,
"description": "Continue planning with the model.",
},
],
}
],
},
}
def _selected_plan_implementation_answer(result: dict[str, Any] | None) -> str | None:
"""
Extract the selected Plan prompt label from a Codex hook result.
:param result: Codex ``requestUserInput`` result payload.
:returns: Selected option label, or ``None`` when absent.
"""
if result is None:
return None
answers = result.get("answers")
if not isinstance(answers, dict):
return None
question_answer = answers.get(_PLAN_IMPLEMENTATION_QUESTION_ID)
if not isinstance(question_answer, dict):
return None
values = question_answer.get("answers")
if not isinstance(values, list) or not values:
return None
selected = values[0]
return selected if isinstance(selected, str) and selected else None
async def _start_plan_implementation_turn(
codex_client: CodexAppServerClient,
*,
bridge_dir: Path,
thread_id: str,
text: str,
forwarder_state: _CodexForwarderState,
) -> None:
"""
Start a Codex Default-mode implementation turn on an existing thread.
:param codex_client: Connected Codex app-server client.
:param bridge_dir: Native Codex bridge directory.
:param thread_id: Codex thread id, e.g. ``"thread_123"``.
:param text: User input for the turn.
:param forwarder_state: Mutable state with the current model.
:returns: None.
"""
collaboration_mode = _default_collaboration_mode(forwarder_state)
if collaboration_mode is None:
_logger.warning("Codex plan implementation skipped: current model is unknown")
return
response = await codex_client.request(
"turn/start",
{
"threadId": thread_id,
"input": [{"type": "text", "text": text}],
"collaborationMode": collaboration_mode,
},
)
turn_id = response.get("result", {}).get("turn", {}).get("id")
if isinstance(turn_id, str) and turn_id:
update_active_turn_id(bridge_dir, turn_id)
async def _start_clear_context_plan_implementation_turn(
codex_client: CodexAppServerClient,
*,
bridge_dir: Path,
plan_text: str,
forwarder_state: _CodexForwarderState,
) -> None:
"""
Start a fresh Codex thread and implement the completed plan there.
:param codex_client: Connected Codex app-server client.
:param bridge_dir: Native Codex bridge directory.
:param plan_text: Completed plan markdown from the prior thread.
:param forwarder_state: Mutable state with the current model.
:returns: None.
"""
if not forwarder_state.model:
_logger.warning(
"Codex clear-context plan implementation skipped: current model is unknown"
)
return
thread_response = await codex_client.request(
"thread/start",
{"model": forwarder_state.model, "sessionStartSource": "clear"},
)
thread_id = thread_response.get("result", {}).get("thread", {}).get("id")
if not isinstance(thread_id, str) or not thread_id:
_logger.warning("Codex clear-context plan implementation skipped: new thread id missing")
return
update_thread_id(bridge_dir, thread_id)
text = f"{_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n{plan_text}"
await _start_plan_implementation_turn(
codex_client,
bridge_dir=bridge_dir,
thread_id=thread_id,
text=text,
forwarder_state=forwarder_state,
)
def _default_collaboration_mode(
forwarder_state: _CodexForwarderState,
) -> dict[str, Any] | None:
"""
Build Codex's Default collaboration mode for ``turn/start``.
``developer_instructions: null`` deliberately asks Codex
app-server to fill in the built-in Default-mode instructions via
its own normalization path.
:param forwarder_state: Mutable state with the current model.
:returns: Codex ``CollaborationMode`` JSON object, or ``None``.
"""
if not forwarder_state.model:
return None
return {
"mode": "default",
"settings": {
"model": forwarder_state.model,
"reasoning_effort": None,
"developer_instructions": None,
},
}
async def _handle_turn_started(
client: httpx.AsyncClient,
session_id: str,
bridge_dir: Path,
params: dict[str, Any],
) -> None:
"""
Forward a Codex terminal turn start event.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param params: Codex ``turn/started`` params.
:returns: None.
"""
edge = _turn_started_status_edge(bridge_dir, params)
await _post_turn_status_edge(client, session_id, edge)
def _turn_started_status_edge(
bridge_dir: Path,
params: dict[str, Any],
) -> _CodexTurnStatusEdge:
"""
Record a Codex turn start and return the Omnigent running edge.
:param bridge_dir: Native Codex bridge directory.
:param params: Codex ``turn/started`` params.
:returns: Running status edge for the observed turn start.
"""
turn = params.get("turn")
turn_id = _turn_id_from_payload(turn) or _turn_id_from_payload(params)
update_active_turn_id(bridge_dir, turn_id)
return _CodexTurnStatusEdge(
status="running",
turn_id=turn_id,
source="turn/started",
)
async def _handle_terminal_turn_event(
client: httpx.AsyncClient,
session_id: str,
bridge_dir: Path,
method: str,
params: dict[str, Any],
) -> bool:
"""
Forward a terminal-observed Codex turn completion/failure event.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param method: Codex method, e.g. ``"turn/completed"``.
:param params: Codex turn event params.
:returns: ``True`` when the terminal event belonged to the active
turn and was forwarded, ``False`` when it was stale.
"""
edge = _terminal_turn_status_edge(bridge_dir, method, params)
if edge is None:
terminal_turn_id = _terminal_turn_id_from_params(params)
_logger.info(
"Codex forwarder ignored stale terminal turn event: method=%s turn_id=%s",
method,
terminal_turn_id,
)
return False
await _post_turn_status_edge(client, session_id, edge)
return True
def _terminal_turn_status_edge(
bridge_dir: Path,
method: str,
params: dict[str, Any],
) -> _CodexTurnStatusEdge | None:
"""
Return the terminal Omnigent edge for a Codex terminal turn event.
The edge is produced when the event clears the recorded active turn, or
when it safely recovers a missed ``turn/started`` for the bridge's current
thread. Stale or ambiguous terminal events return ``None``.
:param bridge_dir: Native Codex bridge directory.
:param method: Codex terminal method, e.g. ``"turn/completed"``.
:param params: Codex turn event params.
:returns: Terminal status edge, or ``None`` when the event is stale.
"""
terminal_turn_id = _terminal_turn_id_from_params(params)
if not clear_active_turn_id_if_matches(bridge_dir, terminal_turn_id):
if not _terminal_turn_boundary_matches_idle_bridge(bridge_dir, params, terminal_turn_id):
return None
source = f"{method}:recovered"
else:
source = method
# A failed turn carries ``turn.error`` (or an ``error`` item) even when
# Codex reports it via ``turn/completed`` ("silent success"). Force
# ``failed`` and attach the error so the reason is surfaced downstream.
error = _terminal_error_from_turn(params)
if error is not None:
_logger.info(
"Codex forwarder forcing failed status from turn.error: turn_id=%s method=%s kind=%s",
terminal_turn_id,
method,
error.kind,
)
return _CodexTurnStatusEdge(
status="failed",
turn_id=terminal_turn_id,
source=f"{source}:turn-error",
error=error,
)
if _turn_status_is_failed(params):
_logger.info(
"Codex forwarder forcing failed status from turn.status: turn_id=%s method=%s",
terminal_turn_id,
method,
)
return _CodexTurnStatusEdge(
status="failed",
turn_id=terminal_turn_id,
source=f"{source}:turn-failed",
)
if method == "turn/completed" and _turn_items_are_empty(params):
_logger.warning(
"Codex forwarder observed an empty turn (zero items): "
"turn_id=%s method=%s; mapping to idle",
terminal_turn_id,
method,
)
return _CodexTurnStatusEdge(
status="idle" if method == "turn/completed" else "failed",
turn_id=terminal_turn_id,
source=source,
)
def _turn_status_is_failed(params: dict[str, Any]) -> bool:
"""
Report whether a Codex turn recorded a ``failed`` status.
Catches a failure that lacks a populated ``turn.error`` object, so a
``turn/completed`` whose ``turn.status`` is ``failed`` still maps to
``failed`` rather than ``idle``.
:param params: Codex turn event params.
:returns: ``True`` when ``params['turn']['status']`` resolves to ``failed``.
"""
turn = params.get("turn")
if not isinstance(turn, dict):
return False
status = turn.get("status")
if isinstance(status, dict):
status = status.get("type") or status.get("status")
return status in {"failed", "errored"}
def _turn_items_are_empty(params: dict[str, Any]) -> bool:
"""
Report whether a Codex turn explicitly carried zero items.
Only an explicitly present but empty ``items`` list counts as "empty":
a missing ``items`` key (e.g. a legacy ``turnId``-only terminal
notification) is unknown, not empty, and must not trip the WARN.
:param params: Codex turn event params.
:returns: ``True`` when ``params['turn']['items']`` is a zero-length list.
"""
turn = params.get("turn")
if not isinstance(turn, dict):
return False
items = turn.get("items")
return isinstance(items, list) and len(items) == 0
def _terminal_turn_id_from_params(params: dict[str, Any]) -> str | None:
"""
Extract the terminal turn id from Codex turn-boundary params.
:param params: Codex ``turn/completed`` / ``turn/failed`` params.
:returns: Codex turn id, e.g. ``"turn_abc123"``, or ``None``.
"""
turn = params.get("turn")
return _turn_id_from_payload(turn) or _turn_id_from_payload(params)
def _terminal_turn_boundary_matches_idle_bridge(
bridge_dir: Path,
params: dict[str, Any],
terminal_turn_id: str | None,
) -> bool:
"""
Return whether a terminal boundary can close a missed-start turn.
A Codex listener can miss ``turn/started`` while reconnecting. If no
active turn is recorded, but a later ``turn/completed`` / ``turn/failed``
event carries the current thread id, the forwarder may safely publish the
terminal status edge. If another active turn is recorded, the event is
stale or ambiguous and must stay ignored.
:param bridge_dir: Native Codex bridge directory.
:param params: Codex terminal turn event params.
:param terminal_turn_id: Terminal turn id from the event, e.g.
``"turn_abc123"``.
:returns: ``True`` when the event belongs to the bridge's current idle
thread and can publish the terminal status edge.
"""
if terminal_turn_id is None:
return False
state = read_bridge_state(bridge_dir)
if state is None or state.active_turn_id is not None:
return False
return _thread_id_from_params(params) == state.thread_id
def _claim_completed_item(
params: dict[str, Any],
item: dict[str, Any],
forwarder_state: _CodexForwarderState | None,
) -> bool:
"""
Claim one completed Codex transcript item for Omnigent posting.
Returns ``True`` when the caller should post the item; ``False`` when
it was already posted this connection (dedup gate). Also advances the
anonymous-item counter on a successful claim so the next anonymous
item in the same (thread, turn) gets a fresh key.
When ``forwarder_state`` is ``None``, dedup is disabled and the
function always returns ``True`` (used in tests that bypass
``supervise_forwarder``).
:param params: Codex ``item/completed`` params.
:param item: Codex item payload.
:param forwarder_state: Optional mutable state holding synced-item
keys and anonymous-item counters.
:returns: ``True`` when the item should be posted to AP.
"""
if forwarder_state is None:
return True
item_key, is_anon = _completed_item_key(params, item, forwarder_state)
if not forwarder_state.claim_item_key(item_key):
return False
if is_anon:
thread_id = _thread_id_from_params(params) or "thread"
turn_id = params.get("turnId")
turn_id = turn_id if isinstance(turn_id, str) and turn_id else "turn"
forwarder_state.advance_anon_counter(thread_id, turn_id)
return True
async def _handle_completed_item(
client: httpx.AsyncClient,
session_id: str,
params: dict[str, Any],
*,
forwarder_state: _CodexForwarderState | None = None,
bridge_dir: Path | None = None,
) -> None:
"""
Forward one Codex completed item event when it maps to Omnigent history.
Deduplicates via ``_claim_completed_item`` so replay and live deliveries
of the same item only write once. Collab items are dispatched separately.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex ``item/completed`` params.
:param forwarder_state: Optional mutable state for dedup tracking.
:returns: None.
"""
item = params.get("item")
if not isinstance(item, dict):
return
item_type = item.get("type")
turn_id = _turn_id_from_payload(params)
if item_type in {"agentMessage", "plan"} and forwarder_state is not None and turn_id:
item_id = item.get("id")
forwarder_state.discard_partial_text_item(
turn_id=turn_id,
item_type=item_type,
item_id=item_id if isinstance(item_id, str) and item_id else None,
)
_logger.info(
"Codex forwarder observed completed item: turn_id=%s item_type=%s",
turn_id,
item_type,
)
# Collab-agent items register child sessions; they do not append transcript
# records and must not go through the dedup gate.
if item_type == _CODEX_COLLAB_AGENT_ITEM_TYPE:
if forwarder_state is not None:
await _handle_collab_item(client, params, item, forwarder_state)
return
# A context-compaction item is a status edge, not transcript history:
# clear the compaction spinner. Handled before the dedup gate (it never
# appends an item).
if item_type == _CODEX_COMPACTION_ITEM_TYPE:
await _post_compaction_status(
client, session_id, "completed", forwarder_state=forwarder_state
)
if forwarder_state is None or not forwarder_state.compaction_item_persisted:
try:
await _persist_codex_compaction_item(
client, session_id=session_id, bridge_dir=bridge_dir
)
except Exception: # noqa: BLE001
_logger.warning(
"Failed to persist codex compaction item for %s", session_id, exc_info=True
)
else:
if forwarder_state is not None:
forwarder_state.compaction_item_persisted = True
return
if not _claim_completed_item(params, item, forwarder_state):
return
if item_type == "userMessage":
await _post_user_message(client, session_id, params, item)
if forwarder_state is not None:
turn_id = _turn_id_from_payload(params)
if turn_id:
forwarder_state.note_user_message_posted(turn_id)
return
if item_type == "agentMessage":
# User-before-assistant ordering guarantee. On a fresh thread the
# forwarder subscribes via ``thread/resume`` only after the first
# turn starts, so the early ``userMessage`` event can stream past
# before the subscription lands — it is then recovered only via a
# later resume backfill, which can post it AFTER this reply. Since
# Omnigent assigns each mirrored item a position by POST arrival order
# and the web UI renders strictly by position, that inverts the
# bubbles. Recover and post the turn's user message first so it
# always takes the earlier position.
await _ensure_user_message_posted(client, session_id, params, forwarder_state)
await _post_agent_message(client, session_id, params, item)
return
if item_type == "plan":
await _post_plan_item(client, session_id, params, item)
return
if item_type in _REVIEW_MODE_ITEM_TYPES:
await _post_review_mode_marker(client, session_id, params, item)
return
if item_type in _TOOL_ITEM_TYPES:
await _post_tool_item(client, session_id, params, item)
async def _maybe_persist_interrupted_partial_text(
client: httpx.AsyncClient,
*,
session_id: str,
method: str,
params: dict[str, Any],
forwarder_state: _CodexForwarderState | None,
) -> None:
"""
Mirror an interrupted Codex turn and persist any buffered visible text.
Normal Codex turns emit durable ``item/completed`` records, so their
streamed deltas remain transient. Interrupted turns can end with only
streamed deltas and a terminal ``turn/completed`` status of
``interrupted``. In that case, publish ``session.interrupted`` and
persist the visible partial answer as a real assistant message before
the session goes idle.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param method: Codex terminal method, e.g. ``"turn/completed"``.
:param params: Codex terminal notification params.
:param forwarder_state: Mutable forwarder state carrying partial text.
:returns: None.
"""
if method != "turn/completed":
return
if not _turn_status_is_interrupted(_turn_status_from_params(params)):
return
turn_id = _terminal_turn_id_from_params(params)
response_id = _response_id(_params_with_turn_id(params, turn_id)) if turn_id else None
await _post_session_interrupted(client, session_id, response_id=response_id)
if forwarder_state is None:
return
if turn_id is None:
return
buffers = forwarder_state.consume_partial_text_for_turn(turn_id)
buffers_to_persist = [
buffer
for buffer in buffers
if _claim_partial_text_buffer(params, turn_id, buffer, forwarder_state)
]
text = "".join(buffer.text() for buffer in buffers_to_persist)
if not text:
return
scoped_params = _params_with_turn_id(params, turn_id)
await _ensure_user_message_posted(client, session_id, scoped_params, forwarder_state)
await _post_interrupted_partial_agent_message(client, session_id, scoped_params, text)
def _claim_partial_text_buffer(
params: dict[str, Any],
turn_id: str,
buffer: _PartialTextBuffer,
forwarder_state: _CodexForwarderState,
) -> bool:
"""
Claim the completed-item dedup key for a persisted partial text buffer.
:param params: Codex terminal notification params.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:param buffer: Partial text buffer being persisted.
:param forwarder_state: Mutable forwarder state with item dedup keys.
:returns: ``True`` when the partial buffer should be persisted.
"""
if buffer.item_id is None:
return True
thread_id = _thread_id_from_params(params) or "thread"
return forwarder_state.claim_item_key(f"{thread_id}:{turn_id}:{buffer.item_id}")
async def _post_interrupted_partial_agent_message(
client: httpx.AsyncClient,
session_id: str,
params: dict[str, Any],
text: str,
) -> None:
"""
Persist an interrupted Codex turn's visible partial assistant text.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex turn params including ``turnId``.
:param text: Partial assistant text, e.g. ``"The answer is"``.
:returns: None.
"""
await _post_external_item(
client,
session_id,
item_type="message",
item_data={
"role": "assistant",
"agent": _AGENT_NAME,
"interrupted": True,
"content": [{"type": "output_text", "text": text}],
},
response_id=_response_id(params),
)
async def _flush_turn_diff(
client: httpx.AsyncClient,
*,
session_id: str,
params: dict[str, Any],
forwarder_state: _CodexForwarderState | None,
) -> None:
"""
Post the turn's aggregated working-tree diff as a single tool card.
Codex streams ``turn/diff/updated`` during a turn; the forwarder keeps
only the newest diff (:meth:`_CodexForwarderState.note_turn_diff`) and
flushes it once here, at the terminal turn boundary, so the transcript
is not spammed with a growing diff on every edit. The aggregated diff is
mirrored as a ``turn_diff`` ``function_call`` / ``function_call_output``
pair — the same rail as the per-edit ``apply_patch`` cards — so it reads
as a distinct end-of-turn summary and also captures edits made outside
``fileChange`` items (e.g. via shell ``sed``/redirects). Live-only:
resume backfill replays ``item/completed`` records, not this
notification, so a resumed session relies on the per-edit ``fileChange``
cards instead. Idempotent — the stored diff is consumed on flush, so a
second terminal boundary for the same turn is a no-op.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex terminal turn-boundary params.
:param forwarder_state: Mutable forwarder state holding the stored diff.
:returns: None.
"""
if forwarder_state is None:
return
turn_id = _terminal_turn_id_from_params(params)
if turn_id is None:
return
diff = forwarder_state.consume_turn_diff(turn_id)
if not diff:
return
response_id = _response_id(_params_with_turn_id(params, turn_id))
call_id = f"codex_turn_diff_{turn_id}"
await _post_external_item(
client,
session_id,
item_type="function_call",
item_data={
"agent": _AGENT_NAME,
"name": "turn_diff",
"arguments": "{}",
"call_id": call_id,
},
response_id=response_id,
)
await _post_external_item(
client,
session_id,
item_type="function_call_output",
item_data={"call_id": call_id, "output": diff},
response_id=response_id,
)
async def _handle_collab_item(
client: httpx.AsyncClient,
params: dict[str, Any],
item: dict[str, Any],
forwarder_state: _CodexForwarderState,
) -> None:
"""
Handle a Codex ``collabAgentToolCall`` completed item.
Registers newly discovered child threads and posts Omnigent status updates
from the collab-agent state snapshot in the item. Does not write
durable transcript records — the transcript for each child arrives
via that child's own ``item/completed`` stream.
:param client: HTTP client for Omnigent event posts.
:param params: Codex ``item/completed`` params.
:param item: Codex ``collabAgentToolCall`` item.
:param forwarder_state: Mutable state for child-thread mappings.
:returns: None.
"""
if item.get("tool") != _CODEX_COLLAB_SPAWN_TOOL:
return
parent_session_id = _parent_session_id_from_forwarder_state(forwarder_state)
if parent_session_id is None:
return
parent_thread_id = _collab_parent_thread_id(params, item)
for child_thread_id in _collab_receiver_thread_ids(item):
await _ensure_child_session(
client,
parent_session_id=parent_session_id,
parent_thread_id=parent_thread_id,
child_thread_id=child_thread_id,
item=item,
forwarder_state=forwarder_state,
)
await _post_collab_agent_statuses(client, item=item, forwarder_state=forwarder_state)
def _parent_session_id_from_forwarder_state(
forwarder_state: _CodexForwarderState,
) -> str | None:
"""
Return the parent Omnigent session id stored on the forwarder state.
Set by ``supervise_forwarder`` when the loop starts. Returns ``None``
when called from a context that did not set a parent session (e.g.
direct handler tests that bypass ``supervise_forwarder``).
:param forwarder_state: Mutable forwarder state.
:returns: Parent session id, e.g. ``"conv_parent"``, or ``None``.
"""
return forwarder_state.parent_session_id
async def _ensure_child_session(
client: httpx.AsyncClient,
*,
parent_session_id: str,
parent_thread_id: str | None,
child_thread_id: str,
item: dict[str, Any],
forwarder_state: _CodexForwarderState,
) -> None:
"""
Ensure a Codex child thread has an Omnigent child session row.
Registers the child via ``_register_child_session`` when unknown,
then backfills its history at most once per connection.
:param client: HTTP client for Omnigent event posts.
:param parent_session_id: Parent Omnigent session id, e.g. ``"conv_parent"``.
:param parent_thread_id: Parent Codex thread id, or ``None``.
:param child_thread_id: Codex child thread id, e.g. ``"thread_child"``.
:param item: Codex ``collabAgentToolCall`` item with spawn metadata.
:param forwarder_state: Mutable state for child-thread mappings.
:returns: None.
"""
child_session_id = forwarder_state.session_for_child_thread(child_thread_id)
if child_session_id is None:
child_session_id = await _register_child_session(
client,
parent_session_id=parent_session_id,
parent_thread_id=parent_thread_id,
child_thread_id=child_thread_id,
item=item,
)
if child_session_id is None:
return
forwarder_state.note_child_thread(child_thread_id, child_session_id)
# Backfill is done via the codex_client stored on the state.
codex_client = forwarder_state.codex_client
if codex_client is not None and forwarder_state.needs_child_thread_backfill(child_thread_id):
await _backfill_child_thread(
client,
codex_client,
parent_session_id=parent_session_id,
child_session_id=child_session_id,
child_thread_id=child_thread_id,
forwarder_state=forwarder_state,
)
async def _register_child_session(
client: httpx.AsyncClient,
*,
parent_session_id: str,
parent_thread_id: str | None,
child_thread_id: str,
item: dict[str, Any],
) -> str | None:
"""
POST ``external_codex_subagent_start`` and return the child session id.
:param client: HTTP client for Omnigent event posts.
:param parent_session_id: Parent Omnigent session id, e.g. ``"conv_parent"``.
:param parent_thread_id: Parent Codex thread id, or ``None``.
:param child_thread_id: Codex child thread id, e.g. ``"thread_child"``.
:param item: Codex ``collabAgentToolCall`` item.
:returns: Omnigent child session id, or ``None`` on failure.
"""
data: dict[str, Any] = {"thread_id": child_thread_id}
if parent_thread_id is not None:
data["parent_thread_id"] = parent_thread_id
tool_call_id = item.get("id")
if isinstance(tool_call_id, str) and tool_call_id:
data["tool_call_id"] = tool_call_id
response = await _post_session_event(
client,
parent_session_id,
event_type=_EXTERNAL_CODEX_SUBAGENT_START_TYPE,
data=data,
)
if response is None or response.status_code >= 400:
_log_failed_session_event_post(_EXTERNAL_CODEX_SUBAGENT_START_TYPE, response)
return None
return _extract_child_session_id(response, child_thread_id)
def _extract_child_session_id(
response: httpx.Response,
child_thread_id: str,
) -> str | None:
"""
Extract the child session id from an ``external_codex_subagent_start`` response.
:param response: Omnigent HTTP response.
:param child_thread_id: Codex child thread id for error logging.
:returns: Omnigent child session id, or ``None`` when absent or malformed.
"""
child_session_id = response.json().get("child_session_id")
if not isinstance(child_session_id, str) or not child_session_id:
_logger.warning(
"Codex sub-agent registration missing child_session_id: thread_id=%s",
child_thread_id,
)
return None
return child_session_id
async def _backfill_child_thread(
client: httpx.AsyncClient,
codex_client: CodexAppServerClient,
*,
parent_session_id: str,
child_session_id: str,
child_thread_id: str,
forwarder_state: _CodexForwarderState,
) -> None:
"""
Replay a child thread's backlog and upsert its name metadata.
Called at most once per connection per child (guarded by
``subscribed_child_threads``). Fetches the child's rollout via
``thread/resume``, upserts the nickname/role labels, and replays
any already-completed items. Live items arriving after discovery
flow through the normal routing path; the dedup key prevents
overlap.
:param client: HTTP client for Omnigent event posts.
:param codex_client: Connected Codex app-server client.
:param parent_session_id: Parent Omnigent session id, e.g.
``"conv_parent"``.
:param child_session_id: Omnigent child session id, e.g.
``"conv_child"``.
:param child_thread_id: Codex child thread id, e.g.
``"thread_child"``.
:param forwarder_state: Mutable state for sub-agent mappings.
:returns: None.
"""
response = await _resume_child_thread_or_log(
client, codex_client, child_session_id=child_session_id, child_thread_id=child_thread_id
)
if response is None:
return
await _apply_child_resume(
client,
parent_session_id=parent_session_id,
child_session_id=child_session_id,
child_thread_id=child_thread_id,
response=response,
forwarder_state=forwarder_state,
)
async def _resume_child_thread_or_log(
client: httpx.AsyncClient,
codex_client: CodexAppServerClient,
*,
child_session_id: str,
child_thread_id: str,
) -> CodexMessage | None:
"""
Request ``thread/resume`` for a child thread, logging errors.
:param client: HTTP client for Omnigent status posts on failure.
:param codex_client: Connected Codex app-server client.
:param child_session_id: Omnigent child session id, e.g. ``"conv_child"``.
:param child_thread_id: Codex child thread id, e.g.
``"thread_child"``.
:returns: JSON-RPC response on success, or ``None`` on error.
"""
try:
return await codex_client.request("thread/resume", {"threadId": child_thread_id})
except RuntimeError as exc:
if _is_thread_not_ready_error(exc):
_logger.info("Codex child thread %s not ready yet; skipping backfill", child_thread_id)
else:
_logger.warning(
"Codex forwarder failed to backfill child thread %s",
child_thread_id,
exc_info=True,
)
await _post_status(client, child_session_id, "failed")
return None
async def _apply_child_resume(
client: httpx.AsyncClient,
*,
parent_session_id: str,
child_session_id: str,
child_thread_id: str,
response: CodexMessage,
forwarder_state: _CodexForwarderState,
) -> None:
"""
Upsert child name labels and replay its backlogged transcript.
:param client: HTTP client for Omnigent event posts.
:param parent_session_id: Parent Omnigent session id, e.g. ``"conv_parent"``.
:param child_session_id: Omnigent child session id, e.g. ``"conv_child"``.
:param child_thread_id: Codex child thread id, e.g. ``"thread_child"``.
:param response: Validated ``thread/resume`` response envelope.
:param forwarder_state: Mutable state for sub-agent mappings.
:returns: None.
"""
await _upsert_child_name_from_resume(
client,
parent_session_id=parent_session_id,
child_thread_id=child_thread_id,
response=response,
)
# Seed the session model (sub-agents inherit it) so replayed child token
# usage is priced into the child's total_cost_usd — see _SessionUsageCoalescer.
usage_coalescer = _SessionUsageCoalescer(client, child_session_id, model=forwarder_state.model)
# A fresh tracker is used for child replay rather than the parent's,
# because child items do not trigger elicitation requests on the parent.
child_elicitation_tracker = _CodexElicitationTaskTracker()
try:
await _replay_resume_response(
client,
session_id=child_session_id,
bridge_dir=Path(),
response=response,
usage_coalescer=usage_coalescer,
elicitation_tracker=child_elicitation_tracker,
forwarder_state=forwarder_state,
)
finally:
await child_elicitation_tracker.close()
forwarder_state.note_child_thread_subscribed(child_thread_id)
def _codex_child_name_data(
child_thread_id: str,
thread: dict[str, Any],
) -> dict[str, Any]:
"""
Build the name-metadata payload for a Codex child upsert.
:param child_thread_id: Codex child thread id, e.g. ``"thread_child"``.
:param thread: Codex thread object from a ``thread/resume`` response.
:returns: Data dict with at least ``thread_id``; name fields added when
present on the thread object.
"""
data: dict[str, Any] = {"thread_id": child_thread_id}
agent_nickname = thread.get("agentNickname")
if isinstance(agent_nickname, str) and agent_nickname:
data["agent_nickname"] = agent_nickname
agent_role = thread.get("agentRole")
if isinstance(agent_role, str) and agent_role:
data["agent_role"] = agent_role
source = _thread_spawn_source(thread)
if source is not None:
parent_thread_id = source.get("parent_thread_id")
if isinstance(parent_thread_id, str) and parent_thread_id:
data["parent_thread_id"] = parent_thread_id
prompt = thread.get("preview") or source.get("prompt")
if isinstance(prompt, str) and prompt:
data["prompt"] = prompt
return data
async def _upsert_child_name_from_resume(
client: httpx.AsyncClient,
*,
parent_session_id: str,
child_thread_id: str,
response: CodexMessage,
) -> None:
"""
Upsert ``agent_nickname`` / ``agent_role`` from a child resume response.
Idempotent — the server merges labels. No-ops when the resume carries
no name fields beyond the thread id.
:param client: HTTP client for Omnigent event posts.
:param parent_session_id: Parent Omnigent session id, e.g. ``"conv_parent"``.
:param child_thread_id: Codex child thread id, e.g. ``"thread_child"``.
:param response: Codex ``thread/resume`` response envelope.
:returns: None.
"""
result = response.get("result")
if not isinstance(result, dict):
return
thread = result.get("thread")
if not isinstance(thread, dict):
return
data = _codex_child_name_data(child_thread_id, thread)
if len(data) <= 1:
return
response_obj = await _post_session_event(
client, parent_session_id, event_type=_EXTERNAL_CODEX_SUBAGENT_START_TYPE, data=data
)
_log_failed_session_event_post(_EXTERNAL_CODEX_SUBAGENT_START_TYPE, response_obj)
def _thread_spawn_source(thread: dict[str, Any]) -> dict[str, Any] | None:
"""
Return the ``thread_spawn`` source metadata from a Codex thread object.
:param thread: Codex thread object from a ``thread/started`` or
``thread/resume`` payload.
:returns: The ``thread_spawn`` dict when present, otherwise ``None``.
"""
source = thread.get("source")
if not isinstance(source, dict):
return None
subagent = source.get("subAgent")
if not isinstance(subagent, dict):
return None
thread_spawn = subagent.get("thread_spawn")
return thread_spawn if isinstance(thread_spawn, dict) else None
async def _post_collab_agent_statuses(
client: httpx.AsyncClient,
*,
item: dict[str, Any],
forwarder_state: _CodexForwarderState,
) -> None:
"""
Publish Omnigent status updates from a Codex collab-agent state snapshot.
:param client: HTTP client for Omnigent event posts.
:param item: Codex ``collabAgentToolCall`` item carrying
``agentsStates``.
:param forwarder_state: Mutable state for child-thread mappings.
:returns: None.
"""
states = item.get("agentsStates")
if not isinstance(states, dict):
return
for thread_id, state in states.items():
if not isinstance(thread_id, str) or not isinstance(state, dict):
continue
child_session_id = forwarder_state.session_for_child_thread(thread_id)
if child_session_id is None:
continue
ap_status = _omnigent_status_from_collab_state(state)
if ap_status is not None:
await _post_status(client, child_session_id, ap_status)
def _omnigent_status_from_collab_state(state: dict[str, Any]) -> str | None:
"""
Convert a Codex collab-agent state dict to an Omnigent session status.
:param state: Codex ``CollabAgentState`` dict, e.g.
``{"status": "running"}``.
:returns: Omnigent status literal, e.g. ``"running"``, or ``None`` when
the Codex status is unrecognized.
"""
status = state.get("status")
if status in _CODEX_COLLAB_RUNNING_STATUSES:
return "running"
if status in _CODEX_COLLAB_FAILED_STATUSES:
return "failed"
if status in {"completed", "interrupted", "shutdown"}:
return "idle"
return None
def _collab_receiver_thread_ids(item: dict[str, Any]) -> list[str]:
"""
Extract receiver thread ids from a Codex collab-agent item.
:param item: Codex ``collabAgentToolCall`` item.
:returns: Deduplicated receiver thread ids in original order.
"""
raw = item.get("receiverThreadIds")
if not isinstance(raw, list):
return []
seen: set[str] = set()
result: list[str] = []
for value in raw:
if isinstance(value, str) and value and value not in seen:
result.append(value)
seen.add(value)
return result
def _collab_parent_thread_id(
params: dict[str, Any],
item: dict[str, Any],
) -> str | None:
"""
Return the Codex parent thread id for a collab-agent spawn.
:param params: Codex notification params.
:param item: Codex ``collabAgentToolCall`` item.
:returns: Parent thread id, or ``None`` when not determinable.
"""
sender = item.get("senderThreadId")
if isinstance(sender, str) and sender:
return sender
return _thread_id_from_params(params)
async def _handle_agent_message_delta(
client: httpx.AsyncClient,
session_id: str,
bridge_dir: Path,
params: dict[str, Any],
delta_coalescer: _OutputTextDeltaCoalescer,
forwarder_state: _CodexForwarderState | None,
) -> None:
"""
Forward one live Codex assistant text delta to AP.
Codex app-server emits ``item/agentMessage/delta`` while a turn is
running. Omnigent normally persists only the completed ``agentMessage`` item,
so this path publishes a transient text-delta SSE event and relies on
the later ``item/completed`` notification for durable completed-turn
history. The same text is also buffered in memory so an interrupted turn
that never emits a completed item can still persist the visible partial
answer.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param params: Codex ``item/agentMessage/delta`` params, e.g.
``{"turnId": "turn_123", "itemId": "item_123",
"delta": "hi"}``.
:param delta_coalescer: Coalescer for high-frequency assistant text
deltas.
:param forwarder_state: Optional forwarder state used to recover the
turn's user message before streaming a recovered assistant delta.
:returns: None.
"""
turn_id = _turn_id_from_payload(params)
delta = params.get("delta")
if not isinstance(delta, str):
_logger.warning("Codex agentMessage delta missing string delta: turn_id=%s", turn_id)
return
if not _is_active_turn_delta(bridge_dir, turn_id):
edge = _delta_recovery_status_edge(bridge_dir, params, turn_id)
if edge is not None:
await _ensure_user_message_posted(client, session_id, params, forwarder_state)
await _post_turn_status_edge(client, session_id, edge)
_record_partial_text_delta(
forwarder_state,
turn_id=turn_id,
item_type="agentMessage",
item_id=_item_id_from_delta_params(params),
delta=delta,
)
await delta_coalescer.append(
delta,
message_id=_streaming_message_id(params, "agentMessage"),
)
return
_logger.info("Codex forwarder ignored stale assistant delta: turn_id=%s", turn_id)
return
_record_partial_text_delta(
forwarder_state,
turn_id=turn_id,
item_type="agentMessage",
item_id=_item_id_from_delta_params(params),
delta=delta,
)
await delta_coalescer.append(
delta,
message_id=_streaming_message_id(params, "agentMessage"),
)
async def _handle_plan_delta(
client: httpx.AsyncClient,
session_id: str,
bridge_dir: Path,
params: dict[str, Any],
delta_coalescer: _OutputTextDeltaCoalescer,
forwarder_state: _CodexForwarderState | None,
) -> None:
"""
Forward one live Codex plan text delta to AP.
Plan mode streams visible plan prose through
``item/plan/delta`` rather than ``item/agentMessage/delta``.
Omnigent uses the same transient output-text delta channel for both,
and the later completed ``plan`` item or structured plan update
provides the durable completed-turn transcript state. Interrupted turns
consume the buffered deltas so the visible partial plan is still durable.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param params: Codex ``item/plan/delta`` params, e.g.
``{"turnId": "turn_123", "itemId": "item_plan",
"delta": "1. Inspect"}``.
:param delta_coalescer: Coalescer for high-frequency assistant text
deltas.
:param forwarder_state: Optional forwarder state used to recover the
turn's user message before streaming a recovered plan delta.
:returns: None.
"""
turn_id = _turn_id_from_payload(params)
delta = params.get("delta")
if not isinstance(delta, str):
_logger.warning("Codex plan delta missing string delta: turn_id=%s", turn_id)
return
if not _is_active_turn_delta(bridge_dir, turn_id):
edge = _delta_recovery_status_edge(bridge_dir, params, turn_id)
if edge is not None:
await _ensure_user_message_posted(client, session_id, params, forwarder_state)
await _post_turn_status_edge(client, session_id, edge)
_record_partial_text_delta(
forwarder_state,
turn_id=turn_id,
item_type="plan",
item_id=_item_id_from_delta_params(params),
delta=delta,
)
await delta_coalescer.append(
delta,
message_id=_streaming_message_id(params, "plan"),
)
return
_logger.info("Codex forwarder ignored stale plan delta: turn_id=%s", turn_id)
return
_record_partial_text_delta(
forwarder_state,
turn_id=turn_id,
item_type="plan",
item_id=_item_id_from_delta_params(params),
delta=delta,
)
await delta_coalescer.append(
delta,
message_id=_streaming_message_id(params, "plan"),
)
async def _ensure_user_message_posted(
client: httpx.AsyncClient,
session_id: str,
params: dict[str, Any],
forwarder_state: _CodexForwarderState | None,
) -> None:
"""
Guarantee a turn's user message is posted before its assistant reply.
The forwarder's live stream normally delivers ``userMessage`` before
``agentMessage`` for a turn, so this is a no-op. But on a fresh thread
the subscription can miss the early ``userMessage`` event; this
recovers it via a targeted ``thread/resume`` and posts it through the
normal claim/post path so it takes an earlier Omnigent position than the
reply. The recovered item carries Codex's resume id (e.g. ``item-1``),
matching the id the resume backfill would later use — so the dedup
gate drops the backfill's duplicate.
No-op when ``forwarder_state`` is absent (tests bypassing
``supervise_forwarder``), when no Codex client is wired, or when the
turn's user message was already posted this connection.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex ``item/completed`` params for the assistant
message whose turn's user message must already be posted.
:param forwarder_state: Mutable forwarder state tracking posted user
turns and holding the Codex app-server client.
:returns: None.
"""
if forwarder_state is None:
return
turn_id = _turn_id_from_payload(params)
if not turn_id or forwarder_state.has_posted_user_message(turn_id):
return
codex_client = forwarder_state.codex_client
thread_id = _thread_id_from_params(params)
if codex_client is None or thread_id is None:
return
try:
response = await codex_client.request("thread/resume", {"threadId": thread_id})
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - degrade to current behavior on resume failure.
_logger.warning(
"Codex forwarder could not resume to recover user message: thread=%s turn=%s",
thread_id,
turn_id,
exc_info=True,
)
return
user_item = _find_turn_user_message(response, turn_id)
if user_item is None:
return
recovered_params: dict[str, Any] = {
"threadId": thread_id,
"turnId": turn_id,
"item": user_item,
}
if not _claim_completed_item(recovered_params, user_item, forwarder_state):
return
await _post_user_message(client, session_id, recovered_params, user_item)
forwarder_state.note_user_message_posted(turn_id)
def _find_turn_user_message(response: CodexMessage, turn_id: str) -> dict[str, Any] | None:
"""
Locate a turn's ``userMessage`` item in a ``thread/resume`` response.
:param response: Codex ``thread/resume`` response envelope.
:param turn_id: Codex turn id whose user message to find, e.g.
``"turn_123"``.
:returns: The ``userMessage`` item dict, or ``None`` when the turn or
its user message is absent.
"""
result = response.get("result")
if not isinstance(result, dict):
return None
thread = result.get("thread")
if not isinstance(thread, dict):
return None
turns = thread.get("turns")
if not isinstance(turns, list):
return None
for turn in turns:
if not isinstance(turn, dict) or _turn_id_from_payload(turn) != turn_id:
continue
items = turn.get("items")
if not isinstance(items, list):
continue
for item in items:
if isinstance(item, dict) and item.get("type") == "userMessage":
return item
return None
async def _post_user_message(
client: httpx.AsyncClient,
session_id: str,
params: dict[str, Any],
item: dict[str, Any],
) -> None:
"""
Persist a Codex user message observed from the TUI.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex notification params.
:param item: Codex ``userMessage`` item.
:returns: None.
"""
text = _user_message_text(item)
# An image/file-only message has no text but must still be posted: the
# server drains its optimistic pending-input entry (FIFO) and folds the
# image in by file_id (``_merge_pending_file_blocks``). Bailing here would
# leak the pending entry — the user bubble would never persist (rendering
# the reply above the dangling image) and the NEXT message would drain
# this stale entry, folding the prior image into it. Only a truly empty
# message (no text, no file block) is skipped.
has_file_block = _user_message_has_file_content(item)
if not text and not has_file_block:
return
# Text-only / text+image post the text; image-only posts empty content and
# relies on the server-side pending fold to supply the image block.
content: list[dict[str, Any]] = [{"type": "input_text", "text": text}] if text else []
item_data: dict[str, Any] = {
"role": "user",
"content": content,
}
if _is_codex_skill_wrapper(text):
item_data["is_meta"] = True
_logger.debug(
"Marked Codex skill wrapper as meta for session=%s source_id=%s",
session_id,
_source_id(params, item),
)
await _post_external_item(
client,
session_id,
item_type="message",
item_data=item_data,
response_id=_response_id(params),
)
async def _post_agent_message(
client: httpx.AsyncClient,
session_id: str,
params: dict[str, Any],
item: dict[str, Any],
) -> None:
"""
Persist a Codex assistant message observed from the TUI/app-server.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex notification params.
:param item: Codex ``agentMessage`` item.
:returns: None.
"""
text = item.get("text")
if not isinstance(text, str) or not text:
return
await _post_external_item(
client,
session_id,
item_type="message",
item_data={
"role": "assistant",
"agent": _AGENT_NAME,
"content": [{"type": "output_text", "text": text}],
},
response_id=_response_id(params),
)
async def _post_tool_item(
client: httpx.AsyncClient,
session_id: str,
params: dict[str, Any],
item: dict[str, Any],
) -> None:
"""
Mirror one completed Codex built-in tool call into Omnigent history.
A native Codex session runs Codex's own tools (shell commands, file
edits, web search) rather than client-tunneled dynamic tools, so a
single ``item/completed`` notification carries both the invocation
and its result. This translates that one item into the AP
``function_call`` / ``function_call_output`` pair the web UI renders.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex ``item/completed`` params.
:param item: Codex tool item, e.g.
``{"type": "commandExecution", "id": "call_abc",
"command": "/bin/zsh -lc 'pwd'", "aggregatedOutput": "/repo\n",
"exitCode": 0}``.
:returns: None.
"""
tool_call = _codex_tool_call_from_item(item)
if tool_call is None:
return
arguments_text = _json_string(tool_call.arguments)
if arguments_text is None:
_logger.warning(
"Codex tool call arguments are not JSON serializable: call_id=%s tool=%s",
tool_call.call_id,
tool_call.name,
)
return
await _post_external_item(
client,
session_id,
item_type="function_call",
item_data={
"agent": _AGENT_NAME,
"name": tool_call.name,
"arguments": arguments_text,
"call_id": tool_call.call_id,
},
response_id=_response_id(params),
)
await _post_external_item(
client,
session_id,
item_type="function_call_output",
item_data={"call_id": tool_call.call_id, "output": tool_call.output},
response_id=_response_id(params),
)
async def _post_plan_item(
client: httpx.AsyncClient,
session_id: str,
params: dict[str, Any],
item: dict[str, Any],
) -> None:
"""
Persist one completed Codex plan item as assistant text.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex ``item/completed`` params.
:param item: Codex ``plan`` thread item.
:returns: None.
"""
text = item.get("text")
if not isinstance(text, str) or not text:
return
await _post_external_item(
client,
session_id,
item_type="message",
item_data={
"role": "assistant",
"agent": _AGENT_NAME,
"content": [{"type": "output_text", "text": text}],
},
response_id=_response_id(params),
)
async def _post_review_mode_marker(
client: httpx.AsyncClient,
session_id: str,
params: dict[str, Any],
item: dict[str, Any],
) -> None:
"""
Mirror a Codex review-mode enter/exit transition into Omnigent history.
Codex ``/review`` brackets a turn with ``enteredReviewMode`` /
``exitedReviewMode`` thread items. The web UI has no dedicated review
affordance, and a review transition is session *state*, not user input —
so it is surfaced as a short assistant-message marker (the same visible
rail used for plan updates in :func:`_handle_turn_plan_updated`). A
user-role ``[System: …]`` note was rejected here because a non-meta
user item drains the pending-input FIFO server-side, which would
swallow the web user's next real message.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex ``item/completed`` params.
:param item: Codex ``enteredReviewMode`` / ``exitedReviewMode`` item,
e.g. ``{"type": "enteredReviewMode", "id": "rev_1",
"review": "review the auth changes"}``.
:returns: None.
"""
entered = item.get("type") == "enteredReviewMode"
header = "Entered review mode" if entered else "Exited review mode"
review = item.get("review")
if isinstance(review, str) and review.strip():
text = f"{header}: {review.strip()}"
else:
text = header
await _post_external_item(
client,
session_id,
item_type="message",
item_data={
"role": "assistant",
"agent": _AGENT_NAME,
"content": [{"type": "output_text", "text": text}],
},
response_id=_response_id(params),
)
def _codex_tool_call_from_item(item: dict[str, Any]) -> _CodexToolCall | None:
"""
Translate a completed Codex tool item into a normalized tool call.
:param item: Codex tool item from an ``item/completed`` notification,
e.g. ``{"type": "commandExecution", "id": "call_abc", ...}``.
:returns: Normalized tool call, or ``None`` for a malformed item that
should be dropped rather than mirrored with invented fields.
"""
call_id = item.get("id")
item_type = item.get("type")
if not isinstance(call_id, str) or not call_id:
_logger.warning("Codex tool item missing string id: type=%s", item_type)
return None
builder = _TOOL_ITEM_BUILDERS.get(item_type) if isinstance(item_type, str) else None
if builder is None:
return None
return builder(call_id, item)
# Codex runs each model-issued shell command inside its OWN bwrap command
# sandbox. In a hardened container that disallows unprivileged user namespaces,
# that sandbox cannot start and every command hard-fails with this raw bwrap
# error, with no hint at how to recover. Detect the marker and append actionable
# guidance so a top-level session degrades with direction instead of an opaque
# failure. The codex
# ``--approval-mode`` presets do NOT disable this sandbox — only the "Full
# access" preset's ``danger-full-access`` (or a config ``sandbox_mode``) does.
_CODEX_SANDBOX_NAMESPACE_ERROR_MARKER = "No permissions to create new namespace"
_CODEX_SANDBOX_BYPASS_GUIDANCE = (
"Omnigent: Codex's command sandbox could not start because this container "
"disallows unprivileged user namespaces, so the command did not run. To run "
'shell commands here, start a new Codex session with the "Full access" '
"approval preset (New chat → Advanced settings), or set "
'sandbox_mode = "danger-full-access" in ~/.codex/config.toml on the runner.'
)
def _augment_sandbox_namespace_error(output_text: str) -> str:
"""Append recovery guidance when a Codex shell command failed because its
own command sandbox could not start (no unprivileged user namespaces).
Returns *output_text* unchanged when the bwrap-namespace marker is absent,
so ordinary command output is never altered. See issue #657.
:param output_text: Aggregated command output, any exit-code suffix already
appended, e.g. ``"bwrap: No permissions ...\\n[exit code: 1]"``.
:returns: The output with a trailing guidance paragraph, or unchanged.
"""
if _CODEX_SANDBOX_NAMESPACE_ERROR_MARKER not in output_text:
return output_text
return f"{output_text}\n\n{_CODEX_SANDBOX_BYPASS_GUIDANCE}"
def _command_execution_tool_call(call_id: str, item: dict[str, Any]) -> _CodexToolCall | None:
"""
Build a tool call from a Codex ``commandExecution`` item.
:param call_id: Codex item id, e.g. ``"call_abc"``.
:param item: Codex ``commandExecution`` item, e.g.
``{"command": "/bin/zsh -lc 'pwd'", "cwd": "/repo",
"aggregatedOutput": "/repo\n", "exitCode": 0}``.
:returns: Normalized tool call, or ``None`` when the command is
missing.
"""
command = item.get("command")
if not isinstance(command, str) or not command:
_logger.warning("Codex commandExecution missing command: call_id=%s", call_id)
return None
arguments: dict[str, Any] = {"command": command}
cwd = item.get("cwd")
if isinstance(cwd, str) and cwd:
arguments["cwd"] = cwd
output = item.get("aggregatedOutput")
# A command that prints nothing (e.g. ``touch x``) legitimately has no
# aggregated output; Codex reports that as "" or null. AP's
# function_call_output requires a string, so "" is the faithful
# representation of "no output captured" here — not an invented default.
output_text = output if isinstance(output, str) else ""
exit_code = item.get("exitCode")
# Codex reports a non-zero exit separately from stdout/stderr; surface
# it inline so a failed command does not look successful in the UI.
if isinstance(exit_code, int) and exit_code != 0:
suffix = f"[exit code: {exit_code}]"
output_text = f"{output_text}\n{suffix}" if output_text else suffix
# Turn codex's opaque "sandbox can't start" bwrap failure into actionable
# recovery guidance; a no-op for any other output.
output_text = _augment_sandbox_namespace_error(output_text)
return _CodexToolCall(call_id=call_id, name="shell", arguments=arguments, output=output_text)
def _file_change_tool_call(call_id: str, item: dict[str, Any]) -> _CodexToolCall | None:
"""
Build a tool call from a Codex ``fileChange`` item.
:param call_id: Codex item id, e.g. ``"call_abc"``.
:param item: Codex ``fileChange`` item, e.g.
``{"changes": [{"path": "/repo/x.py", "kind": {"type": "add"},
"diff": "print('hi')\n"}], "status": "completed"}``.
:returns: Normalized tool call, or ``None`` when no changes are
present.
"""
changes = item.get("changes")
if not isinstance(changes, list) or not changes:
_logger.warning("Codex fileChange missing changes: call_id=%s", call_id)
return None
summary_lines: list[str] = []
for change in changes:
if not isinstance(change, dict):
continue
path = change.get("path")
kind = change.get("kind")
kind_type = kind.get("type") if isinstance(kind, dict) else None
label = kind_type if isinstance(kind_type, str) and kind_type else "change"
summary_lines.append(f"{label} {path}")
output_text = "\n".join(summary_lines)
return _CodexToolCall(
call_id=call_id,
name="apply_patch",
arguments={"changes": changes},
output=output_text,
)
def _web_search_tool_call(call_id: str, item: dict[str, Any]) -> _CodexToolCall | None:
"""
Build a tool call from a Codex ``webSearch`` item.
Codex does not surface the search results, so the queries it ran are
the only result data available and are used as the output text.
:param call_id: Codex item id, e.g. ``"ws_abc"``.
:param item: Codex ``webSearch`` item, e.g.
``{"query": "python latest version",
"action": {"type": "search", "queries": ["python latest"]}}``.
:returns: Normalized tool call, or ``None`` when no query is present.
"""
query = item.get("query")
action = item.get("action")
queries = action.get("queries") if isinstance(action, dict) else None
query_list = [q for q in queries if isinstance(q, str)] if isinstance(queries, list) else []
if not query_list and isinstance(query, str) and query:
query_list = [query]
if not query_list:
_logger.warning("Codex webSearch missing query: call_id=%s", call_id)
return None
return _CodexToolCall(
call_id=call_id,
name="web_search",
arguments={"query": query_list[0]},
output="\n".join(query_list),
)
def _image_view_tool_call(call_id: str, item: dict[str, Any]) -> _CodexToolCall | None:
"""
Build a tool call from a Codex ``imageView`` item.
Codex emits an ``imageView`` item when the model opens a local image
(e.g. a screenshot on disk) to look at it. The only datum is the
absolute path, so it becomes both the argument and the mirrored
output — the web UI cannot read a runner-local path, so the path is
the faithful record of which image was viewed.
:param call_id: Codex item id, e.g. ``"img_abc"``.
:param item: Codex ``imageView`` item, e.g.
``{"type": "imageView", "id": "img_abc", "path": "/repo/shot.png"}``.
:returns: Normalized tool call, or ``None`` when the path is missing.
"""
path = item.get("path")
if not isinstance(path, str) or not path:
_logger.warning("Codex imageView missing path: call_id=%s", call_id)
return None
return _CodexToolCall(
call_id=call_id,
name="view_image",
arguments={"path": path},
output=path,
)
def _image_generation_tool_call(call_id: str, item: dict[str, Any]) -> _CodexToolCall | None:
"""
Build a tool call from a Codex ``imageGeneration`` item.
Codex emits an ``imageGeneration`` item when the model generates an
image. The raw ``result`` payload (base64 image bytes) is deliberately
NOT mirrored — the web UI has no assistant-side image rendering and a
multi-megabyte base64 string would only bloat the transcript. Instead
the card carries the human-meaningful metadata: the revised prompt as
the argument and the status plus on-disk save path as the output.
:param call_id: Codex item id, e.g. ``"imggen_abc"``.
:param item: Codex ``imageGeneration`` item, e.g.
``{"type": "imageGeneration", "id": "imggen_abc",
"status": "completed", "revisedPrompt": "a red bicycle",
"result": "<base64>", "savedPath": "/repo/out.png"}``.
:returns: Normalized tool call, or ``None`` when the status is missing.
"""
status = item.get("status")
if not isinstance(status, str) or not status:
_logger.warning("Codex imageGeneration missing status: call_id=%s", call_id)
return None
arguments: dict[str, Any] = {}
revised_prompt = item.get("revisedPrompt")
if isinstance(revised_prompt, str) and revised_prompt:
arguments["revised_prompt"] = revised_prompt
output_lines = [f"status: {status}"]
saved_path = item.get("savedPath")
if isinstance(saved_path, str) and saved_path:
output_lines.append(f"saved to {saved_path}")
return _CodexToolCall(
call_id=call_id,
name="generate_image",
arguments=arguments,
output="\n".join(output_lines),
)
# Codex built-in tool item types this forwarder mirrors into Omnigent history.
# ``mcpToolCall`` is intentionally absent: its event shape has not been
# verified, so it is logged-but-skipped rather than mirrored with guessed
# fields. Add it here once its real shape is captured.
_TOOL_ITEM_BUILDERS: dict[str, _ToolItemBuilder] = {
"commandExecution": _command_execution_tool_call,
"fileChange": _file_change_tool_call,
"webSearch": _web_search_tool_call,
"imageView": _image_view_tool_call,
"imageGeneration": _image_generation_tool_call,
}
_TOOL_ITEM_TYPES = frozenset(_TOOL_ITEM_BUILDERS)
# Codex ``/review`` enter/exit thread items. The web UI has no dedicated
# review-mode affordance, so these are mirrored as a visible assistant-message
# marker (see :func:`_post_review_mode_marker`).
_REVIEW_MODE_ITEM_TYPES = frozenset({"enteredReviewMode", "exitedReviewMode"})
async def _post_external_item(
client: httpx.AsyncClient,
session_id: str,
*,
item_type: str,
item_data: dict[str, Any],
response_id: str,
) -> None:
"""
Post one external conversation item to AP.
The forwarder does not send a dedup key to the server — items are
persisted with a random primary key. Avoiding re-posts on resume is
the producer's own responsibility.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param item_type: Conversation item type, e.g. ``"message"``.
:param item_data: Conversation item payload.
:param response_id: Response id for the mirrored Codex turn.
:returns: None.
"""
response = await _post_session_event(
client,
session_id,
event_type="external_conversation_item",
data={
"item_type": item_type,
"item_data": item_data,
"response_id": response_id,
},
)
if response is None:
_logger.warning("failed to post Codex conversation item")
return
if response.status_code >= 400:
_logger.warning(
"failed to post Codex conversation item: status=%s body=%s",
response.status_code,
response.text[:1000],
)
async def _post_status(
client: httpx.AsyncClient,
session_id: str,
status: str,
*,
response_id: str | None = None,
output: str | None = None,
reauth_required: bool = False,
) -> None:
"""
Publish a native Codex status edge.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param status: Session status, e.g. ``"running"``.
:param response_id: Optional response id for this status edge,
e.g. ``"codex_turn_abc123"``.
:param output: Optional human-readable reason carried with a terminal
edge, e.g. a Codex error message. The server forwards this as
the authoritative terminal output for a ``failed`` / ``idle`` edge.
:param reauth_required: When ``True``, mark a ``failed`` edge as caused by
an authentication error so the surface can prompt a re-auth.
Surface-only: no automatic ``codex login`` is triggered.
:returns: None.
"""
data: dict[str, Any] = {"status": status}
if response_id is not None:
data["response_id"] = response_id
if output is not None:
data["output"] = output
if reauth_required:
data["reauth_required"] = True
response = await _post_session_event(
client,
session_id,
event_type="external_session_status",
data=data,
)
_log_failed_session_event_post("external_session_status", response)
async def _post_turn_status_edge(
client: httpx.AsyncClient,
session_id: str,
edge: _CodexTurnStatusEdge | None,
) -> None:
"""
Publish one Codex turn lifecycle edge if a valid edge was derived.
When the edge carries a turn-level error, the error message is
surfaced as the terminal ``output`` so the failure reason is visible
rather than silently swallowed; an auth-classified error additionally
flags ``reauth_required`` and appends a re-auth hint to the output.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param edge: Derived lifecycle edge, or ``None`` when no status should
be published.
:returns: None.
"""
if edge is None:
return
_logger.info(
"Codex forwarder publishing turn status: source=%s turn_id=%s status=%s",
edge.source,
edge.turn_id,
edge.status,
)
response_id = _response_id(_params_with_turn_id({}, edge.turn_id)) if edge.turn_id else None
output: str | None = None
reauth_required = False
if edge.error is not None:
output = edge.error.message
if edge.error.is_auth:
reauth_required = True
output = f"{output}\n\n{_CODEX_REAUTH_HINT}"
await _post_status(
client,
session_id,
edge.status,
response_id=response_id,
output=output,
reauth_required=reauth_required,
)
async def _post_external_elicitation_resolved(
client: httpx.AsyncClient,
session_id: str,
*,
elicitation_id: str,
) -> bool:
"""
Post a native-side elicitation resolution signal to AP.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param elicitation_id: Omnigent elicitation id, e.g.
``"elicit_codex_abc123"``.
:returns: ``True`` when Omnigent accepted the event.
"""
response = await _post_session_event(
client,
session_id,
event_type=_EXTERNAL_ELICITATION_RESOLVED_TYPE,
data={"elicitation_id": elicitation_id},
)
_log_failed_session_event_post(_EXTERNAL_ELICITATION_RESOLVED_TYPE, response)
return response is not None and response.status_code < 400
async def _post_output_text_delta(
client: httpx.AsyncClient,
session_id: str,
delta: str,
*,
message_id: str | None = None,
index: int | None = None,
final: bool | None = None,
) -> None:
"""
Publish a transient Codex assistant text delta.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param delta: Assistant text fragment, e.g. ``"hello"``.
:param message_id: Optional stable native message stream id,
e.g. ``"codex:thread_123:turn_123:agentMessage:item_agent"``.
:param index: Optional zero-based chunk index for ``message_id``,
e.g. ``0``.
:param final: Optional final-chunk marker for ``message_id``,
e.g. ``False``.
:returns: None.
"""
data: dict[str, Any] = {"delta": delta}
if message_id is not None:
data["message_id"] = message_id
if index is not None:
data["index"] = index
if final is not None:
data["final"] = final
response = await _post_session_event(
client,
session_id,
event_type="external_output_text_delta",
data=data,
)
_log_failed_session_event_post("external_output_text_delta", response)
async def _post_compaction_status(
client: httpx.AsyncClient,
session_id: str,
status: str,
*,
forwarder_state: _CodexForwarderState | None,
) -> None:
"""
Mirror a Codex context-compaction edge to Omnigent (#1255).
Publishes ``external_compaction_status`` so the web UI shows its
"Compacting conversation…" spinner while Codex compacts and clears it
when done — matching how claude-native brackets compaction. Consecutive
identical statuses are deduped because Codex may signal completion via
both a ``contextCompaction`` item and a ``thread/compacted``
notification.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param status: ``"in_progress"`` or ``"completed"``.
:param forwarder_state: Optional state carrying the dedupe baseline.
:returns: None.
"""
if forwarder_state is not None and forwarder_state.compaction_status_posted == status:
return
response = await _post_session_event(
client,
session_id,
event_type=_EXTERNAL_COMPACTION_STATUS_TYPE,
data={"status": status},
)
_log_failed_session_event_post(_EXTERNAL_COMPACTION_STATUS_TYPE, response)
if forwarder_state is not None and response is not None and response.status_code < 400:
forwarder_state.compaction_status_posted = status
if status == "in_progress":
forwarder_state.compaction_item_persisted = False
async def _persist_codex_compaction_item(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path | None = None,
) -> None:
"""Persist a compaction boundary item to the conversation store.
Codex appends a ``Compacted`` entry to the rollout JSONL after
compaction. That entry carries ``replacement_history`` — the
post-compaction context. When ``bridge_dir`` is available, we
read the latest ``Compacted`` entry from the rollout and use
its ``replacement_history`` as ``compacted_messages``.
"""
resp = await client.get(
f"/v1/sessions/{session_id}/items",
params={"limit": 1, "order": "desc"},
)
resp.raise_for_status()
items = resp.json().get("data", [])
last_item_id = items[0]["id"] if items else f"compact_boundary_{session_id}"
compacted = None
if bridge_dir is not None:
try:
state = read_bridge_state(bridge_dir)
if state is not None:
codex_home = Path(state.codex_home)
thread_id = state.thread_id
rollout_files = sorted(
codex_home.glob(f"sessions/**/*rollout-*{thread_id}.jsonl"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if rollout_files:
compacted = _read_compacted_history(rollout_files[0])
except Exception: # noqa: BLE001
_logger.debug(
"Failed to read codex rollout for compaction persist",
exc_info=True,
)
data: dict[str, object] = {
"summary": "[Codex compaction — context was compacted in the terminal]",
"last_item_id": last_item_id,
"model": "unknown",
"token_count": 0,
}
if compacted is not None:
if compacted.get("replacement_history"):
data["compacted_messages"] = compacted["replacement_history"]
if compacted.get("window_id") is not None:
data["window_id"] = compacted["window_id"]
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "compaction", "data": data},
)
resp.raise_for_status()
def _read_compacted_history(rollout_path: Path) -> dict[str, object] | None:
"""Read the last ``Compacted`` entry from a rollout JSONL.
Codex appends a ``{type: "compacted", payload: {replacement_history: [...],
window_id: N}}`` entry after compaction. Returns a dict with
``replacement_history`` and ``window_id`` for persistence, or ``None``.
:param rollout_path: Path to the rollout JSONL.
:returns: Dict with ``replacement_history`` and ``window_id``, or ``None``.
"""
last_compacted = None
with rollout_path.open() as f:
for line in f:
try:
entry = json.loads(line)
except (json.JSONDecodeError, TypeError):
continue
if entry.get("type") == "compacted":
last_compacted = entry
if last_compacted is None:
return None
payload = last_compacted.get("payload")
if not isinstance(payload, dict):
return None
history = payload.get("replacement_history")
if not isinstance(history, list) or not history:
return None
# Store the full replacement_history — messages + compaction
# tokens. Although the messages duplicate pre-compaction items
# in the conversation store, they are needed for rollout
# reconstruction (e.g. sandbox recovery where the rollout file
# is lost).
return {
"replacement_history": [item for item in history if isinstance(item, dict)],
"window_id": payload.get("window_id"),
}
async def _handle_reasoning_delta(
client: httpx.AsyncClient,
session_id: str,
params: dict[str, Any],
forwarder_state: _CodexForwarderState | None,
) -> None:
"""
Forward one live Codex reasoning (chain-of-thought) delta to AP.
Codex emits ``item/reasoning/textDelta`` and
``item/reasoning/summaryTextDelta`` while it thinks. Omnigent has no
completed reasoning conversation item — the reasoning block is
transient and is finalized when the turn's assistant message arrives —
so this only publishes a transient ``external_output_reasoning_delta``
so the web UI paints a live "thinking" block, matching the in-process
executor's wire shape (#1254). The first delta of a reasoning item
opens the block (``started=True`` → ``response.reasoning.started``).
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param params: Codex reasoning delta params, e.g.
``{"turnId": "turn_123", "itemId": "item_r", "delta": "Let me"}``.
:param forwarder_state: Optional forwarder state tracking which
reasoning item is currently open (for the ``started`` edge).
:returns: None.
"""
delta = params.get("delta")
if not isinstance(delta, str):
_logger.warning(
"Codex reasoning delta missing string delta: turn_id=%s",
_turn_id_from_payload(params),
)
return
item_id = _item_id_from_delta_params(params)
started = False
if forwarder_state is not None:
if item_id is not None:
started = forwarder_state.reasoning_stream_item_id != item_id
forwarder_state.reasoning_stream_item_id = item_id
else:
# Codex reasoning deltas normally carry an itemId; if one is
# missing, ``None`` on state means no block is open yet.
# ``""`` marks "open, id unknown" so later id-less deltas in the
# same block don't re-open it.
started = forwarder_state.reasoning_stream_item_id is None
forwarder_state.reasoning_stream_item_id = ""
# An empty, non-opening delta carries nothing to render.
if not delta and not started:
return
await _post_output_reasoning_delta(client, session_id, delta, started=started)
async def _post_output_reasoning_delta(
client: httpx.AsyncClient,
session_id: str,
delta: str,
*,
started: bool,
) -> None:
"""
Publish a transient Codex reasoning delta.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param delta: Reasoning text fragment, e.g. ``"Let me think"``.
:param started: Whether this opens a new reasoning block; when
``True`` the server precedes the delta with a single
``response.reasoning.started`` SSE.
:returns: None.
"""
response = await _post_session_event(
client,
session_id,
event_type=_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE,
data={"delta": delta, "started": started},
)
_log_failed_session_event_post(_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE, response)
async def _post_session_interrupted(
client: httpx.AsyncClient,
session_id: str,
*,
response_id: str | None = None,
) -> None:
"""
Publish a Codex-observed interrupted-turn signal into AP.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param response_id: Optional interrupted response id, e.g.
``"codex_turn_abc123"``.
:returns: None.
"""
data: dict[str, Any] = {}
if response_id is not None:
data["response_id"] = response_id
response = await _post_session_event(
client,
session_id,
event_type=_EXTERNAL_SESSION_INTERRUPTED_TYPE,
data=data,
)
_log_failed_session_event_post(_EXTERNAL_SESSION_INTERRUPTED_TYPE, response)
def _session_usage_data_from_params(params: dict[str, Any]) -> dict[str, int] | None:
"""
Extract Omnigent session-usage fields from a Codex usage notification.
:param params: Codex ``thread/tokenUsage/updated`` params.
:returns: A dict with any of ``context_tokens`` / ``context_window``
(context ring), ``cumulative_input_tokens`` /
``cumulative_output_tokens`` /
``cumulative_cache_read_input_tokens`` (priced into session cost by
the server), or ``None`` when the notification has no usable usage
values.
"""
token_usage = params.get("tokenUsage")
if not isinstance(token_usage, dict):
return None
total = token_usage.get("total")
if not isinstance(total, dict):
return None
cumulative_input_tokens = total.get("inputTokens")
context_window = total.get("contextWindow")
output_tokens = total.get("outputTokens")
cached_input_tokens = total.get("cachedInputTokens")
data: dict[str, int] = {}
if isinstance(cumulative_input_tokens, int) and cumulative_input_tokens >= 0:
# Codex's ``tokenUsage.total`` is CUMULATIVE across the whole thread
# (the CLI subtracts prior totals to recover per-turn deltas), so
# ``total.inputTokens`` / ``outputTokens`` are the session's cumulative
# token counts. Forward them as the cumulative fields the server prices
# into ``total_cost_usd`` (SET semantics) — codex-native produces no
# ``response.completed``, so the Omnigent relay never accounts its cost.
data["cumulative_input_tokens"] = cumulative_input_tokens
# Codex's ``inputTokens`` is INCLUSIVE of cached tokens
# (``non_cached_input = input_tokens - cached_input_tokens`` in
# codex-rs ``protocol.rs``). Forward the cumulative cached count so the
# server can price the cached portion at the (cheaper) cache-read rate
# instead of billing the whole input at the full input rate. Same
# cumulative (SET) semantics as ``cumulative_input_tokens``.
if isinstance(cached_input_tokens, int) and cached_input_tokens >= 0:
data["cumulative_cache_read_input_tokens"] = cached_input_tokens
# ``context_tokens`` drives the context-window ring in the web UI. It
# must reflect the CURRENT context occupancy (how much of the window
# the latest turn consumed), NOT the cumulative total across all turns.
# Codex's ``tokenUsage.last`` carries the per-turn breakdown; fall back
# to ``total.inputTokens`` only when ``last`` is unavailable (first
# frame before a turn completes).
last = token_usage.get("last")
last_input = last.get("inputTokens") if isinstance(last, dict) else None
if isinstance(last_input, int) and last_input >= 0:
data["context_tokens"] = last_input
elif isinstance(cumulative_input_tokens, int) and cumulative_input_tokens >= 0:
data["context_tokens"] = cumulative_input_tokens
if isinstance(output_tokens, int) and output_tokens >= 0:
data["cumulative_output_tokens"] = output_tokens
if isinstance(context_window, int) and context_window > 0:
data["context_window"] = context_window
if not data:
return None
return data
@dataclass
class _ForwardHealth:
"""
Process-level health of Omnigent session-event forwarding (#1120).
Network failures (connect timeouts, 503s, resets) make
``_post_session_event`` drop transcript/usage events after its bounded
retries, previously visible only as scattered per-item warnings. This
tracks consecutive permanent failures so a sustained outage escalates
to a single loud signal instead of staying effectively silent.
:param consecutive_failures: Permanent post failures since the last
success.
:param degraded_logged: Whether the degraded-sync edge has already
been logged for the current outage (so it logs once, not per item).
"""
consecutive_failures: int = 0
degraded_logged: bool = False
# After this many consecutive permanent forward failures, sync is treated as
# degraded and escalated once to ERROR. Small enough to fire during a real
# outage, large enough to ride out a transient blip the retries already cover.
_FORWARD_DEGRADED_THRESHOLD = 5
_forward_health = _ForwardHealth()
# Bridge dir for dead-lettering undeliverable durable events; set per-forwarder (#1120).
_dead_letter_dir: ContextVar[Path | None] = ContextVar("_codex_dead_letter_dir", default=None)
# Durable event types worth dead-lettering (not ephemeral deltas).
_DEAD_LETTER_EVENT_TYPES = frozenset({"external_conversation_item", "external_session_usage"})
def _reset_forward_health() -> None:
"""
Reset forward-health tracking (test seam / new forwarder lifetime).
:returns: None.
"""
global _forward_health
_forward_health = _ForwardHealth()
def _note_forward_success() -> None:
"""
Record a successful forward, clearing any degraded-sync state.
:returns: None.
"""
if _forward_health.degraded_logged:
_logger.info(
"codex-native forward sync recovered after %d consecutive failures",
_forward_health.consecutive_failures,
)
_forward_health.consecutive_failures = 0
_forward_health.degraded_logged = False
def _note_forward_failure(event_type: str) -> None:
"""
Record a permanent forward failure; escalate once when sync degrades.
:param event_type: Session event type that failed to post, e.g.
``"external_conversation_item"``.
:returns: None.
"""
_forward_health.consecutive_failures += 1
if (
_forward_health.consecutive_failures >= _FORWARD_DEGRADED_THRESHOLD
and not _forward_health.degraded_logged
):
_logger.error(
"codex-native forward sync degraded: %d consecutive Omnigent "
"event-post failures; transcript/usage mirroring may be incomplete "
"(latest type=%s)",
_forward_health.consecutive_failures,
event_type,
)
_forward_health.degraded_logged = True
async def _replay_dead_letters_on_startup(
ap_client: httpx.AsyncClient,
bridge_dir: Path,
) -> None:
"""
Re-POST proven-undelivered dead-lettered forwards on forwarder startup (#1579).
Best-effort recovery for the realistic case — the host/server returned after
an outage or a restart. Delegates to the shared
:func:`replay_dead_letters` drain, supplying a re-POST that routes each
record to its recorded session via :func:`_post_session_event_inner` (the
inner so a re-failure does not double dead-letter through the wrapper).
Never raises: a replay failure must not block live forwarding.
:param ap_client: HTTP client for Omnigent event posts.
:param bridge_dir: Native Codex bridge directory holding the dead-letter files.
:returns: None.
"""
async def _repost(record: dict[str, object]) -> RepostResult:
session_id = record["session_id"]
event_type = record["event_type"]
payload = record["payload"]
assert isinstance(session_id, str)
assert isinstance(event_type, str)
assert isinstance(payload, dict)
result = await _post_session_event_inner(
ap_client,
session_id,
event_type=event_type,
data=payload,
max_attempts=1,
timeout=_REPLAY_POST_TIMEOUT_SECONDS,
)
response = result.response
if response is None:
return RepostResult(
delivered=False,
delivered_ambiguous=result.delivered_ambiguous,
http_status=None,
)
delivered = response.status_code < 400
return RepostResult(
delivered=delivered,
delivered_ambiguous=False,
http_status=None if delivered else response.status_code,
)
try:
await replay_dead_letters(
bridge_dir,
repost=_repost,
retryable_status_codes=_POST_RETRY_STATUS_CODES,
logger_name=__name__,
max_records=_REPLAY_MAX_RECORDS,
deadline_seconds=_REPLAY_DEADLINE_SECONDS,
)
except Exception: # noqa: BLE001 - replay must never block forwarder startup.
_logger.warning("Codex forwarder dead-letter replay failed", exc_info=True)
@dataclass(frozen=True)
class _PostResult:
"""
Classified outcome of one :func:`_post_session_event_inner` call (#1579).
Surfaces *why* a POST failed so the caller can dead-letter with the
structured classification replay needs — distinguishing the two ``None``
cases the inner used to conflate: an ambiguous-skip (the item may already
be committed) from a proven-undelivered transport failure after retries.
:param response: Final HTTP response, or ``None`` when no response was
seen (a transport failure, or an ambiguous conversation-item skip).
:param delivered_ambiguous: ``True`` when the POST was abandoned after an
ambiguous transport failure (request sent, response lost), so the item
may already be committed server-side — never safe to replay.
:param transport_error: Transport-error class name when a POST raised
without a response, e.g. ``"ConnectError"``; ``None`` when the server
responded.
"""
response: httpx.Response | None
delivered_ambiguous: bool = False
transport_error: str | None = None
async def _post_session_event(
client: httpx.AsyncClient,
session_id: str,
*,
event_type: str,
data: dict[str, Any],
) -> httpx.Response | None:
"""
Post one Omnigent session event, tracking forward-sync health (#1120).
Thin wrapper over :func:`_post_session_event_inner` that classifies the
outcome — a sub-400 response is a success; ``None`` or a >=400 final
response is a permanent failure — and updates :data:`_forward_health`
so a sustained outage escalates to a single ERROR instead of silently
dropping events. On a durable-event failure it dead-letters the dropped
payload with the structured classification replay needs (#1579).
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param event_type: Session event type, e.g.
``"external_conversation_item"``.
:param data: Event data payload, e.g. ``{"status": "running"}``.
:returns: The final HTTP response, or ``None`` (see
:func:`_post_session_event_inner`).
"""
result = await _post_session_event_inner(client, session_id, event_type=event_type, data=data)
response = result.response
if response is not None and response.status_code < 400:
_note_forward_success()
else:
_note_forward_failure(event_type)
dl_dir = _dead_letter_dir.get()
if event_type in _DEAD_LETTER_EVENT_TYPES and dl_dir is not None:
http_status = response.status_code if response is not None else None
if response is not None:
reason = f"http {response.status_code}"
elif result.delivered_ambiguous:
reason = "ambiguous transport failure (may already be committed)"
else:
reason = "proven-undelivered transport failure after retries"
append_dead_letter(
dl_dir,
session_id=session_id,
event_type=event_type,
payload=data,
reason=reason,
delivered_ambiguous=result.delivered_ambiguous,
http_status=http_status,
transport_error=result.transport_error,
)
return response
async def _post_session_event_inner(
client: httpx.AsyncClient,
session_id: str,
*,
event_type: str,
data: dict[str, Any],
max_attempts: int = _POST_MAX_ATTEMPTS,
timeout: float | None = None,
) -> _PostResult:
"""
Post one Omnigent session event with bounded transient retries.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param event_type: Session event type, e.g.
``"external_conversation_item"``.
:param data: Event data payload, e.g.
``{"status": "running"}``.
:param max_attempts: Maximum POST attempts before giving up, e.g. ``3``.
Startup dead-letter replay passes ``1`` — its natural retry cadence is
the next startup, so an in-call retry loop only adds latency (#1579).
:param timeout: Optional per-request timeout in seconds overriding the
client default, e.g. ``5.0``. Replay passes a short value so a hung
server fails fast instead of stalling startup on the 30s client default.
:returns: A :class:`_PostResult` carrying the final response, or — when no
response was seen — whether the POST was abandoned after an ambiguous
transport failure (``external_conversation_item`` only; the item may
already be committed, so retrying risks a duplicate) versus a
proven-undelivered transport failure after all retries.
"""
url = f"/v1/sessions/{url_component(session_id)}/events"
payload = {"type": event_type, "data": data}
for attempt in range(1, max_attempts + 1):
try:
if timeout is None:
response = await client.post(url, json=payload)
else:
response = await client.post(url, json=payload, timeout=timeout)
except httpx.HTTPError as exc:
# Conversation items persist with a random primary key and no
# server-side dedup, so an ambiguous failure (request sent,
# response lost — the server may have committed it) must not
# be retried: a re-post would duplicate the item.
# Other event types are idempotent / transient, so retrying
# them on the same errors is safe and preserves delivery.
if event_type == "external_conversation_item" and post_may_have_been_delivered(exc):
_logger.warning(
"skipping Codex session event after an ambiguous transport "
"failure (may already be committed); not retrying to avoid "
"a duplicate: type=%s error=%r",
event_type,
exc,
)
return _PostResult(
response=None,
delivered_ambiguous=True,
transport_error=type(exc).__name__,
)
if _is_final_post_attempt(attempt, max_attempts):
_log_post_transport_failure(event_type, exc, max_attempts)
return _PostResult(response=None, transport_error=type(exc).__name__)
await _sleep(_post_retry_delay(attempt))
continue
# An HTTP response (no transport error) proves the server is reachable,
# so clear any stale connectivity-failure record — otherwise a recovered
# connection could have an old failure misattributed to a later,
# unrelated idle-watchdog stall.
note_native_post_success()
if _post_response_is_final(response, attempt, max_attempts):
return _PostResult(response=response)
await _sleep(_post_retry_delay(attempt))
return _PostResult(response=None)
def _post_response_is_final(response: httpx.Response, attempt: int, max_attempts: int) -> bool:
"""
Return whether a session-event POST response should stop retries.
:param response: HTTP response from AP.
:param attempt: One-based attempt number, e.g. ``1``.
:param max_attempts: Maximum POST attempts allowed, e.g. ``3``.
:returns: ``True`` when the caller should return ``response``.
"""
if response.status_code < 400:
return True
if not _should_retry_post_status(response.status_code):
return True
return _is_final_post_attempt(attempt, max_attempts)
def _is_final_post_attempt(attempt: int, max_attempts: int) -> bool:
"""
Return whether an Omnigent event POST attempt is the final try.
:param attempt: One-based attempt number, e.g. ``3``.
:param max_attempts: Maximum POST attempts allowed, e.g. ``3``.
:returns: ``True`` when no further retry is allowed.
"""
return attempt >= max_attempts
def _log_post_transport_failure(event_type: str, exc: httpx.HTTPError, max_attempts: int) -> None:
"""
Log an exhausted Omnigent session-event transport failure.
:param event_type: Session event type, e.g.
``"external_conversation_item"``.
:param exc: Final transport error.
:param max_attempts: Number of attempts that were made, e.g. ``3``.
:returns: None.
"""
_logger.warning(
"failed to post Codex session event after retries: type=%s attempts=%s error=%r",
event_type,
max_attempts,
exc,
)
# Surface this connectivity failure to the harness idle-turn watchdog: if
# the turn stalls because events can't reach the server, the watchdog
# attaches this cause to the failure reason instead of a generic
# "wedged LLM" message.
record_native_post_failure(event_type, exc)
def _log_failed_session_event_post(
event_type: str,
response: httpx.Response | None,
) -> None:
"""
Log failed best-effort session events such as status and usage.
:param event_type: Session event type, e.g.
``"external_session_status"``.
:param response: Final Omnigent response, or ``None`` after transport
errors exhausted all retries.
:returns: None.
"""
if response is None:
_logger.warning("failed to post Codex session event: type=%s", event_type)
return
if response.status_code >= 400:
_logger.warning(
"failed to post Codex session event: type=%s status=%s body=%s",
event_type,
response.status_code,
response.text[:1000],
)
def _should_retry_post_status(status_code: int) -> bool:
"""
Return whether an Omnigent event POST status is transient.
:param status_code: HTTP status code, e.g. ``503``.
:returns: ``True`` when the forwarder should retry.
"""
return status_code in _POST_RETRY_STATUS_CODES
def _post_retry_delay(attempt: int) -> float:
"""
Return the retry delay for a failed Omnigent event POST attempt.
:param attempt: One-based failed attempt number, e.g. ``1``.
:returns: Delay in seconds before the next attempt.
"""
return _POST_RETRY_DELAY_SECONDS * attempt
def _turn_id_from_payload(payload: object) -> str | None:
"""
Extract a turn id from a Codex payload.
:param payload: Codex notification params or nested turn object.
:returns: Turn id, or ``None`` when absent.
"""
if not isinstance(payload, dict):
return None
value = payload.get("id") or payload.get("turnId")
return value if isinstance(value, str) and value else None
def _turn_status_from_params(params: dict[str, Any]) -> str | None:
"""
Extract a Codex turn status from terminal notification params.
:param params: Codex terminal params, e.g.
``{"turn": {"id": "turn_123", "status": "interrupted"}}``.
:returns: Status string, e.g. ``"interrupted"``, or ``None``.
"""
status: object = params.get("status")
turn = params.get("turn")
if isinstance(turn, dict):
status = turn.get("status")
if isinstance(status, dict):
status = status.get("type") or status.get("status")
return status if isinstance(status, str) and status else None
def _turn_status_is_interrupted(status: str | None) -> bool:
"""
Return whether a Codex turn status represents user interruption.
:param status: Codex turn status, e.g. ``"interrupted"``.
:returns: ``True`` for interrupted/cancelled terminal statuses.
"""
if status is None:
return False
normalized = status.replace("_", "").replace("-", "").lower()
return normalized in {"interrupted", "cancelled", "canceled"}
def _params_with_turn_id(params: dict[str, Any], turn_id: str) -> dict[str, Any]:
"""
Return params with a top-level ``turnId`` for Omnigent response ids.
:param params: Codex notification params.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:returns: Shallow-copied params containing ``turnId``.
"""
scoped = dict(params)
scoped["turnId"] = turn_id
return scoped
def _thread_id_from_started_event(event: CodexMessage) -> str | None:
"""
Extract a thread id from a Codex ``thread/started`` event.
:param event: Codex app-server notification envelope.
:returns: Thread id, e.g. ``"thread_abc"``, or ``None``.
"""
if event.get("method") != "thread/started":
return None
params = event.get("params")
if not isinstance(params, dict):
return None
thread = params.get("thread")
if not isinstance(thread, dict):
return None
thread_id = thread.get("id")
return thread_id if isinstance(thread_id, str) and thread_id else None
def _parent_thread_id_from_started_event(event: CodexMessage) -> str | None:
"""
Extract the spawning parent thread id from a child ``thread/started``.
:param event: Codex app-server notification envelope.
:returns: Parent Codex thread id from
``source.subAgent.thread_spawn.parent_thread_id``, e.g.
``"thread_parent"``, or ``None`` when absent.
"""
if event.get("method") != "thread/started":
return None
params = event.get("params")
if not isinstance(params, dict):
return None
thread = params.get("thread")
if not isinstance(thread, dict):
return None
source = _thread_spawn_source(thread)
if source is None:
return None
parent_thread_id = source.get("parent_thread_id")
return parent_thread_id if isinstance(parent_thread_id, str) and parent_thread_id else None
def _thread_started_is_subagent(event: CodexMessage) -> bool:
"""
Return whether a ``thread/started`` event announces a child sub-agent.
Codex AgentControl children emit ``thread/started`` when they begin.
These events carry a ``source.subAgent.thread_spawn`` object that
distinguishes them from a top-level session rotation triggered by
the user running ``/clear``.
:param event: Codex app-server notification envelope.
:returns: ``True`` when the started thread declares itself a
sub-agent via ``source.subAgent.thread_spawn``.
"""
if event.get("method") != "thread/started":
return False
params = event.get("params")
if not isinstance(params, dict):
return False
thread = params.get("thread")
if not isinstance(thread, dict):
return False
return _thread_spawn_source(thread) is not None
async def wait_for_thread_started(
client: CodexAppServerClient,
*,
timeout: float = _THREAD_START_TIMEOUT_SECONDS,
) -> str:
"""
Wait for a freshly launched Codex TUI to create its app-server thread.
A cold-start Codex TUI (launched with ``--remote`` and no ``resume``)
creates a new thread, and the app-server emits a ``thread/started``
notification to connected listeners. *client* must already be connected
so it observes that notification. The returned id is then used to
subscribe the forwarder and to drive web-UI message injection, so the
terminal and chat share one thread. The host-spawned runner auto-create
uses this because — unlike the local CLI — it has no TTY to ``resume`` an
existing thread into, and ``resume`` of a not-yet-persisted thread fails.
:param client: A connected :class:`CodexAppServerClient` listening for
app-server notifications.
:param timeout: Seconds to wait for ``thread/started`` before failing.
:returns: The Codex thread id, e.g.
``"019e8720-98d7-7b23-ac0a-bfb0eb02e0c9"``.
:raises TimeoutError: If no ``thread/started`` arrives within *timeout*.
:raises RuntimeError: If the event stream ends before a thread starts.
"""
async with asyncio.timeout(timeout):
async for event in client.iter_events():
thread_id = _thread_id_from_started_event(event)
if thread_id is not None:
return thread_id
raise RuntimeError("Codex app-server event stream ended before thread startup.")
def _thread_id_from_params(params: dict[str, Any]) -> str | None:
"""
Extract the thread id carried by a Codex notification params object.
:param params: Codex notification params, e.g.
``{"threadId": "thread_abc"}``.
:returns: Thread id, or ``None`` when the event does not carry one.
"""
thread_id = params.get("threadId")
if isinstance(thread_id, str) and thread_id:
return thread_id
thread = params.get("thread")
if isinstance(thread, dict):
nested_thread_id = thread.get("id")
if isinstance(nested_thread_id, str) and nested_thread_id:
return nested_thread_id
return None
def _is_active_turn_delta(bridge_dir: Path, turn_id: str | None) -> bool:
"""
Return whether a Codex delta belongs to the current active turn.
:param bridge_dir: Native Codex bridge directory.
:param turn_id: Codex turn id from the delta notification, e.g.
``"turn_123"``.
:returns: ``True`` when the bridge state identifies the same
active turn.
"""
if turn_id is None:
return False
state = read_bridge_state(bridge_dir)
return state is not None and state.active_turn_id == turn_id
def _item_id_from_delta_params(params: dict[str, Any]) -> str | None:
"""
Extract a Codex item id from a streaming delta notification.
:param params: Codex delta params, e.g.
``{"itemId": "item_abc123"}``.
:returns: Item id, or ``None`` when absent.
"""
item_id = params.get("itemId")
return item_id if isinstance(item_id, str) and item_id else None
def _streaming_message_id(params: dict[str, Any], item_type: str) -> str | None:
"""
Build a stable Omnigent live-delta stream id for a Codex item.
Omnigent Web uses this id to keep terminal-observed live text in a
provisional native block, then replace that block when the durable
completed item arrives. Returning ``None`` preserves the generic
Responses-style text stream for malformed deltas that carry no
usable Codex identity.
:param params: Codex delta params, e.g.
``{"threadId": "thread_123", "turnId": "turn_123",
"itemId": "item_agent"}``.
:param item_type: Codex item type, e.g. ``"agentMessage"``.
:returns: Stable message id, e.g.
``"codex:thread_123:turn_123:agentMessage:item_agent"``, or
``None``.
"""
thread_id = _thread_id_from_params(params)
turn_id = _turn_id_from_payload(params)
item_id = _item_id_from_delta_params(params)
if thread_id is None and turn_id is None and item_id is None:
return None
parts = ["codex"]
if thread_id is not None:
parts.append(thread_id)
if turn_id is not None:
parts.append(turn_id)
parts.append(item_type)
if item_id is not None:
parts.append(item_id)
return ":".join(parts)
def _record_partial_text_delta(
forwarder_state: _CodexForwarderState | None,
*,
turn_id: str | None,
item_type: str,
item_id: str | None,
delta: str,
) -> None:
"""
Record a visible Codex text delta for interrupted-turn durability.
:param forwarder_state: Mutable forwarder state, or ``None`` when direct
tests bypass stateful supervision.
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
:param item_type: Codex item type, e.g. ``"agentMessage"``.
:param item_id: Codex item id, e.g. ``"item_abc123"``, or ``None``.
:param delta: Text fragment, e.g. ``"hel"``.
:returns: None.
"""
if forwarder_state is None or turn_id is None:
return
forwarder_state.record_partial_text_delta(
turn_id=turn_id,
item_type=item_type,
item_id=item_id,
delta=delta,
)
def _try_recover_active_turn_from_delta(
bridge_dir: Path,
params: dict[str, Any],
turn_id: str | None,
) -> bool:
"""
Adopt a Codex delta turn when subscription missed ``turn/started``.
Fresh remote Codex sessions can begin a TUI turn while the observer
connection is still retrying ``thread/resume``. In that race the
first plan delta is already scoped by ``threadId``/``turnId`` but
bridge state has no active turn yet. Treat that as the current turn
only when the thread matches the bridge state; an already-active
different turn remains protected from stale deltas.
:param bridge_dir: Native Codex bridge directory.
:param params: Codex delta notification params.
:param turn_id: Turn id extracted from *params*.
:returns: ``True`` when the delta was adopted as the active turn.
"""
if turn_id is None:
return False
state = read_bridge_state(bridge_dir)
if state is None or state.active_turn_id is not None:
return False
thread_id = params.get("threadId")
if thread_id != state.thread_id:
return False
update_active_turn_id(bridge_dir, turn_id)
return True
def _delta_recovery_status_edge(
bridge_dir: Path,
params: dict[str, Any],
turn_id: str | None,
) -> _CodexTurnStatusEdge | None:
"""
Recover a missed turn start from a scoped Codex delta.
:param bridge_dir: Native Codex bridge directory.
:param params: Codex delta notification params.
:param turn_id: Turn id extracted from *params*, e.g.
``"turn_abc123"``.
:returns: Running status edge when the delta adopts the turn, or
``None`` when the delta is stale or ambiguous.
"""
if not _try_recover_active_turn_from_delta(bridge_dir, params, turn_id):
return None
return _CodexTurnStatusEdge(
status="running",
turn_id=turn_id,
source="delta:recovered",
)
def _user_message_text(item: dict[str, Any]) -> str:
"""
Convert a Codex ``userMessage`` item into plain text.
:param item: Codex ``userMessage`` item.
:returns: Joined text content.
"""
content = item.get("content")
if not isinstance(content, list):
return ""
parts: list[str] = []
for block in content:
if not isinstance(block, dict):
continue
text = block.get("text")
if isinstance(text, str) and text:
parts.append(text)
return "\n\n".join(parts)
def _user_message_has_file_content(item: dict[str, Any]) -> bool:
"""
Return whether a Codex ``userMessage`` carries a non-text block.
Codex echoes an attached image/file as a non-text content block (an
image-only message arrives as ``[{"type": "image", "url": ...}]`` with
no text block). Callers use this to decide whether a text-less message
is still real and must be persisted, versus a genuinely empty one.
:param item: Codex ``userMessage`` item.
:returns: ``True`` when any content block is a non-text (image/file)
block, ``False`` otherwise.
"""
content = item.get("content")
if not isinstance(content, list):
return False
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type")
if isinstance(block_type, str) and block_type and block_type != "text":
return True
return False
def _is_codex_skill_wrapper(text: str) -> bool:
stripped = text.strip()
return stripped.startswith("<skill>") and stripped.endswith("</skill>")
def _json_string(value: dict[str, Any]) -> str | None:
"""
Serialize a dict for OpenAI-compatible function call arguments.
:param value: JSON-serializable dictionary, e.g.
``{"command": "pwd"}``.
:returns: JSON string, or ``None`` when serialization fails.
"""
try:
return json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
return None
def _plan_text_from_update(params: dict[str, Any]) -> str | None:
"""
Render a Codex ``turn/plan/updated`` payload as Markdown text.
:param params: Codex plan update params.
:returns: Markdown plan text, or ``None`` when no valid plan steps
are present.
"""
plan = params.get("plan")
if not isinstance(plan, list) or not plan:
return None
lines: list[str] = []
explanation = params.get("explanation")
if isinstance(explanation, str) and explanation:
lines.append(explanation)
lines.append("")
lines.append("Plan:")
for entry in plan:
if not isinstance(entry, dict):
continue
step = entry.get("step")
if not isinstance(step, str) or not step:
continue
status = entry.get("status")
marker = _plan_status_marker(status)
lines.append(f"{marker} {step}")
if len(lines) == 1 or (len(lines) == 3 and lines[-1] == "Plan:"):
return None
return "\n".join(lines)
def _plan_status_marker(status: Any) -> str:
"""
Return a readable Markdown marker for a Codex plan step status.
:param status: Codex step status value.
:returns: Markdown list marker.
"""
if status == "completed":
return "- [x]"
if status in {"inProgress", "in_progress"}:
return "- [~]"
return "- [ ]"
def _response_id(params: dict[str, Any]) -> str:
"""
Build a stable Omnigent response id for a Codex notification.
:param params: Codex notification params.
:returns: Response id, e.g. ``"codex_turn_abc123"``.
"""
turn_id = params.get("turnId")
if isinstance(turn_id, str) and turn_id:
return f"codex_{turn_id}"
return "codex_native"
def _source_id(params: dict[str, Any], item: dict[str, Any]) -> str:
"""
Build a stable per-record label for one Codex item.
Only used for debug-log correlation — it is not sent to the server
and is not a dedup key (the server persists external items with a
random primary key).
:param params: Codex notification params.
:param item: Codex item payload.
:returns: Record label, e.g. ``"turn_abc:item_xyz"``.
"""
turn_id = params.get("turnId")
item_id = item.get("id")
left = turn_id if isinstance(turn_id, str) and turn_id else "thread"
right = item_id if isinstance(item_id, str) and item_id else "item"
return f"{left}:{right}"
def _completed_item_key(
params: dict[str, Any],
item: dict[str, Any],
forwarder_state: _CodexForwarderState,
) -> tuple[str, bool]:
"""
Build a total dedup key for one durable Codex transcript item.
The key is always non-empty so dedup is never silently disabled.
Items with stable Codex-assigned ``id`` fields use
``threadId:turnId:item.id`` — identical across replay and live
deliveries of the same item, so the second delivery is correctly
dropped by the dedup gate.
Items without a stable ``id`` fall back to a per-(thread, turn)
positional counter. The counter is peeked here and only advanced by
the caller after a successful claim. This guarantees *distinctness
within a turn* (two genuinely different anonymous items get different
keys) and ensures the key is never ``None`` (which would silently
disable dedup). It does **not** guarantee cross-delivery dedup for
anonymous items: if replay and live each deliver an anonymous item in
the same (thread, turn), both advance the counter from the same
starting value and therefore collide — one will be dropped. However,
because Codex emits a stable ``id`` on all durable transcript items
in practice, this anonymous path is a safety net for malformed events,
not a primary dedup mechanism.
:param params: Codex ``item/completed`` params.
:param item: Codex item payload.
:param forwarder_state: Mutable state holding per-(thread, turn)
anonymous item counters.
:returns: ``(key, is_anonymous)`` where ``key`` is the dedup key and
``is_anonymous`` is ``True`` when a positional counter was used.
"""
thread_id = _thread_id_from_params(params) or "thread"
turn_id = params.get("turnId")
turn_id = turn_id if isinstance(turn_id, str) and turn_id else "turn"
item_id = item.get("id")
if isinstance(item_id, str) and item_id:
return f"{thread_id}:{turn_id}:{item_id}", False
return forwarder_state.peek_anon_item_key(thread_id, turn_id), True