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

2653 lines
101 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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-<ISO-ts>-<thread_id>.jsonl``,
where the trailing ``<thread_id>`` 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/<hash>/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 <target_thread_id>``. 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/<hash>/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/<YYYY>/<MM>/<DD>/`` 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-*-<thread>.jsonl`` file. Codex
``resume <thread>`` 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_<turn_id>"`` 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."
)