"""Native Codex TUI wrapper for the Omnigent CLI.""" from __future__ import annotations import asyncio import contextlib import json import logging import os import re import secrets import shutil import socket import time import uuid from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from tempfile import TemporaryDirectory from typing import Any import click import httpx import yaml from omnigent._native_resume_hint import echo_native_resume_hint from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress from omnigent._wrapper_labels import ( CODEX_NATIVE_WRAPPER_VALUE as _WRAPPER_LABEL_VALUE, ) from omnigent._wrapper_labels import WRAPPER_LABEL_KEY as _WRAPPER_LABEL_KEY from omnigent.claude_native import ( _attach_with_reconnect, attach_local_terminal, ) from omnigent.claude_native_bridge import url_component from omnigent.codex_native_app_server import ( CodexAppServerClient, CodexNativeAppServer, build_codex_native_server, build_codex_remote_args, client_for_transport, codex_session_meta_model_provider, codex_terminal_env, preload_codex_thread_for_resume, resolve_native_codex_launch, ) from omnigent.codex_native_bridge import ( CODEX_NATIVE_BRIDGE_ID_LABEL_KEY, CodexNativeBridgeState, bridge_dir_for_bridge_id, clear_bridge_state, codex_home_for_bridge_dir, prepare_bridge_dir, read_bridge_state, socket_path_for_bridge_dir, write_bridge_state, ) from omnigent.codex_native_forwarder import supervise_forwarder from omnigent.codex_native_state import read_launch_state, write_launch_state from omnigent.conversation_browser import conversation_url, open_conversation_link_if_enabled from omnigent.entities.session_resources import terminal_resource_id from omnigent.host.daemon_launch import ( error_text, launch_or_reuse_daemon_runner, wait_for_host_online, wait_for_runner_online, ) from omnigent.native_coding_agents import native_shell_terminal_spec from omnigent.native_terminal import ( DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S, ) from omnigent.native_terminal import ( DAEMON_RUNNER_ONLINE_TIMEOUT_S as _DAEMON_RUNNER_ONLINE_TIMEOUT_S, ) from omnigent.native_terminal import ( DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S, ) from omnigent.native_terminal import ( bind_session_runner as _bind_session_runner, ) from omnigent.native_terminal import ( terminal_attach_url as _attach_url, ) _logger = logging.getLogger(__name__) _AGENT_NAME = "codex-native-ui" _DEFAULT_CODEX_COMMAND = "codex" _TERMINAL_NAME = "codex" _TERMINAL_SESSION_KEY = "main" _CODEX_TERMINAL_SCROLLBACK_LINES = 100_000 _CODEX_THREAD_START_TIMEOUT_SECONDS = 15.0 _SESSION_LABELS = { "omnigent.ui": "terminal", _WRAPPER_LABEL_KEY: _WRAPPER_LABEL_VALUE, } _RESUME_ACTION_SWITCH = "switch" _RESUME_ACTION_CANCEL = "cancel" _RUNNER_UNAVAILABLE_ERROR_CODE = "runner_unavailable" _CONFLICT_ERROR_CODE = "conflict" _RUNNER_OFFLINE_MESSAGE_FRAGMENT = " is offline for conversation " _UNBOUND_RUNNER_MESSAGE_FRAGMENT = "not bound to a runner" # Codex thread ids are UUIDv7 (time-ordered), e.g. # ``"019e96aa-0be2-7343-8d3b-6f914d60936b"``. Restricting the cloned id to # hex + hyphens keeps it safe to interpolate into a rollout filename and a # ``codex resume`` argument (no path separators / traversal). _CODEX_THREAD_ID_RE = re.compile(r"^[0-9a-fA-F-]+$") _CODEX_AUTH_UNAVAILABLE_BINARY_MISSING = "binary-missing" _CODEX_AUTH_UNAVAILABLE_NEEDS_AUTH = "needs-auth" @dataclass(frozen=True) class _CodexAuthSource: """Resolved source for Codex authentication material.""" auth_path: Path def _resolve_codex_auth_source() -> _CodexAuthSource: """ Resolve the local Codex auth source used for availability checks. This is the seam for future managed credentials: once Omnigent can provide centrally managed Codex credentials, this resolver can return that source instead. For now it deliberately defaults to Codex's local ``auth.json`` via the same ``CODEX_HOME`` source resolver used when launching native Codex, so inherited private Omnigent homes map back to the user's real Codex home. :returns: Local Codex auth source to inspect synchronously. """ from omnigent.inner.codex_executor import _codex_home_config_source_from_env return _CodexAuthSource(auth_path=_codex_home_config_source_from_env() / "auth.json") def _codex_auth_json_has_available_credential(auth_path: Path) -> bool: """Return whether ``auth.json`` parses and carries a usable credential. Presence-based by design. A real Codex ``auth.json`` (see the openai/codex ``AuthDotJson`` shape) is one of: * **API-key** auth — a top-level ``OPENAI_API_KEY`` (or a ``personal_access_token`` from ``codex login --with-access-token``). * **ChatGPT / OAuth** auth — a ``tokens`` object holding ``access_token`` / ``refresh_token`` (and an ``id_token``). There is intentionally **no expiry judgement**. Codex stores no top-level expiry field; ChatGPT-mode access tokens are short-lived JWTs that Codex silently refreshes via the long-lived ``refresh_token`` (recorded in ``last_refresh``), so an "expired" ``access_token`` is the normal between-refresh state, not a deauth. Whether the ``refresh_token`` itself is still valid is server-side and opaque, so this local-only, side-effect-free check only asks whether a credential is *configured* — not whether it would authenticate. A revoked/truly-expired session can therefore still surface as available and fail at run time; catching that needs a network probe, which is out of scope here. :param auth_path: Path to the Codex ``auth.json``. :returns: ``True`` when the file parses and contains a credential field; ``False`` when it is missing, malformed, or carries no credential. """ try: raw = auth_path.read_text(encoding="utf-8") except OSError: return False try: data = json.loads(raw) except json.JSONDecodeError: return False if not isinstance(data, dict): return False for key in ("OPENAI_API_KEY", "personal_access_token"): value = data.get(key) if isinstance(value, str) and value.strip(): return True tokens = data.get("tokens") if isinstance(tokens, dict): for field in ("access_token", "refresh_token"): value = tokens.get(field) if isinstance(value, str) and value.strip(): return True return False def _codex_auth_unavailable_reason() -> str | None: """ Return why local Codex is unavailable, or ``None`` when available. Readiness must ask the same question the launch resolver answers, not read a credential file the launch ignores. :func:`resolve_native_codex_launch` routes a Databricks-gateway / provider-configured setup through a Databricks profile or a ``model_provider`` override and mints its bearer at run time (``databricks auth token`` / a provider auth command) — it never reads ``auth.json``. So on such a host ``auth.json`` is legitimately empty, and gating on it is a false negative (the launch works). Only when the launch defers to Codex's *own* login is ``auth.json`` the credential that decides availability, so that is the only case gated on it. This mirrors the fail-open the ``claude-sdk`` / ``openai-agents`` gateway harnesses already rely on: their gateway token is a runtime mint the daemon can't observe. The check stays synchronous, side-effect free, and local: it resolves the launch (local config reads) and, only on the defer-to-login path, inspects the local auth source. It never runs ``codex login``, a status command, or a network probe; any resolver failure fails safe onto the ``auth.json`` check. :returns: ``"binary-missing"`` when the CLI is absent, ``"needs-auth"`` when the launch would defer to Codex's own login but ``auth.json`` is missing, malformed, or carries no credential, and ``None`` when a provider will route the launch or a login credential is configured. Token *validity* (revoked/expired refresh, an unreachable gateway) is not judged locally — it surfaces at the first turn via the executor. """ if shutil.which(_DEFAULT_CODEX_COMMAND) is None: return _CODEX_AUTH_UNAVAILABLE_BINARY_MISSING # ponytail: resolve_native_codex_launch runs once per codex spelling # (codex / codex-native / native-codex → 3×) per hello frame; on a host with # NO configured provider it also runs ambient detection (a localhost ollama # probe + a `claude auth status` subprocess). It's off the event loop and # only bites unconfigured hosts — memoize the launch across the map build in # configured_harness_map if that cost ever shows up. try: launch = resolve_native_codex_launch(model=None) routes_through_provider = ( launch.profile is not None or codex_session_meta_model_provider(launch) != "openai" ) except Exception: # noqa: BLE001 - readiness must never raise; fail onto auth.json. _logger.debug("codex readiness: launch resolve failed; using auth.json", exc_info=True) routes_through_provider = False if routes_through_provider: return None source = _resolve_codex_auth_source() if not _codex_auth_json_has_available_credential(source.auth_path): return _CODEX_AUTH_UNAVAILABLE_NEEDS_AUTH return None def _update_startup_progress( startup_progress: RunnerStartupProgress | None, message: str, ) -> None: """ Show one concise Codex startup milestone when a renderer is active. :param startup_progress: Optional progress renderer from :func:`runner_startup_progress`. :param message: User-facing status text, e.g. ``"Starting Codex terminal..."``. :returns: None. """ if startup_progress is not None: startup_progress.update(message) @dataclass(frozen=True) class _ResumeWorkspaceActionOption: """ One selectable action in the Codex cwd-mismatch prompt. :param action: Stable action value returned to the caller, e.g. ``"switch"``. :param label: User-facing action label, e.g. ``"Switch working directory to /home/me/repo"``. """ action: str label: str @dataclass class LaunchedCodexTerminal: """ Terminal resource returned by the Omnigent runner launch path. :param terminal_id: Terminal resource id, e.g. ``"terminal_codex_main"``. :param tmux_socket: Local tmux socket path when the runner exposed one, e.g. ``"/tmp/omnigent-terminal-x/tmux.sock"``. :param tmux_target: Tmux target when exposed by the runner, e.g. ``"main"``. """ terminal_id: str tmux_socket: Path | None tmux_target: str | None @dataclass class PreparedCodexTerminal: """ Prepared native Codex terminal attachment details. :param session_id: Omnigent session/conversation id. :param terminal_id: Terminal resource id to attach. :param tmux_socket: Local tmux socket path when the runner exposed one and it is reachable from this CLI process. :param tmux_target: Tmux target for direct local attaches, e.g. ``"main"``. :param bridge_dir: Native Codex bridge directory. :param thread_id: Codex app-server thread id. ``None`` until the first attached TUI creates a fresh thread. :param app_server_url: App-server transport the TUI, forwarder, and initial-turn connect over, e.g. ``"ws://127.0.0.1:9876"``. ``None`` for runner-owned terminal attaches where the CLI never connects to the app-server directly. :param app_server: Running app-server process when this wrapper invocation owns it. ``None`` for reattached live terminals. :param event_client: App-server client already listening for the Codex thread. Fresh sessions keep this listener open after it observes the TUI-created ``thread/started`` event. :param reattached: ``True`` when an existing terminal was reused. """ session_id: str terminal_id: str tmux_socket: Path | None tmux_target: str | None bridge_dir: Path thread_id: str | None app_server_url: str | None app_server: CodexNativeAppServer | None event_client: CodexAppServerClient | None reattached: bool def run_codex_native( *, server: str | None, session_id: str | None, codex_args: tuple[str, ...], resume_picker: bool = False, command: str = _DEFAULT_CODEX_COMMAND, model: str | None = None, prompt: str | None = None, auto_open_conversation: bool = False, ) -> None: """ Launch Codex TUI in an Omnigent terminal. :param server: Resolved Omnigent server URL, e.g. ``"http://127.0.0.1:8123"``. :param session_id: Optional existing Omnigent conversation id, e.g. ``"conv_abc123"``. :param codex_args: Raw Codex CLI args to pass before ``resume``. :param resume_picker: ``True`` runs the Codex-native picker. :param command: Codex executable, e.g. ``"codex"``. :param model: Optional model id, e.g. ``"gpt-5.4-mini"``. :param prompt: Optional first prompt to send after launch. :param auto_open_conversation: When ``True``, open the browser conversation URL after the session is prepared. :returns: None after the terminal attach session ends. :raises click.ClickException: If setup fails. """ resolved_command = command.strip() if not resolved_command: raise click.ClickException("Codex command must not be empty.") _preflight_local_tools() if server is None: raise click.ClickException( "Codex requires a resolved Omnigent server URL. The CLI should call " "_ensure_backend before run_codex_native." ) with TemporaryDirectory(prefix="omnigent-codex-native-") as tmpdir: spec_path = _materialize_codex_agent_spec(Path(tmpdir), model=model) _run_with_remote_server( server.rstrip("/"), spec_path, session_id=session_id, resume_picker=resume_picker, codex_args=codex_args, model=model, prompt=prompt, auto_open_conversation=auto_open_conversation, ) def _record_launch_for_fresh_session(session_id: str) -> None: """ Persist the wrapper's current cwd as the Codex session launch state. :param session_id: Newly created Omnigent conversation id, e.g. ``"conv_abc123"``. :returns: None. """ try: write_launch_state(session_id, str(Path.cwd().resolve())) except OSError: _logger.warning( "failed to record codex-native launch state for %s", session_id, exc_info=True, ) def _align_working_directory_with_session(session_id: str) -> None: """ Resolve cwd mismatch before resuming a Codex-native session. Native Codex state is workspace-scoped from the user's point of view: the app-server and TUI should reopen from the directory where the session was created. If client-side launch state is present and points at a different existing directory, ask whether to switch there before the runner and app-server sample cwd. :param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``. :returns: None. Side-effect-only; may change process cwd. :raises click.ClickException: If recorded state exists but no viable resume directory exists, or if the user cancels. """ state = read_launch_state(session_id) if state is None: return current = Path.cwd().resolve() recorded_path = Path(state.working_directory).resolve() if current == recorded_path: return if not recorded_path.is_dir(): raise click.ClickException( f"Session {session_id} was created in {recorded_path}, but that " "directory no longer exists. Recreate or move the project back " "before resuming Codex." ) action = _prompt_codex_resume_workspace_action( recorded_path=recorded_path, current=current, ) if action == _RESUME_ACTION_SWITCH: _switch_to_recorded_working_directory(recorded_path) return raise click.ClickException("Resume cancelled.") def _prompt_codex_resume_workspace_action( *, recorded_path: Path, current: Path, ) -> str: """ Ask how to handle a Codex resume cwd mismatch. :param recorded_path: Recorded launch cwd, already resolved. :param current: Current cwd, already resolved. :returns: One of ``"switch"`` or ``"cancel"``. """ options = _codex_resume_workspace_action_options(recorded_path=recorded_path) click.echo(f"\nSession was started in: {recorded_path}", err=True) click.echo(f"Current working directory: {current}", err=True) click.echo("Codex resume is workspace-scoped. Choose an action:", err=True) for option in options: click.echo(f" {option.action:<6} - {option.label}", err=True) return click.prompt( "Resume action", type=click.Choice([option.action for option in options]), default=options[0].action, show_choices=True, err=True, ) def _codex_resume_workspace_action_options( *, recorded_path: Path, ) -> list[_ResumeWorkspaceActionOption]: """ Build the valid actions for a cwd-mismatched Codex resume. :param recorded_path: Recorded launch cwd, already resolved. :returns: Action options in display order. """ return [ _ResumeWorkspaceActionOption( action=_RESUME_ACTION_SWITCH, label=f"Switch working directory to {recorded_path}", ), _ResumeWorkspaceActionOption( action=_RESUME_ACTION_CANCEL, label="Cancel resume", ), ] def _switch_to_recorded_working_directory(recorded_path: Path) -> None: """ Switch process cwd to *recorded_path* for Codex resume. :param recorded_path: Existing recorded launch cwd. :returns: None. """ os.chdir(recorded_path) click.echo(f"Switched to {recorded_path}.", err=True) def _materialize_codex_agent_spec( tmpdir: Path, *, model: str | None, ) -> Path: """ Write the terminal-first agent spec used by ``omnigent codex``. :param tmpdir: Temporary directory for the generated YAML file. :param model: Optional model id, e.g. ``"gpt-5.4-mini"``. :returns: Path to the generated YAML spec. """ yaml_path = tmpdir / "codex-native-ui.yaml" executor: dict[str, str] = {"harness": "codex-native"} if model is not None: executor["model"] = model raw: dict[str, Any] = { "name": _AGENT_NAME, "prompt": ( "Codex is running in the session terminal. Web UI messages are " "forwarded into the same native Codex app-server thread." ), "executor": executor, # Opt the native session into the child-session spawn writes # (sys_session_create / sys_session_send / sys_session_close) # so the wrapped codex can author agent configs and launch # them as sub-agent sessions. The relay derives its advertised # tool set from this spec via ToolManager. "spawn": True, "os_env": { "type": "caller_process", "cwd": ".", "sandbox": {"type": "none"}, }, # Declare a default shell terminal so the relay advertises the # ``sys_terminal_*`` family to the wrapped codex (the relay's # gate is a non-empty ``terminals:`` block on this spec). Its # command follows the user's ``$SHELL`` (zsh/fish/bash); caller # process / no sandbox matches the ``os_env`` stance above — the # native CLI already runs unsandboxed on the user's workspace. "terminals": native_shell_terminal_spec(), } yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8") return yaml_path def _run_with_local_server( spec_path: Path, *, session_id: str | None, resume_picker: bool, codex_args: tuple[str, ...], command: str, model: str | None, prompt: str | None, auto_open_conversation: bool = False, ) -> None: """ Start a local Omnigent server, launch Codex, and attach to it. :param spec_path: Generated Codex wrapper agent spec. :param session_id: Optional existing Omnigent session id. :param resume_picker: When ``True``, run the Codex-native picker. :param codex_args: Raw Codex CLI args. :param command: Codex executable to run. :param model: Optional Codex model id. :param prompt: Optional first prompt. :param auto_open_conversation: When ``True``, open the browser conversation URL after the session is prepared. :returns: None. """ from omnigent.chat import ( _bundle_agent, _find_free_port, _start_local_server, _stop_local_server, _wait_for_server, ) port = _find_free_port() server_handle = _start_local_server(spec_path, port, ephemeral=False) base_url = f"http://127.0.0.1:{port}" try: _wait_for_server(port, server_handle) resolved_session_id = _resolve_session_id_for_resume( base_url=base_url, headers={}, session_id=session_id, resume_picker=resume_picker, ) if resolved_session_id is None and resume_picker and session_id is None: return if resolved_session_id is not None: _align_working_directory_with_session(resolved_session_id) async def _drive() -> None: """ Prepare Codex and attach in a single event loop. :returns: None. """ with runner_startup_progress(initial_message="Preparing Codex...") as progress: bundle = None if resolved_session_id is not None else _bundle_agent(spec_path) prepared = await _prepare_codex_terminal( base_url=base_url, headers={}, session_id=resolved_session_id, runner_id=server_handle.runner_id, session_bundle=bundle, codex_args=codex_args, command=command, model=model, startup_progress=progress, ) if resolved_session_id is None: _record_launch_for_fresh_session(prepared.session_id) click.echo(f"Web UI: {conversation_url(base_url, prepared.session_id)}", err=True) open_conversation_link_if_enabled( base_url=base_url, conversation_id=prepared.session_id, enabled=auto_open_conversation, warn=lambda message: click.echo(message, err=True), ) await _attach_with_forwarder( base_url=base_url, headers={}, prepared=prepared, prompt=prompt, ) if resolved_session_id is None: echo_native_resume_hint( native_command="codex", session_id=prepared.session_id, ) asyncio.run(_drive()) finally: _stop_local_server(server_handle) def _run_with_remote_server( base_url: str, spec_path: Path, *, session_id: str | None, resume_picker: bool, codex_args: tuple[str, ...], model: str | None, prompt: str | None, auto_open_conversation: bool = False, ) -> None: """ Launch Codex on an Omnigent server via a daemon-spawned runner. :param base_url: Remote Omnigent server base URL, e.g. ``"https://example.databricks.com"``. :param spec_path: Generated Codex wrapper agent spec. :param session_id: Optional existing Omnigent session id. :param resume_picker: When ``True``, run the Codex-native picker. :param codex_args: Raw Codex CLI args. :param model: Optional Codex model id. :param prompt: Optional first prompt. :param auto_open_conversation: When ``True``, open the browser conversation URL after the session is prepared. :returns: None. """ from omnigent.chat import _bundle_agent, _remote_headers, _server_auth from omnigent.cli import _ensure_host_daemon from omnigent.host.identity import load_or_create_host_identity headers = _remote_headers(server_url=base_url) attach_auth = _server_auth(server_url=base_url) try: resolved_session_id = _resolve_session_id_for_resume( base_url=base_url, headers=headers, session_id=session_id, resume_picker=resume_picker, ) if resolved_session_id is None and resume_picker and session_id is None: return if resolved_session_id is not None: _align_working_directory_with_session(resolved_session_id) async def _drive() -> None: """ Prepare Codex and attach in a single event loop. :returns: None. """ with runner_startup_progress(initial_message="Preparing Codex...") as progress: _update_startup_progress(progress, "Connecting to local daemon...") _ensure_host_daemon(base_url) host_id = load_or_create_host_identity().host_id bundle = None if resolved_session_id is not None else _bundle_agent(spec_path) prepared = await _prepare_codex_terminal_via_daemon( base_url=base_url, headers=headers, session_id=resolved_session_id, session_bundle=bundle, codex_args=codex_args, model=model, host_id=host_id, workspace=str(Path.cwd().resolve()), startup_progress=progress, ) if resolved_session_id is None: _record_launch_for_fresh_session(prepared.session_id) click.echo(f"Web UI: {conversation_url(base_url, prepared.session_id)}", err=True) open_conversation_link_if_enabled( base_url=base_url, conversation_id=prepared.session_id, enabled=auto_open_conversation, warn=lambda message: click.echo(message, err=True), ) async def _recover() -> None: """ Refresh auth headers before a terminal reattach attempt. :returns: None. """ new_headers = _remote_headers(server_url=base_url) headers.clear() headers.update(new_headers) if prompt: await _post_initial_prompt( base_url=base_url, headers=headers, session_id=prepared.session_id, prompt=prompt, auth=attach_auth, ) await _attach_terminal_resource( base_url=base_url, headers=headers, prepared=prepared, recover=_recover, ) if resolved_session_id is None: echo_native_resume_hint( native_command="codex", session_id=prepared.session_id, server=base_url, ) asyncio.run(_drive()) except httpx.ConnectError as exc: raise click.ClickException( f"Could not reach the omnigent server at {base_url}. " "Confirm the server is running and reachable from here " f"(e.g. `curl {base_url}/health`), and that --server is correct." ) from exc async def _prepare_codex_terminal_via_daemon( *, base_url: str, headers: dict[str, str], session_id: str | None, session_bundle: bytes | None, codex_args: tuple[str, ...], model: str | None, host_id: str, workspace: str, startup_progress: RunnerStartupProgress | None = None, ) -> PreparedCodexTerminal: """ Create or resume a Codex-native session through a daemon runner. The runner owns the Codex app-server, transcript forwarder, and tmux terminal. The CLI only persists launch intent, waits for the terminal resource, and attaches to it. :param base_url: Omnigent server base URL, e.g. ``"https://example.databricks.com"``. :param headers: HTTP auth headers for Omnigent requests. :param session_id: Existing session id to resume, or ``None`` for a fresh session. :param session_bundle: Gzipped Codex wrapper bundle. Required when *session_id* is ``None``. :param codex_args: User pass-through Codex args, e.g. ``("--config", "approval_policy=on-request")``. :param model: Optional model override for this launch, e.g. ``"gpt-5.4-mini"``. :param host_id: Local host daemon id, e.g. ``"host_abc123"``. :param workspace: Absolute workspace path for the runner cwd, e.g. ``"/Users/me/repo"``. :param startup_progress: Optional user-visible progress renderer, e.g. a handle from :func:`runner_startup_progress`. :returns: Prepared terminal details for attaching. :raises click.ClickException: If setup fails. """ persist_args = list(codex_args) timeout = httpx.Timeout(30.0, read=120.0) async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout) as client: reattached = session_id is not None if session_id is None: if session_bundle is None: raise click.ClickException("Creating a Codex session requires a session bundle.") _update_startup_progress(startup_progress, "Creating Codex session...") session_id = await _create_codex_session( client, session_bundle, bridge_id=None, terminal_launch_args=persist_args or None, ) else: _update_startup_progress(startup_progress, "Loading Codex session...") payload = await _fetch_codex_session(client, session_id) labels = payload.get("labels") if isinstance(payload, dict) else None if ( not isinstance(labels, dict) or labels.get(_WRAPPER_LABEL_KEY) != _WRAPPER_LABEL_VALUE ): raise click.ClickException( f"Conversation {session_id!r} is not a codex-native session." ) existing_terminal = await _find_running_codex_terminal(client, session_id) if existing_terminal is not None: external_session_id = payload.get("external_session_id") thread_id = external_session_id if isinstance(external_session_id, str) else None if persist_args or model is not None: click.echo( "Ignoring Codex launch args/model for an already-running " "terminal; restart the session terminal to apply them.", err=True, ) _update_startup_progress(startup_progress, "Codex terminal ready.") return PreparedCodexTerminal( session_id=session_id, terminal_id=existing_terminal.terminal_id, tmux_socket=existing_terminal.tmux_socket, tmux_target=existing_terminal.tmux_target, bridge_dir=bridge_dir_for_bridge_id(session_id), thread_id=thread_id, app_server_url=None, app_server=None, event_client=None, reattached=True, ) patch: dict[str, Any] = {} if persist_args: patch["terminal_launch_args"] = persist_args if model is not None: patch["model_override"] = model if patch: _update_startup_progress(startup_progress, "Updating Codex session...") resp = await client.patch( f"/v1/sessions/{url_component(session_id)}", json=patch, ) if resp.status_code >= 400: raise click.ClickException( f"Codex session launch config update failed " f"({resp.status_code}): {error_text(resp)}" ) await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S) _update_startup_progress(startup_progress, "Starting runner...") runner_id = await launch_or_reuse_daemon_runner( client, host_id=host_id, session_id=session_id, workspace=workspace, ) _update_startup_progress(startup_progress, "Waiting for runner...") await wait_for_runner_online(client, runner_id, timeout_s=_DAEMON_RUNNER_ONLINE_TIMEOUT_S) # Must run AFTER wait_for_runner_online — unregistered runners # 400 on replace_runner_id. The daemon bind paths don't route # through replace_runner_id, so without this re-bind a stopped # session stays stopped. await _bind_session_runner(client, session_id, runner_id) _update_startup_progress(startup_progress, "Starting Codex terminal...") await _ensure_codex_terminal_on_runner(client, session_id) terminal = await _wait_for_codex_terminal_ready( client, session_id, timeout_s=_DAEMON_TERMINAL_READY_TIMEOUT_S, ) _update_startup_progress(startup_progress, "Codex terminal ready.") return PreparedCodexTerminal( session_id=session_id, terminal_id=terminal.terminal_id, tmux_socket=terminal.tmux_socket, tmux_target=terminal.tmux_target, bridge_dir=bridge_dir_for_bridge_id(session_id), thread_id=None, app_server_url=None, app_server=None, event_client=None, reattached=reattached, ) async def _ensure_codex_terminal_on_runner( client: httpx.AsyncClient, session_id: str, ) -> None: """ Ask the bound runner to ensure the Codex app-server and terminal exist. :param client: HTTP client pointed at the Omnigent server. :param session_id: Session id, e.g. ``"conv_abc123"``. :returns: None. :raises click.ClickException: If the runner rejects the ensure request. """ resp = await client.post( f"/v1/sessions/{url_component(session_id)}/resources/terminals", json={"terminal": "codex", "session_key": "main", "ensure_native_terminal": True}, timeout=60.0, ) if resp.status_code >= 400: raise click.ClickException( f"Codex terminal ensure failed ({resp.status_code}): {error_text(resp)}" ) async def _wait_for_codex_terminal_ready( client: httpx.AsyncClient, session_id: str, *, timeout_s: float, ) -> LaunchedCodexTerminal: """ Wait until the runner exposes the Codex terminal resource. :param client: HTTP client pointed at the Omnigent server. :param session_id: Session id, e.g. ``"conv_abc123"``. :param timeout_s: Max seconds to wait, e.g. ``60.0``. :returns: Terminal details including direct tmux attach metadata when available. :raises click.ClickException: If no terminal appears in time. """ loop = asyncio.get_running_loop() deadline = loop.time() + timeout_s while loop.time() < deadline: terminal = await _find_running_codex_terminal(client, session_id) if terminal is not None: return terminal await asyncio.sleep(0.2) raise click.ClickException( f"The runner did not create the Codex terminal for {session_id!r} within {timeout_s:.0f}s." ) async def _post_initial_prompt( *, base_url: str, headers: dict[str, str], session_id: str, prompt: str, auth: httpx.Auth | None, ) -> None: """ Send the first Codex prompt through Omnigent instead of the app-server. :param base_url: Omnigent server base URL. :param headers: HTTP auth headers for Omnigent requests. :param session_id: Session id, e.g. ``"conv_abc123"``. :param prompt: User prompt text. :param auth: Optional refresh-capable HTTP auth for long-lived Databricks-backed sessions. :returns: None. :raises click.ClickException: If Omnigent rejects the prompt. """ async with httpx.AsyncClient( base_url=base_url, headers=headers, auth=auth, timeout=httpx.Timeout(30.0), ) as client: resp = await client.post( f"/v1/sessions/{url_component(session_id)}/events", json={ "type": "message", "data": { "role": "user", "content": [{"type": "input_text", "text": prompt}], }, }, ) if resp.status_code >= 400: raise click.ClickException( f"Codex initial prompt failed ({resp.status_code}): {error_text(resp)}" ) async def _prepare_codex_terminal( *, base_url: str, headers: dict[str, str], session_id: str | None, runner_id: str | None, session_bundle: bytes | None, codex_args: tuple[str, ...], command: str, model: str | None, startup_progress: RunnerStartupProgress | None = None, ) -> PreparedCodexTerminal: """ Create/bind a session, start app-server, and launch Codex TUI. :param base_url: Omnigent server base URL. :param headers: HTTP auth headers. :param session_id: Optional existing session id. :param runner_id: Runner id to bind. :param session_bundle: Gzipped agent bundle for new sessions. :param codex_args: Raw Codex CLI args. :param command: Codex executable. :param model: Optional model id. :param startup_progress: Optional user-visible progress renderer, e.g. a handle from :func:`runner_startup_progress`. :returns: Prepared terminal details. """ timeout = httpx.Timeout(30.0, read=120.0) async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout) as client: bridge_id: str thread_id: str | None = None if session_id is None: if session_bundle is None: raise click.ClickException("Creating a Codex session requires a session bundle.") _update_startup_progress(startup_progress, "Creating Codex session...") bridge_id = secrets.token_urlsafe(24) session_id = await _create_codex_session(client, session_bundle, bridge_id=bridge_id) else: _update_startup_progress(startup_progress, "Loading Codex session...") payload = await _fetch_codex_session(client, session_id) labels = payload.get("labels") if isinstance(payload, dict) else None if ( not isinstance(labels, dict) or labels.get(_WRAPPER_LABEL_KEY) != _WRAPPER_LABEL_VALUE ): raise click.ClickException( f"Conversation {session_id!r} is not a codex-native session." ) bridge_id = str(labels.get(CODEX_NATIVE_BRIDGE_ID_LABEL_KEY) or session_id) existing_terminal = await _find_running_codex_terminal(client, session_id) external_session_id = payload.get("external_session_id") thread_id = external_session_id if isinstance(external_session_id, str) else None if existing_terminal is not None and thread_id is not None: reattach_bridge_dir = bridge_dir_for_bridge_id(bridge_id) reattach_unix_socket = socket_path_for_bridge_dir(reattach_bridge_dir) # The running terminal's real transport lives in its bridge # state (``ws://`` for terminals launched by current code). # Reattach starts no app-server/forwarder/initial-turn, so # app_server_url is unused here, but populate it accurately # from bridge state and fall back to the legacy unix path. reattach_state = read_bridge_state(reattach_bridge_dir) reattach_transport = ( reattach_state.socket_path if reattach_state is not None else str(reattach_unix_socket) ) _update_startup_progress(startup_progress, "Codex terminal ready.") return PreparedCodexTerminal( session_id=session_id, terminal_id=existing_terminal.terminal_id, tmux_socket=existing_terminal.tmux_socket, tmux_target=existing_terminal.tmux_target, bridge_dir=reattach_bridge_dir, thread_id=thread_id, app_server_url=reattach_transport, app_server=None, event_client=None, reattached=True, ) if thread_id is None: raise click.ClickException( f"Conversation {session_id!r} is missing its Codex thread id." ) bridge_dir = prepare_bridge_dir(bridge_id) socket_path = socket_path_for_bridge_dir(bridge_dir) codex_home = codex_home_for_bridge_dir(bridge_dir) clear_bridge_state(bridge_dir) # Route across all offerings: a configured provider (configure # harness), the Databricks ucode profile, or Codex's own login — # so `omnigent codex` honors the provider selection like the # in-process codex harness. Resolved before any rollout synthesis # so session_meta can name the provider the launch routes through. _codex_launch = resolve_native_codex_launch(model=model) if thread_id is not None: await _ensure_local_codex_resume_rollout( client, session_id=session_id, external_session_id=thread_id, codex_home=codex_home, workspace=Path.cwd().resolve(), model_provider=codex_session_meta_model_provider(_codex_launch), codex_path=command, ) # Listen on a loopback WebSocket, mirroring the host-spawned # runner (``runner/app.py`` ``_auto_create_codex_terminal``). # Codex CLI ``app-server`` only accepts ``stdio://``, ``ws://``, # or ``off`` — it dropped ``unix://`` — so a ``unix://`` listen # exits immediately and the terminal (and the web-UI Terminal # pill) never appears. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _probe: _probe.bind(("127.0.0.1", 0)) codex_ws_url = f"ws://127.0.0.1:{_probe.getsockname()[1]}" app_server = build_codex_native_server( socket_path=socket_path, codex_home=codex_home, cwd=Path.cwd(), model=_codex_launch.model, profile=_codex_launch.profile, codex_path=command, extra_config_overrides=_codex_launch.config_overrides, bridge_dir=bridge_dir, ap_server_url=base_url, ap_auth_headers=headers, ) app_server.listen_url = codex_ws_url event_client: CodexAppServerClient | None = None terminal_id: str | None = None launched_terminal: LaunchedCodexTerminal | None = None try: await app_server.start() if thread_id is None: event_client = client_for_transport( codex_ws_url, client_name="omnigent-codex-native", ) await event_client.connect() else: await preload_codex_thread_for_resume(codex_ws_url, thread_id) write_bridge_state( bridge_dir, CodexNativeBridgeState( session_id=session_id, socket_path=codex_ws_url, thread_id=thread_id, codex_home=str(codex_home), ), ) if runner_id is not None: await _bind_session_runner(client, session_id, runner_id) _update_startup_progress(startup_progress, "Starting Codex terminal...") launched_terminal = await _launch_codex_terminal( client, session_id, codex_args=codex_args, command=command, thread_id=thread_id, remote_url=codex_ws_url, env=codex_terminal_env(app_server), # Give the --remote TUI the same provider overrides as # the app-server so it resolves the Omnigent provider # and skips the OpenAI-login onboarding screen. config_overrides=tuple(app_server.config_overrides), ) terminal_id = launched_terminal.terminal_id _update_startup_progress(startup_progress, "Codex terminal ready.") except Exception: if terminal_id is not None: await _close_codex_terminal( base_url=base_url, headers=headers, session_id=session_id, terminal_id=terminal_id, ) if event_client is not None: await event_client.close() await app_server.close() raise if launched_terminal is None: raise click.ClickException("Codex terminal was not launched.") return PreparedCodexTerminal( session_id=session_id, terminal_id=launched_terminal.terminal_id, tmux_socket=launched_terminal.tmux_socket, tmux_target=launched_terminal.tmux_target, bridge_dir=bridge_dir, thread_id=thread_id, app_server_url=codex_ws_url, app_server=app_server, event_client=event_client, reattached=False, ) async def _attach_with_forwarder( *, base_url: str, headers: dict[str, str], prepared: PreparedCodexTerminal, prompt: str | None, recover: Any | None = None, auth: httpx.Auth | None = None, ) -> None: """ Attach to the Codex terminal while forwarding app-server events. :param base_url: Omnigent server base URL. :param headers: HTTP auth headers. :param prepared: Prepared terminal details. :param prompt: Optional first prompt to send. :param recover: Optional reconnect recovery callback. :param auth: Optional long-lived HTTP auth for remote sessions. :returns: None. """ forwarder: asyncio.Task[None] | None = None try: if prepared.thread_id is None: attach_task = asyncio.create_task( _attach_terminal_resource( base_url=base_url, headers=headers, prepared=prepared, recover=recover, ), name="codex-native-terminal-attach", ) await asyncio.sleep(0) try: prepared.thread_id = await _initialize_fresh_terminal_thread( base_url=base_url, headers=headers, prepared=prepared, ) if prepared.app_server is not None: forwarder = _start_codex_forwarder( base_url=base_url, headers=headers, prepared=prepared, auth=auth, ) if prompt: await _start_initial_turn( prepared.app_server_url, prepared.thread_id, prompt, ) await attach_task except Exception: if not attach_task.done(): attach_task.cancel() with contextlib.suppress(asyncio.CancelledError): await attach_task raise else: if prepared.app_server is not None: forwarder = _start_codex_forwarder( base_url=base_url, headers=headers, prepared=prepared, auth=auth, ) if prompt: await _start_initial_turn(prepared.app_server_url, prepared.thread_id, prompt) await _attach_terminal_resource( base_url=base_url, headers=headers, prepared=prepared, recover=recover, ) finally: if forwarder is not None: forwarder.cancel() with contextlib.suppress(asyncio.CancelledError): await forwarder if not prepared.reattached: active_session_id = ( _active_codex_session_id(prepared.bridge_dir) or prepared.session_id ) await _close_codex_terminal( base_url=base_url, headers=headers, session_id=active_session_id, terminal_id=prepared.terminal_id, ) if prepared.app_server is not None: await prepared.app_server.close() def _start_codex_forwarder( *, base_url: str, headers: dict[str, str], prepared: PreparedCodexTerminal, auth: httpx.Auth | None, ) -> asyncio.Task[None]: """ Start the transcript forwarder for a prepared Codex terminal. :param base_url: Omnigent server base URL. :param headers: HTTP auth headers. :param prepared: Prepared terminal details with a known thread id. :param auth: Optional long-lived HTTP auth for remote sessions. :returns: Running forwarder task. :raises click.ClickException: If the Codex thread id is not known. """ if prepared.thread_id is None: raise click.ClickException("Codex thread id was not initialized.") return asyncio.create_task( supervise_forwarder( base_url=base_url, headers=headers, session_id=prepared.session_id, bridge_dir=prepared.bridge_dir, app_server_url=prepared.app_server_url, thread_id=prepared.thread_id, client=prepared.event_client, auth=auth, ), name="codex-native-forwarder", ) async def _initialize_fresh_terminal_thread( *, base_url: str, headers: dict[str, str], prepared: PreparedCodexTerminal, ) -> str: """ Wait for an attached fresh Codex TUI to create its app-server thread. Codex terminals can be launched in a tmux pane that waits for the first client attach before starting Codex. This preserves web terminal sharing while letting Codex query the real attached terminal during startup. :param base_url: Omnigent server base URL. :param headers: HTTP auth headers. :param prepared: Prepared terminal details whose ``thread_id`` is still ``None``. :returns: The Codex app-server thread id, e.g. ``"thread_abc123"``. :raises click.ClickException: If no thread-start listener exists. """ if prepared.event_client is None: raise click.ClickException("Codex event listener was not initialized.") thread_id = await _wait_for_thread_started(prepared.event_client) async with httpx.AsyncClient( base_url=base_url, headers=headers, timeout=httpx.Timeout(30.0), ) as client: await _patch_external_session_id(client, prepared.session_id, thread_id) write_bridge_state( prepared.bridge_dir, CodexNativeBridgeState( session_id=prepared.session_id, socket_path=prepared.app_server_url, thread_id=thread_id, codex_home=str(codex_home_for_bridge_dir(prepared.bridge_dir)), ), ) return thread_id async def _attach_terminal_resource( *, base_url: str, headers: dict[str, str], prepared: PreparedCodexTerminal, recover: Any | None, ) -> None: """ Attach the current terminal to the prepared Omnigent terminal resource. :param base_url: Omnigent server base URL. :param headers: HTTP auth headers. :param prepared: Prepared terminal details. :param recover: Optional reconnect recovery callback. :returns: None after the attach exits. """ direct_tmux_error = _direct_tmux_unavailable_reason(prepared) if direct_tmux_error is None: if prepared.tmux_socket is None or prepared.tmux_target is None: raise click.ClickException("Codex tmux attach metadata was incomplete.") await _attach_direct_tmux(prepared.tmux_socket, prepared.tmux_target) return if prepared.app_server_url is None: raise click.ClickException( f"Runner-owned Codex terminal requires direct tmux attach, but {direct_tmux_error}" ) await _attach_with_reconnect( attach=attach_local_terminal, attach_url=_attach_url(base_url, prepared.session_id, prepared.terminal_id), headers=headers, recover=recover, base_url=base_url, session_id=prepared.session_id, terminal_id=prepared.terminal_id, active_session_id_reader=lambda: _active_codex_session_id(prepared.bridge_dir), ) def _active_codex_session_id(bridge_dir: Path) -> str | None: """ Return the active Omnigent session id for a native Codex bridge. :param bridge_dir: Native Codex bridge directory. :returns: Omnigent session id, e.g. ``"conv_abc123"``, or ``None`` when bridge state has not been written yet. """ state = read_bridge_state(bridge_dir) return state.session_id if state is not None else None def _can_attach_direct_tmux(prepared: PreparedCodexTerminal) -> bool: """ Return whether this process can attach to the runner tmux directly. :param prepared: Prepared terminal details. :returns: ``True`` when the runner exposed a local tmux socket, the socket exists on this host, and ``tmux`` is available on PATH. """ return _direct_tmux_unavailable_reason(prepared) is None def _direct_tmux_unavailable_reason(prepared: PreparedCodexTerminal) -> str | None: """ Explain why this process cannot attach to the runner tmux directly. :param prepared: Prepared terminal details. :returns: ``None`` when direct tmux attach is available, otherwise a human-readable reason for the missing prerequisite. """ if prepared.tmux_socket is None: return "the terminal resource did not include a tmux socket path." if prepared.tmux_target is None: return "the terminal resource did not include a tmux target." if not prepared.tmux_socket.exists(): return f"tmux socket {prepared.tmux_socket} is not reachable from this CLI process." if shutil.which("tmux") is None: return "tmux is not available on PATH." return None async def _attach_direct_tmux(socket_path: Path, tmux_target: str) -> None: """ Attach the current terminal directly to the runner-owned tmux pane. This avoids the local WebSocket + PTY relay used for browser and non-local runner attaches. ``TMUX`` is removed from the child environment so users who run ``omnigent codex`` inside their own tmux session can still attach to Omnigent' private tmux server. :param socket_path: Runner tmux socket path. :param tmux_target: Tmux target to attach, e.g. ``"main"``. :returns: None after the attach process exits. """ env = dict(os.environ) env.pop("TMUX", None) process = await asyncio.create_subprocess_exec( "tmux", "-S", str(socket_path), "-f", os.devnull, "attach", "-t", tmux_target, env=env, ) await process.wait() async def _create_codex_session( client: httpx.AsyncClient, bundle: bytes, *, bridge_id: str | None, terminal_launch_args: list[str] | None = None, ) -> str: """ Create a bundled terminal-first Codex session. :param client: HTTP client pointed at AP. :param bundle: Gzipped agent bundle. :param bridge_id: Opaque bridge id, e.g. ``"bridge_abc123"``. ``None`` omits the label so the runner-owned bridge keys by session id. :param terminal_launch_args: Pass-through Codex CLI args to persist for runner-owned terminal launch, e.g. ``["--config", "approval_policy=on-request"]``. :returns: New Omnigent session id. """ labels = dict(_SESSION_LABELS) if bridge_id is not None: labels[CODEX_NATIVE_BRIDGE_ID_LABEL_KEY] = bridge_id metadata = { "labels": labels, } if terminal_launch_args: metadata["terminal_launch_args"] = terminal_launch_args resp = await client.post( "/v1/sessions", data={"metadata": json.dumps(metadata)}, files={"bundle": ("codex-native-ui.tar.gz", bundle, "application/gzip")}, timeout=120.0, ) if resp.status_code >= 400: raise click.ClickException( f"Codex session creation failed ({resp.status_code}): {error_text(resp)}" ) body = resp.json() new_session_id = body.get("session_id") if not isinstance(new_session_id, str) or not new_session_id: raise click.ClickException("Codex session creation response did not include session_id.") return new_session_id async def _fetch_codex_session(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]: """ Fetch an existing Omnigent session. :param client: HTTP client pointed at AP. :param session_id: Omnigent session id, e.g. ``"conv_abc123"``. :returns: Decoded session payload. """ resp = await client.get(f"/v1/sessions/{url_component(session_id)}") if resp.status_code == 404: raise click.ClickException(f"Conversation {session_id!r} not found on the server.") if resp.status_code >= 400: raise click.ClickException( f"Failed to fetch conversation {session_id!r} ({resp.status_code}): {error_text(resp)}" ) payload = resp.json() if not isinstance(payload, dict): raise click.ClickException("Conversation fetch returned non-object JSON.") return payload def _mint_codex_thread_id() -> str: """ Mint a fresh UUIDv7 thread id for a forked Codex clone. Codex thread ids are UUIDv7 (time-ordered), e.g. ``"019e96aa-0be2-7343-8d3b-6f914d60936b"``. A fork writes the cloned rollout under a freshly minted id (rather than reusing the source's) so the clone gets its own Omnigent ``external_session_id`` — mirroring how claude-native assigns the clone a new transcript uuid. The stdlib has no UUIDv7 generator before Python 3.14, so we assemble one per RFC 9562 §5.7 (48-bit millisecond timestamp + version + variant + random) rather than add a dependency. :returns: A UUIDv7 string, e.g. ``"019e96aa-0be2-7343-8d3b-6f914d60936b"``. """ unix_ms = int(time.time() * 1000) value = bytearray(unix_ms.to_bytes(6, "big") + secrets.token_bytes(10)) value[6] = (value[6] & 0x0F) | 0x70 # version 7 in the high nibble value[8] = (value[8] & 0x3F) | 0x80 # RFC 4122 variant (0b10) return str(uuid.UUID(bytes=bytes(value))) def _find_codex_rollout(codex_home: Path, thread_id: str) -> Path | None: """ Find a Codex rollout file by thread id within a ``CODEX_HOME``. Codex persists each thread's history as a single append-only JSONL rollout at ``$CODEX_HOME/sessions/YYYY/MM/DD/rollout--.jsonl``, where the trailing ```` matches the thread's ``session_meta.id``. We locate it by that filename suffix. :param codex_home: A per-session private ``CODEX_HOME``, e.g. ``Path("~/.omnigent/codex-native//codex-home")``. :param thread_id: Codex thread id / rollout stem, e.g. ``"019e96aa-0be2-7343-8d3b-6f914d60936b"``. :returns: Path to the most recent matching rollout, or ``None`` when none exists on this host. """ if not _CODEX_THREAD_ID_RE.fullmatch(thread_id): return None sessions = codex_home / "sessions" if not sessions.is_dir(): return None matches = [p for p in sessions.glob(f"**/rollout-*-{thread_id}.jsonl") if p.is_file()] if not matches: return None matches.sort(key=lambda path: path.stat().st_mtime, reverse=True) return matches[0] def _copy_rollout_with_cwd( *, source: Path, target: Path, clone_workspace: Path, new_thread_id: str ) -> None: """ Copy a Codex rollout JSONL, rewriting only the structural id/cwd. A rollout interleaves *structural* fields (the live thread settings Codex reads on resume) with *historical* content (recorded shell commands, file paths, messages — facts about what already happened). Only two structural fields carry the working directory — ``session_meta.payload.cwd`` and each ``turn_context.payload.cwd`` — plus the thread id at ``session_meta.payload.id``. Those are rewritten to the clone's id / workspace; every other line (and every other ``cwd`` mention, which lives inside message/tool bodies) is copied verbatim, so the clone's history stays truthful about the source run. :param source: Existing source rollout JSONL. :param target: Temporary output path (atomically renamed by the caller). :param clone_workspace: The resolved directory the clone runs in, written into the structural ``cwd`` fields, e.g. ``Path("/home/me/repo-worktrees/fork")``. :param new_thread_id: The clone's thread id, written into ``session_meta.payload.id``, e.g. ``"019e96aa-0be2-7343-8d3b-6f914d60936b"``. :returns: None. :raises click.ClickException: If a rollout line is not valid JSON. """ workspace_text = str(clone_workspace) with source.open("r", encoding="utf-8") as src, target.open("w", encoding="utf-8") as dst: for line_number, line in enumerate(src, start=1): if not line.strip(): dst.write(line) continue try: record = json.loads(line) except json.JSONDecodeError as exc: raise click.ClickException( f"Cannot clone malformed Codex rollout {source}: " f"line {line_number} is not valid JSON." ) from exc record_type = record.get("type") if isinstance(record, dict) else None if record_type not in ("session_meta", "turn_context"): # Historical record — write the original bytes back unchanged # so message/tool bodies (and their incidental cwd mentions) # are preserved exactly, including whitespace and key order. dst.write(line) continue payload = record.get("payload") if isinstance(payload, dict): if record_type == "session_meta": if isinstance(payload.get("id"), str): payload["id"] = new_thread_id if isinstance(payload.get("cwd"), str): payload["cwd"] = workspace_text elif isinstance(payload.get("cwd"), str): # turn_context payload["cwd"] = workspace_text dst.write(json.dumps(record, separators=(",", ":")) + "\n") def _clone_codex_rollout( *, source_session_id: str, source_thread_id: str, target_thread_id: str, clone_codex_home: Path, clone_workspace: Path, ) -> Path | None: """ Clone a source Codex rollout into the clone's own ``CODEX_HOME``. Used to carry a forked codex-native session's history into the clone. Codex's resume reads the rollout from the app-server's ``CODEX_HOME``, which is per-session-private (keyed by the conversation id), so the source rollout must be copied into the *clone's* ``CODEX_HOME`` under a thread id we assign. We rewrite ``session_meta.payload.id`` → *target_thread_id* and the two structural ``cwd`` fields → *clone_workspace* (see :func:`_copy_rollout_with_cwd`), preserving the record order and all historical content. The clone then launches ``codex resume ``. Writing the file ourselves before launch (rather than pointing resume at the source's home) is what makes the worktree case work and keeps the clone's history isolated from the source. This is the codex-native mirror of :func:`omnigent.claude_native._clone_claude_transcript`. See designs/FORK_SESSION_UX.md. :param source_session_id: The SOURCE conversation id, used to locate the source's ``CODEX_HOME``, e.g. ``"conv_abc123"``. :param source_thread_id: The SOURCE Codex thread id / rollout stem to copy from, e.g. ``"019e96aa-0be2-7343-8d3b-6f914d60936b"``. :param target_thread_id: The thread id to assign the clone's copied rollout, e.g. ``"019eaa11-...."``. Must be a safe rollout stem; the clone's ``external_session_id`` is set to this so a later relaunch resumes it via the normal path. :param clone_codex_home: The clone's per-session private ``CODEX_HOME``, e.g. ``Path("~/.omnigent/codex-native//codex-home")``. :param clone_workspace: The resolved directory the clone will run in (its worktree or same dir). Written into the structural ``cwd`` fields. Pass an already-resolved path. :returns: Path to the written clone rollout, or ``None`` when the ids are unsafe or the source rollout can't be found on this host (caller launches fresh in that case). :raises click.ClickException: If the source rollout is malformed. """ if not _CODEX_THREAD_ID_RE.fullmatch(source_thread_id): return None if not _CODEX_THREAD_ID_RE.fullmatch(target_thread_id): return None source_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id(source_session_id)) source = _find_codex_rollout(source_home, source_thread_id) if source is None: return None # Preserve the source's ``sessions///
/`` layout; only swap # the thread id embedded in the rollout filename so the clone lands in # its own CODEX_HOME under the assigned id. rel_dir = source.parent.relative_to(source_home) target_dir = clone_codex_home / rel_dir target = target_dir / source.name.replace(source_thread_id, target_thread_id) target_dir.mkdir(mode=0o700, parents=True, exist_ok=True) tmp = target.with_suffix(".jsonl.tmp") try: _copy_rollout_with_cwd( source=source, target=tmp, clone_workspace=clone_workspace, new_thread_id=target_thread_id, ) os.replace(tmp, target) finally: with contextlib.suppress(FileNotFoundError): tmp.unlink() return target async def _ensure_local_codex_resume_rollout( client: httpx.AsyncClient, *, session_id: str, external_session_id: str, codex_home: Path, workspace: Path, model_provider: str, codex_path: str | None, ) -> Path: """ Ensure Codex has a local rollout JSONL for cold resume. Cross-machine resume has the Omnigent conversation and Codex thread id on the server, but not necessarily the app-server's local ``$CODEX_HOME/sessions/.../rollout-*-.jsonl`` file. Codex ``resume `` reads that local rollout, so before launching a known-thread terminal we synthesize the rollout from committed AP items when the local rollout is missing. Existing local rollout files are left untouched because Codex treats them as append-only runtime state, not a cache that Omnigent should rewrite. :param client: HTTP client pointed at the Omnigent server. :param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``. :param external_session_id: Codex thread id, e.g. ``"019e96aa-0be2-7343-8d3b-6f914d60936b"``. :param codex_home: Per-session private ``CODEX_HOME`` whose ``sessions`` directory Codex app-server reads. :param workspace: Resolved directory Codex will run in, e.g. ``Path("/home/me/repo")``. Pass an already-resolved path so structural rollout cwd fields match the terminal cwd. :param model_provider: Provider id this session's launch routes through, e.g. ``"omnigent_databricks"`` (see :func:`omnigent.codex_native_app_server.codex_session_meta_model_provider`). Written into ``session_meta`` so codex's thread-store backfill can resolve the provider when it indexes the rollout. :param codex_path: Codex CLI executable used to stamp the real ``cli_version`` into ``session_meta``, e.g. ``"/usr/local/bin/codex"``. ``None`` (or an unparseable version probe) falls back to ``"0.0.0"`` — codex >= 0.133 requires the field to be *present* to parse the rollout, but treats the value as informational, so a flaky probe must not cost the carried history. :returns: Path to the existing or written rollout. :raises click.ClickException: If Omnigent history cannot be fetched or the rollout cannot be written, or if the persisted Codex thread id is unsafe for use in a rollout filename. """ if not _CODEX_THREAD_ID_RE.fullmatch(external_session_id): raise click.ClickException( f"Cannot resume Codex session {session_id!r}: persisted thread id " f"{external_session_id!r} is not a safe Codex rollout id." ) existing = _find_codex_rollout(codex_home, external_session_id) if existing is not None: return existing target = _codex_resume_rollout_path(codex_home, external_session_id) items = await _fetch_all_session_items_for_codex_resume(client, session_id) cli_version = None if codex_path is not None: from omnigent.inner.codex_executor import _codex_cli_version version_tuple = await _codex_cli_version(codex_path) if version_tuple is not None: cli_version = ".".join(str(part) for part in version_tuple) records = _codex_rollout_records_from_session_items( items, session_id=session_id, external_session_id=external_session_id, cwd=workspace, model_provider=model_provider, cli_version=cli_version or "0.0.0", ) target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) tmp = target.with_suffix(".jsonl.tmp") try: with tmp.open("w", encoding="utf-8") as handle: for record in records: handle.write(json.dumps(record, separators=(",", ":")) + "\n") os.replace(tmp, target) except OSError as exc: raise click.ClickException( f"Failed to write Codex resume rollout {target}: {exc}" ) from exc finally: with contextlib.suppress(FileNotFoundError): tmp.unlink() return target def _codex_resume_rollout_path(codex_home: Path, external_session_id: str) -> Path: """ Return the rollout path to write for a Codex cold resume. Reuses the most recent existing rollout for the thread when present, otherwise creates a date-partitioned path matching Codex's on-disk layout. :param codex_home: Per-session private ``CODEX_HOME``, e.g. ``Path("~/.omnigent/codex-native/x/codex-home")``. :param external_session_id: Codex thread id / rollout stem, e.g. ``"019e96aa-0be2-7343-8d3b-6f914d60936b"``. :returns: Rollout JSONL path to overwrite or create. """ existing = _find_codex_rollout(codex_home, external_session_id) if existing is not None: return existing now = datetime.now(timezone.utc) partition = ( codex_home / "sessions" / now.strftime("%Y") / now.strftime("%m") / now.strftime("%d") ) stamp = now.strftime("%Y-%m-%dT%H-%M-%S") return partition / f"rollout-{stamp}-{external_session_id}.jsonl" async def _fetch_all_session_items_for_codex_resume( client: httpx.AsyncClient, session_id: str, ) -> list[dict[str, Any]]: """ Fetch committed Omnigent session items in chronological order. :param client: HTTP client pointed at the Omnigent server. :param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``. :returns: Flat API item dicts from ``GET /v1/sessions/{id}/items``. :raises click.ClickException: If an item page cannot be fetched or parsed. """ items: list[dict[str, Any]] = [] after: str | None = None while True: params: dict[str, str | int] = {"limit": 1000, "order": "asc"} if after is not None: params["after"] = after resp = await client.get( f"/v1/sessions/{url_component(session_id)}/items", params=params, ) if resp.status_code >= 400: raise click.ClickException( f"Failed to fetch history for {session_id!r} " f"({resp.status_code}): {error_text(resp)}" ) try: payload = resp.json() except ValueError as exc: raise click.ClickException( f"History fetch for {session_id!r} returned non-JSON body: {exc}" ) from exc data = payload.get("data") if isinstance(payload, dict) else None if not isinstance(data, list): raise click.ClickException( f"History fetch for {session_id!r} returned an invalid item list." ) for item in data: if isinstance(item, dict): items.append(item) if not payload.get("has_more"): return items last_id = payload.get("last_id") if not isinstance(last_id, str) or not last_id: raise click.ClickException( f"History fetch for {session_id!r} set has_more without last_id." ) after = last_id def _codex_rollout_records_from_session_items( items: list[dict[str, Any]], *, session_id: str, external_session_id: str, cwd: Path, model_provider: str, cli_version: str, ) -> list[dict[str, Any]]: """ Convert Omnigent session items into Codex rollout JSONL records. The generated records follow Codex's rollout shape: one ``session_meta`` record, a ``turn_context`` before each Omnigent response group, Responses-style ``response_item`` payloads for user, assistant, and tool history, and an ``event_msg`` mirror after each user/assistant message. All three session_meta extras and the event_msg mirrors are load-bearing on codex >= 0.133 (verified against 0.136.0): a ``session_meta`` without ``timestamp`` + ``cli_version`` fails rollout parse ("does not start with session metadata"), an absent ``model_provider`` breaks ``thread/resume`` config load once the thread-store backfill indexes the rollout, and without ``event_msg`` records codex reconstructs zero visible turns — the resume "succeeds" but the thread opens empty. :param items: Flat Omnigent item dicts in chronological order, e.g. ``{"type": "message", "role": "user", "content": [...]}``. :param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``. Used for deterministic synthetic turn ids. :param external_session_id: Codex thread id, e.g. ``"019e96aa-0be2-7343-8d3b-6f914d60936b"``. :param cwd: Working directory to write into structural rollout fields, e.g. ``Path("/home/me/repo")``. :param model_provider: Provider id for ``session_meta.model_provider``, e.g. ``"omnigent_databricks"``. :param cli_version: Codex CLI version string for ``session_meta.cli_version``, e.g. ``"0.136.0"``. :returns: Codex rollout record dictionaries. """ timestamp = _codex_rollout_timestamp() records: list[dict[str, Any]] = [ { "timestamp": timestamp, "type": "session_meta", "payload": { "id": external_session_id, "timestamp": timestamp, "cwd": str(cwd), "originator": "omnigent", "cli_version": cli_version, "model_provider": model_provider, }, } ] seen_turn_ids: set[str] = set() interrupted_response_ids = _interrupted_response_ids_from_session_items(items) for index, item in enumerate(items): if _session_item_response_id(item) in interrupted_response_ids: continue # Compaction items carry the post-compaction context. Emit a # Compacted rollout record and discard all prior records — the # replacement_history replaces them. if item.get("type") == "compaction": compacted_msgs = item.get("compacted_messages") if compacted_msgs: compacted_record: dict[str, Any] = { "timestamp": timestamp, "type": "compacted", "payload": { "message": item.get("summary", ""), "replacement_history": compacted_msgs, }, } w_id = item.get("window_id") if w_id is not None: compacted_record["payload"]["window_id"] = w_id # Replace all prior response_item records — the # replacement_history is the new context baseline. # Keep only session_meta and turn_context records. records = [r for r in records if r.get("type") in ("session_meta",)] records.append(compacted_record) seen_turn_ids.clear() continue payload = _codex_response_item_from_session_item(item) if payload is None: continue turn_id = _codex_turn_id_for_session_item( session_id=session_id, external_session_id=external_session_id, item=item, index=index, ) if turn_id not in seen_turn_ids: records.append( { "timestamp": timestamp, "type": "turn_context", "payload": { "turn_id": turn_id, "cwd": str(cwd), "approval_policy": "on-request", }, } ) seen_turn_ids.add(turn_id) records.append( { "timestamp": timestamp, "type": "response_item", "payload": payload, } ) event_msg = _codex_event_msg_record_for_message(payload, timestamp=timestamp) if event_msg is not None: records.append(event_msg) return records def _codex_event_msg_record_for_message( payload: dict[str, Any], *, timestamp: str, ) -> dict[str, Any] | None: """ Build the ``event_msg`` mirror record for a message ``response_item``. Codex reconstructs a resumed thread's *visible* turns from ``event_msg`` records (``user_message`` / ``agent_message``), not from the ``response_item`` history that feeds the model context. A synthesized rollout without these mirrors resumes "successfully" but renders an empty thread (zero turns) on codex 0.136.0, so the carried history is invisible in the TUI and web UI. :param payload: A ``response_item`` payload already emitted into the rollout, e.g. ``{"type": "message", "role": "user", "content": [...]}``. :param timestamp: Rollout record timestamp, e.g. ``"2026-06-12T08:00:00.000Z"``. :returns: An ``event_msg`` record for user/assistant messages, or ``None`` for tool-call payloads (codex shows those via dedicated event types that are not needed for turn reconstruction). """ if payload.get("type") != "message": return None text = " ".join( block.get("text", "") for block in payload.get("content", []) if isinstance(block, dict) ).strip() if not text: return None role = payload.get("role") if role == "user": event_payload: dict[str, Any] = { "type": "user_message", "message": text, "images": [], "local_images": [], "text_elements": [], } elif role == "assistant": event_payload = { "type": "agent_message", "message": text, "phase": "final_answer", "memory_citation": None, } else: return None return {"timestamp": timestamp, "type": "event_msg", "payload": event_payload} def _interrupted_response_ids_from_session_items(items: list[dict[str, Any]]) -> set[str]: """ Return response ids for Omnigent turns that ended interrupted. A Codex interrupted turn is persisted in Omnigent as visible transcript text plus an ``interrupted`` assistant marker. For native resume, the whole response group must be skipped so Codex does not restore the cancelled user request, partial assistant answer, or any partial tool history. :param items: Flat Omnigent item dicts in chronological order, e.g. ``[{"response_id": "codex_turn_123", "interrupted": True}]``. :returns: Response ids to exclude from synthesized Codex rollout history, e.g. ``{"codex_turn_123"}``. """ response_ids: set[str] = set() for item in items: if not _is_interrupted_assistant_session_item(item): continue response_id = _session_item_response_id(item) if response_id is not None: response_ids.add(response_id) return response_ids def _session_item_response_id(item: dict[str, Any]) -> str | None: """ Extract a non-empty Omnigent response id from a flat item. :param item: Flat Omnigent item dict, e.g. ``{"response_id": "codex_turn_123"}``. :returns: Response id, e.g. ``"codex_turn_123"``, or ``None``. """ response_id = item.get("response_id") return response_id if isinstance(response_id, str) and response_id else None def _codex_response_item_from_session_item(item: dict[str, Any]) -> dict[str, Any] | None: """ Convert one Omnigent item into one Codex ``response_item`` payload. :param item: Flat Omnigent item dict, e.g. ``{"type": "function_call", "name": "shell", ...}``. :returns: Responses-style item payload, or ``None`` for unsupported or empty Omnigent items. """ payload = _codex_response_item_payload(item) if payload is None: return None item_id = item.get("id") if isinstance(item_id, str) and item_id: payload["id"] = item_id return payload def _is_interrupted_assistant_session_item(item: dict[str, Any]) -> bool: """ Return whether an Omnigent item is an interrupted assistant partial. Omnigent persists these messages so the web transcript can show the text and the interrupted label after refresh. They must not be synthesized back into Codex's native rollout during resume, or Codex may treat a partial answer as completed history and continue from a cancelled turn. :param item: Flat Omnigent item dict, e.g. ``{"type": "message", "role": "assistant", "interrupted": True}``. :returns: ``True`` for interrupted assistant messages. """ return ( item.get("type") == "message" and item.get("role") == "assistant" and item.get("interrupted") is True ) def _codex_response_item_payload(item: dict[str, Any]) -> dict[str, Any] | None: """ Convert one supported Omnigent item into a Codex response payload body. :param item: Flat Omnigent item dict. :returns: Payload without the optional item id, or ``None`` for unsupported / empty Omnigent items. """ item_type = item.get("type") if item_type == "message": return _codex_message_payload_from_session_item(item) if item_type == "function_call": return _codex_function_call_payload_from_session_item(item) if item_type == "function_call_output": return _codex_function_call_output_payload_from_session_item(item) return None def _codex_message_payload_from_session_item(item: dict[str, Any]) -> dict[str, Any] | None: """ Convert an Omnigent message item into a Codex message payload. :param item: Omnigent message item. :returns: Codex message payload, or ``None`` for unsupported roles or empty text content. """ role = item.get("role") if role == "user": api_type = "input_text" elif role == "assistant": api_type = "output_text" else: return None content = _codex_content_blocks_from_api_content(item.get("content"), api_type=api_type) if not content: return None return {"type": "message", "role": role, "content": content} def _codex_function_call_payload_from_session_item( item: dict[str, Any], ) -> dict[str, Any] | None: """ Convert an Omnigent function call item into a Codex function call payload. :param item: Omnigent function call item. :returns: Codex function call payload, or ``None`` when optional routing fields are absent. :raises click.ClickException: If the Omnigent item violates required tool history fields. """ name = item.get("name") call_id = item.get("call_id") if not isinstance(name, str) or not name: return None if not isinstance(call_id, str) or not call_id: return None arguments = item.get("arguments") if not isinstance(arguments, str): item_id = item.get("id") raise click.ClickException( "Cannot synthesize Codex resume rollout: Omnigent function_call " f"{item_id!r} has non-string arguments." ) return { "type": "function_call", "name": name, "arguments": arguments, "call_id": call_id, } def _codex_function_call_output_payload_from_session_item( item: dict[str, Any], ) -> dict[str, Any] | None: """ Convert an Omnigent function output item into a Codex function output payload. :param item: Omnigent function output item. :returns: Codex function output payload, or ``None`` when optional routing fields are absent. :raises click.ClickException: If the Omnigent item violates required tool output fields. """ call_id = item.get("call_id") if not isinstance(call_id, str) or not call_id: return None output = item.get("output") if not isinstance(output, str): item_id = item.get("id") raise click.ClickException( "Cannot synthesize Codex resume rollout: Omnigent function_call_output " f"{item_id!r} has non-string output." ) return { "type": "function_call_output", "call_id": call_id, "output": output, } def _codex_content_blocks_from_api_content( content: object, *, api_type: str, ) -> list[dict[str, Any]]: """ Extract text blocks from an Omnigent content array for Codex rollout items. :param content: Omnigent content array, e.g. ``[{"type": "input_text", "text": "hello"}]``. :param api_type: Omnigent block type to include, e.g. ``"input_text"`` or ``"output_text"``. :returns: Codex/OpenAI content blocks preserving *api_type*. """ if not isinstance(content, list): return [] blocks: list[dict[str, Any]] = [] for block in content: if not isinstance(block, dict) or block.get("type") != api_type: continue text = block.get("text") if isinstance(text, str) and text: blocks.append({"type": api_type, "text": text}) return blocks def _codex_turn_id_for_session_item( *, session_id: str, external_session_id: str, item: dict[str, Any], index: int, ) -> str: """ Return a Codex turn id for an Omnigent item. Codex-native forwarder stores Omnigent ``response_id`` as ``"codex_"`` for mirrored items. When that prefix is not present, build a deterministic synthetic turn id from stable inputs. :param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``. :param external_session_id: Codex thread id, e.g. ``"019e96aa-0be2-7343-8d3b-6f914d60936b"``. :param item: Flat Omnigent item dict. :param index: Zero-based fallback item index. :returns: Codex turn id, e.g. ``"turn_abc123"``. """ response_id = item.get("response_id") if isinstance(response_id, str) and response_id.startswith("codex_"): turn_id = response_id.removeprefix("codex_") if turn_id: return turn_id stable = item.get("response_id") or item.get("id") or f"index-{index}" return ( "turn_" + uuid.uuid5( uuid.NAMESPACE_URL, f"omnigent-codex-resume:{session_id}:{external_session_id}:{stable}", ).hex ) def _codex_rollout_timestamp() -> str: """ Return a UTC timestamp string for synthesized Codex rollout records. :returns: ISO-8601 timestamp with ``Z`` suffix, e.g. ``"2026-06-08T12:34:56.789Z"``. """ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") async def _patch_external_session_id( client: httpx.AsyncClient, session_id: str, thread_id: str, ) -> None: """ Persist the native Codex thread id on the Omnigent session. :param client: HTTP client pointed at AP. :param session_id: Omnigent session id, e.g. ``"conv_abc123"``. :param thread_id: Codex thread id. :returns: None. """ resp = await client.patch( f"/v1/sessions/{url_component(session_id)}", json={"external_session_id": thread_id}, ) if resp.status_code >= 400: raise click.ClickException( f"Codex thread bind failed ({resp.status_code}): {error_text(resp)}" ) async def _wait_for_thread_started(client: CodexAppServerClient) -> str: """ Wait for the Codex TUI to create its remote app-server thread. Thin CLI-flavoured wrapper over the canonical :func:`omnigent.codex_native_forwarder.wait_for_thread_started` (shared with the host-spawned runner auto-create), translating its plain exceptions into ``click.ClickException`` for the CLI. :param client: Connected app-server client listening on the session Unix socket. :returns: Codex thread id, e.g. ``"019e70d7-1233-7b53-9c76-f1df1f6b1dba"``. :raises click.ClickException: If no ``thread/started`` event arrives before the startup timeout, or the stream ends first. """ from omnigent.codex_native_forwarder import wait_for_thread_started try: return await wait_for_thread_started(client, timeout=_CODEX_THREAD_START_TIMEOUT_SECONDS) except TimeoutError as exc: raise click.ClickException( "Codex TUI did not start a remote app-server thread before " f"the {_CODEX_THREAD_START_TIMEOUT_SECONDS:.0f}s timeout." ) from exc except RuntimeError as exc: raise click.ClickException( "Codex app-server event stream ended before thread startup." ) from exc async def _start_initial_turn(app_server_url: str, thread_id: str, prompt: str) -> None: """ Submit an initial prompt to a native Codex thread. :param app_server_url: App-server transport to connect over, e.g. ``"ws://127.0.0.1:9876"``. :param thread_id: Codex thread id. :param prompt: Prompt text. :returns: None. """ client = client_for_transport(app_server_url, client_name="omnigent-codex-native") await client.connect() try: await client.request( "turn/start", {"threadId": thread_id, "input": [{"type": "text", "text": prompt}]}, ) finally: await client.close() async def _launch_codex_terminal( client: httpx.AsyncClient, session_id: str, *, codex_args: tuple[str, ...], command: str, thread_id: str | None, remote_url: str, env: dict[str, str], config_overrides: tuple[str, ...] = (), ) -> LaunchedCodexTerminal: """ Launch the server-backed Codex terminal resource. :param client: HTTP client pointed at AP. :param session_id: Omnigent session id. :param codex_args: Raw Codex CLI args. :param command: Codex executable. :param thread_id: Codex thread id to resume. ``None`` starts a fresh remote Codex TUI thread. :param remote_url: App-server transport the Codex TUI attaches to via ``--remote``, e.g. ``"ws://127.0.0.1:9876"``. :param env: Environment overrides for the terminal process. :param config_overrides: Codex ``-c`` provider/model overrides to apply to the ``--remote`` TUI so it resolves the same provider as the app-server (and skips the OpenAI-login onboarding screen). See :func:`build_codex_remote_args`. Empty for a plain Codex-login launch. E.g. ``('model_provider="omnigent_databricks"',)``. :returns: Launched terminal resource details. """ terminal_args = build_codex_remote_args( codex_args=codex_args, thread_id=thread_id, remote_url=remote_url, config_overrides=config_overrides, ) body = { "terminal": _TERMINAL_NAME, "session_key": _TERMINAL_SESSION_KEY, "spec": { "command": command, "args": terminal_args, "os_env_type": "caller_process", "cwd": str(Path.cwd()), "env": env, "scrollback": _CODEX_TERMINAL_SCROLLBACK_LINES, "tmux_allow_passthrough": True, "tmux_start_on_attach": True, }, } resp = await client.post( f"/v1/sessions/{url_component(session_id)}/resources/terminals", json=body, timeout=30.0, ) if resp.status_code >= 400: raise click.ClickException( f"Codex terminal launch failed ({resp.status_code}): {error_text(resp)}" ) payload = resp.json() return _launched_codex_terminal_from_payload(payload) def _launched_codex_terminal_from_payload(payload: object) -> LaunchedCodexTerminal: """ Decode terminal launch metadata returned by the runner. :param payload: Decoded terminal resource JSON object, e.g. ``{"id": "terminal_codex_main", "metadata": {...}}``. :returns: Launched terminal details. :raises click.ClickException: If the response omits a valid terminal id. """ if not isinstance(payload, dict): raise click.ClickException("Codex terminal launch returned non-object JSON.") terminal_id = payload.get("id") if not isinstance(terminal_id, str) or not terminal_id: raise click.ClickException("Codex terminal launch response did not include terminal id.") metadata = payload.get("metadata") tmux_socket: Path | None = None tmux_target: str | None = None if isinstance(metadata, dict): raw_socket = metadata.get("tmux_socket") raw_target = metadata.get("tmux_target") if isinstance(raw_socket, str) and raw_socket: tmux_socket = Path(raw_socket) if isinstance(raw_target, str) and raw_target: tmux_target = raw_target return LaunchedCodexTerminal( terminal_id=terminal_id, tmux_socket=tmux_socket, tmux_target=tmux_target, ) async def _find_running_codex_terminal( client: httpx.AsyncClient, session_id: str, ) -> LaunchedCodexTerminal | None: """ Return the existing running Codex terminal id if present. Lookup happens before rebinding an existing session to this invocation's local runner. If the previously bound runner is offline, the resource route returns an unavailable status; treat that as a reattach miss so the caller can bind the current runner and cold-resume the Codex thread. :param client: HTTP client pointed at AP. :param session_id: Omnigent session id, e.g. ``"conv_abc123"``. :returns: Terminal details, or ``None`` when absent. :raises click.ClickException: If the server rejects the lookup for a reason other than "not currently attachable". """ terminal_id = codex_terminal_resource_id() resp = await client.get( f"/v1/sessions/{url_component(session_id)}" f"/resources/terminals/{url_component(terminal_id)}" ) if _codex_terminal_lookup_is_reattach_miss(resp): return None if resp.status_code >= 400: raise click.ClickException( f"Failed to fetch Codex terminal ({resp.status_code}): {error_text(resp)}" ) payload = resp.json() metadata = payload.get("metadata") if isinstance(payload, dict) else None if isinstance(metadata, dict) and metadata.get("running") is False: return None return _launched_codex_terminal_from_payload(payload) def _codex_terminal_lookup_is_reattach_miss(resp: httpx.Response) -> bool: """ Return whether a terminal lookup means "launch a replacement". Only missing terminals and explicit runner-unavailable states are safe to treat as a reattach miss. Other 409 / 502 / 503 responses can indicate real server or infrastructure failures and should stay loud. :param resp: HTTP response from the terminal resource lookup. :returns: ``True`` when Codex should cold-resume into a new terminal; ``False`` when the response should be handled by the normal status path. """ if resp.status_code == 404: return True error_code = _response_error_code(resp) if error_code == _RUNNER_UNAVAILABLE_ERROR_CODE: return True message = error_text(resp) if resp.status_code == 503 and _runner_offline_message(message): return True if error_code == _CONFLICT_ERROR_CODE and _UNBOUND_RUNNER_MESSAGE_FRAGMENT in message: return True if resp.status_code == 409 and _UNBOUND_RUNNER_MESSAGE_FRAGMENT in message: return True return False def _response_error_code(resp: httpx.Response) -> str | None: """ Extract a structured Omnigent error code from *resp* if present. :param resp: HTTP response from AP. :returns: ``error.code`` when the JSON body has one, otherwise ``None``. """ try: body = resp.json() except ValueError: return None if not isinstance(body, dict): return None error = body.get("error") if not isinstance(error, dict): return None code = error.get("code") return code if isinstance(code, str) else None def _runner_offline_message(message: str) -> bool: """ Return whether *message* is the Omnigent stale-runner error shape. :param message: Error text extracted from AP, e.g. ``"runner 'runner_abc' is offline for conversation 'conv_123'"``. :returns: ``True`` when the message specifically names an offline runner for the conversation being resumed. """ return message.startswith("runner ") and _RUNNER_OFFLINE_MESSAGE_FRAGMENT in message async def _close_codex_terminal( *, base_url: str, headers: dict[str, str], session_id: str, terminal_id: str, ) -> None: """ Best-effort close of the AP-side Codex terminal resource. :param base_url: Omnigent server base URL. :param headers: HTTP auth headers. :param session_id: Omnigent session id. :param terminal_id: Terminal resource id. :returns: None. """ with contextlib.suppress(Exception): async with httpx.AsyncClient( base_url=base_url, headers=headers, timeout=httpx.Timeout(10.0), ) as client: await client.delete( f"/v1/sessions/{url_component(session_id)}" f"/resources/terminals/{url_component(terminal_id)}" ) def _resolve_session_id_for_resume( *, base_url: str, headers: dict[str, str], session_id: str | None, resume_picker: bool, ) -> str | None: """ Translate resume inputs into a concrete Codex-native session id. :param base_url: Omnigent server base URL. :param headers: HTTP auth headers. :param session_id: Explicit session id, e.g. ``"conv_abc123"``. :param resume_picker: ``True`` for bare ``--resume``. :returns: Session id, or ``None`` for a fresh session / cancelled picker. """ if session_id is not None: return session_id if not resume_picker: return None from omnigent_client import OmnigentClient from omnigent.repl._resume_picker import pick_conversation_by_wrapper_label_from_sdk async def _drive() -> str | None: """ Run the async Codex-native picker. :returns: Selected Omnigent session id, or ``None``. """ async with OmnigentClient( base_url=base_url, headers=headers if headers else None, ) as client: return await pick_conversation_by_wrapper_label_from_sdk( client, wrapper_value=_WRAPPER_LABEL_VALUE, agent_name=_AGENT_NAME, ) return asyncio.run(_drive()) def codex_terminal_resource_id() -> str: """ Return the deterministic terminal resource id for Codex. :returns: Terminal resource id, e.g. ``"terminal_codex_main"``. """ return terminal_resource_id(_TERMINAL_NAME, _TERMINAL_SESSION_KEY) def _preflight_local_tools() -> None: """ Verify local executables required by the native Codex wrapper. :returns: None. :raises click.ClickException: If required tools are missing. """ if shutil.which("tmux") is None: raise click.ClickException( "tmux was not found on local PATH. The native Codex wrapper " "attaches to the runner-owned Codex tmux terminal." )