1736 lines
75 KiB
Python
1736 lines
75 KiB
Python
"""Native Antigravity (agy) TUI wrapper for the Omnigent CLI.
|
|
|
|
``omnigent antigravity`` treats the Antigravity ``agy`` CLI as a
|
|
terminal-first program, mirroring ``omnigent codex`` / ``omnigent claude``.
|
|
It creates or binds an Omnigent session, launches ``agy`` in a runner-owned
|
|
tmux terminal resource, then attaches the local TTY (directly to the
|
|
runner's tmux when same-machine, else over the WebSocket PTY bridge).
|
|
|
|
The RPC read driver (Task 12+) replaced the retired transcript-tail forwarder:
|
|
the reader mirrors agy's steps over connect-RPC, and web turns are delivered via
|
|
the ``SendUserCascadeMessage`` RPC, not by typing into the TUI over tmux
|
|
send-keys. (Comments below that name those retired mechanisms only explain a
|
|
specific current behaviour they were replaced by.)
|
|
|
|
Differences from the Codex / Claude wrappers (Phase 1 scope):
|
|
|
|
* **No separate app-server.** agy self-hosts its local control surface, so
|
|
there is no app-server process to start, no ``--remote`` transport, and no
|
|
thread-init handshake.
|
|
* **RPC mirroring (read path) and RPC web-turn delivery (write path).** agy's
|
|
conversation mirrors into the Omnigent chat view via the RPC read driver
|
|
(:mod:`omnigent.antigravity_native_reader`), which polls/streams agy's
|
|
connect-RPC trajectory steps. Web-UI turns are delivered into the native agy
|
|
conversation (the write path) by the native executor
|
|
(:mod:`omnigent.inner.antigravity_native_executor`) over the connect-RPC
|
|
``SendUserCascadeMessage`` method, which agy records as a real ``USER_INPUT``
|
|
turn — NOT ``SendAgentMessage`` (recorded as a ``SYSTEM_MESSAGE``, which would
|
|
never mirror as a user turn; see the executor module).
|
|
* **Per-session identity is minted at cold-start, not assigned at launch.** agy
|
|
mints its own UUID conversation and ignores the launcher's
|
|
``ANTIGRAVITY_CONVERSATION_ID`` (verified empirically). A fresh launch seeds an
|
|
``agy_conv_*`` placeholder; the cold-start (:func:`_cold_start_agy_conversation`,
|
|
the runner's equivalent on the web path) then ``StartCascade``s a real id over
|
|
connect-RPC, writes it to bridge state (which the RPC reader binds), and PATCHes
|
|
it onto the session as ``external_session_id``. A resume reads that real id back
|
|
and passes ``--conversation <id>`` to continue agy's actual conversation (see
|
|
:func:`omnigent.antigravity_native_launch.build_agy_launch`).
|
|
* **Workspace = the agy terminal cwd.** agy runs tools in its process working
|
|
directory, so the terminal cwd is pinned to the session working dir; no
|
|
``--add-dir`` is needed.
|
|
* **Auth is inherited from ``~/.gemini``** — no credential seeding.
|
|
|
|
The runner OWNS the agy terminal: binding a runner triggers its idempotent
|
|
auto-create of the antigravity terminal (``runner/app.py``
|
|
``_auto_create_antigravity_terminal``) for every antigravity-native session. So
|
|
the CLI reattaches to that runner-owned terminal after binding rather than
|
|
launching its own — a double launch 500s ("already observed as required") and
|
|
clobbers the runner's bridge state (breaking web-turn injection). A CLI-side
|
|
launch (:func:`_launch_and_record`) remains only as a defensive fallback for the
|
|
unexpected case where the runner produces no terminal in the wait window.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
|
|
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 (
|
|
ANTIGRAVITY_NATIVE_WRAPPER_VALUE as _WRAPPER_LABEL_VALUE,
|
|
)
|
|
from omnigent._wrapper_labels import (
|
|
UI_MODE_LABEL_KEY as _UI_MODE_LABEL_KEY,
|
|
)
|
|
from omnigent._wrapper_labels import (
|
|
UI_MODE_TERMINAL_VALUE as _UI_MODE_TERMINAL_VALUE,
|
|
)
|
|
from omnigent._wrapper_labels import (
|
|
WRAPPER_LABEL_KEY as _WRAPPER_LABEL_KEY,
|
|
)
|
|
from omnigent.antigravity_native_bridge import (
|
|
AGY_PLACEHOLDER_CONVERSATION_PREFIX,
|
|
ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY,
|
|
AntigravityNativeBridgeState,
|
|
bridge_dir_for_bridge_id,
|
|
clear_bridge_state,
|
|
ensure_agy_feedback_survey_disabled,
|
|
ensure_agy_onboarding_complete,
|
|
is_placeholder_conversation_id,
|
|
prepare_bridge_dir,
|
|
read_bridge_state,
|
|
update_conversation_id,
|
|
write_bridge_state,
|
|
write_tmux_target,
|
|
)
|
|
from omnigent.antigravity_native_launch import (
|
|
agy_binary_path,
|
|
build_agy_launch,
|
|
resolve_native_antigravity_launch,
|
|
)
|
|
from omnigent.antigravity_native_reader import run_reader_with_bridge
|
|
from omnigent.antigravity_native_rpc import (
|
|
AntigravityRpcError,
|
|
resolve_cold_start_agy_rpc_port,
|
|
start_cascade,
|
|
)
|
|
from omnigent.claude_native import (
|
|
_attach_with_reconnect,
|
|
_AttachOutcome,
|
|
attach_local_terminal,
|
|
)
|
|
from omnigent.claude_native_bridge import url_component
|
|
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 (
|
|
bind_session_runner as _bind_session_runner,
|
|
)
|
|
from omnigent.native_terminal import (
|
|
terminal_attach_url as _attach_url,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_AGENT_NAME = "antigravity-native-ui"
|
|
_TERMINAL_NAME = "antigravity"
|
|
_TERMINAL_SESSION_KEY = "main"
|
|
_ANTIGRAVITY_TERMINAL_SCROLLBACK_LINES = 100_000
|
|
_SESSION_LABELS = {
|
|
_UI_MODE_LABEL_KEY: _UI_MODE_TERMINAL_VALUE,
|
|
_WRAPPER_LABEL_KEY: _WRAPPER_LABEL_VALUE,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LaunchedAntigravityTerminal:
|
|
"""
|
|
Terminal resource returned by the Omnigent runner launch path.
|
|
|
|
:param terminal_id: Terminal resource id, e.g.
|
|
``"terminal_antigravity_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(frozen=True)
|
|
class PreparedAntigravityTerminal:
|
|
"""
|
|
Prepared native Antigravity terminal attachment details.
|
|
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param terminal_id: Terminal resource id to attach.
|
|
:param bridge_dir: Native Antigravity bridge directory shared with the
|
|
``antigravity-native`` harness.
|
|
: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 reattached: ``True`` when an existing terminal was reused.
|
|
Drives teardown ownership — a reattached invocation must not
|
|
close the terminal on exit.
|
|
"""
|
|
|
|
session_id: str
|
|
terminal_id: str
|
|
bridge_dir: Path
|
|
tmux_socket: Path | None
|
|
tmux_target: str | None
|
|
reattached: bool
|
|
|
|
|
|
def run_antigravity_native(
|
|
*,
|
|
server: str | None,
|
|
session_id: str | None,
|
|
antigravity_args: tuple[str, ...] = (),
|
|
resume_picker: bool = False,
|
|
command: str | None = None,
|
|
model: str | None = None,
|
|
permission_mode: str | None = None,
|
|
auto_open_conversation: bool = False,
|
|
) -> None:
|
|
"""
|
|
Launch the Antigravity (agy) TUI in an Omnigent terminal and attach.
|
|
|
|
:param server: Resolved Omnigent server URL, e.g.
|
|
``"http://127.0.0.1:8123"``. ``None`` starts a local Omnigent
|
|
server using the existing chat-server machinery.
|
|
:param session_id: Optional existing Omnigent conversation id to
|
|
resume, e.g. ``"conv_abc123"``. ``None`` creates a new bundled
|
|
session.
|
|
:param antigravity_args: Raw pass-through args appended to the ``agy``
|
|
command line after the generated flags.
|
|
:param resume_picker: ``True`` runs the antigravity-native picker once
|
|
the server is reachable; ``False`` keeps the explicit
|
|
``session_id``-or-fresh behavior.
|
|
:param command: Path to the ``agy`` executable. ``None`` resolves it
|
|
via :func:`agy_binary_path`. Kept off the public CLI surface so
|
|
tests can supply a fake executable.
|
|
:param model: Optional model label passed to agy via ``--model``,
|
|
e.g. ``"gemini-2.5-pro"``. ``None`` lets agy use its default.
|
|
:param permission_mode: Optional Omnigent permission mode, e.g.
|
|
``"bypassPermissions"``. ``"bypassPermissions"`` maps to agy's
|
|
``--dangerously-skip-permissions`` (its only pre-emptive control);
|
|
any other value (or ``None``) leaves agy's default ``request-review``
|
|
prompt in place for the attended user — unless the launch is headless,
|
|
in which case the prompt is auto-bypassed so an unattended turn does not
|
|
hang (see
|
|
:func:`omnigent.antigravity_native_launch.should_skip_permissions`).
|
|
: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, launch, or attach fails.
|
|
"""
|
|
resolved_command = (command or agy_binary_path()).strip()
|
|
if not resolved_command:
|
|
raise click.ClickException("Antigravity command must not be empty.")
|
|
_preflight_local_tools()
|
|
# Resolve auth/model config once up front so a missing credential warns
|
|
# before any server work. agy is OAuth-only (subscription), inherited
|
|
# from ~/.gemini — nothing is seeded.
|
|
launch = resolve_native_antigravity_launch(model=model)
|
|
# Detect headless ONCE here (a controlling TTY on stdin+stdout means an
|
|
# interactive client will attach to drive agy's request-review prompt; a
|
|
# non-TTY launch must auto-bypass or the unattended turn hangs forever).
|
|
headless = _launch_is_headless()
|
|
with TemporaryDirectory(prefix="omnigent-antigravity-native-") as tmpdir:
|
|
spec_path = _materialize_antigravity_agent_spec(Path(tmpdir))
|
|
if server is None:
|
|
_run_with_local_server(
|
|
spec_path,
|
|
session_id=session_id,
|
|
resume_picker=resume_picker,
|
|
antigravity_args=antigravity_args,
|
|
command=resolved_command,
|
|
model=launch.model,
|
|
permission_mode=permission_mode,
|
|
headless=headless,
|
|
auto_open_conversation=auto_open_conversation,
|
|
)
|
|
else:
|
|
_run_with_remote_server(
|
|
server.rstrip("/"),
|
|
spec_path,
|
|
session_id=session_id,
|
|
resume_picker=resume_picker,
|
|
antigravity_args=antigravity_args,
|
|
command=resolved_command,
|
|
model=launch.model,
|
|
permission_mode=permission_mode,
|
|
headless=headless,
|
|
auto_open_conversation=auto_open_conversation,
|
|
)
|
|
|
|
|
|
def _materialize_antigravity_agent_spec(tmpdir: Path) -> Path:
|
|
"""
|
|
Write the terminal-first agent spec used by ``omnigent antigravity``.
|
|
|
|
:param tmpdir: Temporary directory for the generated YAML file.
|
|
:returns: Path to the generated YAML spec.
|
|
"""
|
|
yaml_path = tmpdir / "antigravity-native-ui.yaml"
|
|
raw: dict[str, object] = {
|
|
"name": _AGENT_NAME,
|
|
"prompt": (
|
|
"Antigravity (agy) is running in the session terminal. Web UI "
|
|
"turns are forwarded into the native agy conversation."
|
|
),
|
|
"executor": {"harness": "antigravity-native"},
|
|
# Opt the native session into the child-session spawn writes so the
|
|
# wrapped agy can author agent configs and launch them as sub-agent
|
|
# sessions. The Omnigent MCP relay (wired in #1194 — see
|
|
# ``antigravity_native_bridge.write_mcp_config`` and the runner's
|
|
# ``_ensure_comment_relay_started``) derives its advertised
|
|
# ``sys_session_*`` write surface from this ``spawn: true`` gate.
|
|
"spawn": True,
|
|
# Without an ``os_env`` block the runner's filesystem APIs 404 (see
|
|
# ``_require_os_env`` in ``omnigent/runner/app.py``). agy already
|
|
# operates on the user's workspace with full filesystem access, so
|
|
# caller-process / no-sandbox matches reality and enables the web
|
|
# UI's files panel.
|
|
"os_env": {
|
|
"type": "caller_process",
|
|
"cwd": ".",
|
|
"sandbox": {"type": "none"},
|
|
},
|
|
# Declare a default shell terminal so the Omnigent MCP relay advertises
|
|
# the ``sys_terminal_*`` family to the wrapped agy (the relay's gate is
|
|
# a non-empty ``terminals:`` block on this spec). This also feeds the
|
|
# web-UI new-terminal affordance (``server/routes/sessions.py``), so it
|
|
# is not inert even independent of the relay. Its command follows the
|
|
# user's ``$SHELL`` (zsh/fish/bash).
|
|
"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,
|
|
antigravity_args: tuple[str, ...],
|
|
command: str,
|
|
model: str | None,
|
|
permission_mode: str | None = None,
|
|
headless: bool = False,
|
|
auto_open_conversation: bool = False,
|
|
) -> None:
|
|
"""
|
|
Start a local Omnigent server, launch agy, and attach to it.
|
|
|
|
:param spec_path: Generated Antigravity wrapper agent spec.
|
|
:param session_id: Optional existing Omnigent session id.
|
|
:param resume_picker: When ``True`` and ``session_id is None``, run the
|
|
picker.
|
|
:param antigravity_args: Raw pass-through agy args.
|
|
:param command: agy executable to run.
|
|
:param model: Optional agy model id.
|
|
:param permission_mode: Optional Omnigent permission mode (e.g.
|
|
``"bypassPermissions"``) threaded into the agy argv assembly.
|
|
:param headless: ``True`` when no interactive client will attach (forces
|
|
the agy permission-bypass flag so an unattended turn does not hang).
|
|
: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:
|
|
# Picker cancelled — exit before creating a session the user declined.
|
|
return
|
|
|
|
async def _drive() -> None:
|
|
"""
|
|
Prepare agy and attach in a single event loop.
|
|
|
|
:returns: None.
|
|
"""
|
|
with runner_startup_progress(initial_message="Preparing Antigravity...") as progress:
|
|
bundle = None if resolved_session_id is not None else _bundle_agent(spec_path)
|
|
prepared = await _prepare_antigravity_terminal(
|
|
base_url=base_url,
|
|
headers={},
|
|
session_id=resolved_session_id,
|
|
runner_id=server_handle.runner_id,
|
|
session_bundle=bundle,
|
|
antigravity_args=antigravity_args,
|
|
command=command,
|
|
model=model,
|
|
permission_mode=permission_mode,
|
|
headless=headless,
|
|
startup_progress=progress,
|
|
)
|
|
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_terminal(
|
|
base_url=base_url,
|
|
headers={},
|
|
prepared=prepared,
|
|
recover=None,
|
|
)
|
|
if resolved_session_id is None:
|
|
echo_native_resume_hint(
|
|
native_command="antigravity",
|
|
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,
|
|
antigravity_args: tuple[str, ...],
|
|
command: str,
|
|
model: str | None,
|
|
permission_mode: str | None = None,
|
|
headless: bool = False,
|
|
auto_open_conversation: bool = False,
|
|
) -> None:
|
|
"""
|
|
Launch agy on a remote Omnigent server via a daemon-spawned runner.
|
|
|
|
The CLI binds a daemon runner to the session, then launches the agy
|
|
terminal itself (the runner has no agy auto-create branch). Attach
|
|
prefers the runner's tmux when it is local, else the WebSocket PTY
|
|
bridge.
|
|
|
|
:param base_url: Remote Omnigent server base URL, e.g.
|
|
``"https://example.databricks.com"``.
|
|
:param spec_path: Generated Antigravity wrapper agent spec.
|
|
:param session_id: Optional existing Omnigent session id.
|
|
:param resume_picker: When ``True`` and ``session_id is None``, run the
|
|
picker.
|
|
:param antigravity_args: Raw pass-through agy args.
|
|
:param command: agy executable to run.
|
|
:param model: Optional agy model id.
|
|
:param permission_mode: Optional Omnigent permission mode (e.g.
|
|
``"bypassPermissions"``) threaded into the agy argv assembly.
|
|
:param headless: ``True`` when no interactive client will attach (forces
|
|
the agy permission-bypass flag so an unattended turn does not hang).
|
|
: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
|
|
from omnigent.cli import _ensure_host_daemon
|
|
from omnigent.host.identity import load_or_create_host_identity
|
|
|
|
headers = _remote_headers(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
|
|
|
|
async def _drive() -> None:
|
|
"""
|
|
Prepare agy and attach in a single event loop.
|
|
|
|
:returns: None.
|
|
"""
|
|
with runner_startup_progress(initial_message="Preparing Antigravity...") as progress:
|
|
progress.update("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_antigravity_terminal_via_daemon(
|
|
base_url=base_url,
|
|
headers=headers,
|
|
session_id=resolved_session_id,
|
|
session_bundle=bundle,
|
|
antigravity_args=antigravity_args,
|
|
command=command,
|
|
model=model,
|
|
permission_mode=permission_mode,
|
|
headless=headless,
|
|
host_id=host_id,
|
|
workspace=str(Path.cwd().resolve()),
|
|
startup_progress=progress,
|
|
)
|
|
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)
|
|
|
|
await _attach_terminal(
|
|
base_url=base_url,
|
|
headers=headers,
|
|
prepared=prepared,
|
|
recover=_recover,
|
|
)
|
|
if resolved_session_id is None:
|
|
echo_native_resume_hint(
|
|
native_command="antigravity",
|
|
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_antigravity_terminal(
|
|
*,
|
|
base_url: str,
|
|
headers: dict[str, str],
|
|
session_id: str | None,
|
|
runner_id: str | None,
|
|
session_bundle: bytes | None,
|
|
antigravity_args: tuple[str, ...],
|
|
command: str,
|
|
model: str | None,
|
|
permission_mode: str | None = None,
|
|
headless: bool = False,
|
|
startup_progress: RunnerStartupProgress | None = None,
|
|
) -> PreparedAntigravityTerminal:
|
|
"""
|
|
Create/bind a session and launch its agy terminal resource.
|
|
|
|
:param base_url: Omnigent server base URL.
|
|
:param headers: HTTP auth headers; ``{}`` for the local server.
|
|
:param session_id: Optional existing session id.
|
|
:param runner_id: Runner id to bind to the session, or ``None``.
|
|
:param session_bundle: Gzipped agent bundle for new sessions. Required
|
|
when *session_id* is ``None``.
|
|
:param antigravity_args: Raw pass-through agy args.
|
|
:param command: agy executable to run.
|
|
:param model: Optional agy model id.
|
|
:param permission_mode: Optional Omnigent permission mode threaded into the
|
|
agy argv assembly.
|
|
:param headless: ``True`` when no interactive client will attach (forces
|
|
the agy permission-bypass flag).
|
|
:param startup_progress: Optional user-visible progress renderer.
|
|
:returns: Prepared terminal details.
|
|
:raises click.ClickException: If any server operation fails.
|
|
"""
|
|
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
|
|
conversation_id: str
|
|
resume = False
|
|
if session_id is None:
|
|
if session_bundle is None:
|
|
raise click.ClickException(
|
|
"Creating an Antigravity session requires a session bundle."
|
|
)
|
|
_update_progress(startup_progress, "Creating Antigravity session...")
|
|
bridge_id = _mint_agy_conversation_id()
|
|
conversation_id = bridge_id
|
|
session_id = await _create_antigravity_session(
|
|
client,
|
|
session_bundle,
|
|
bridge_id=bridge_id,
|
|
)
|
|
else:
|
|
_update_progress(startup_progress, "Loading Antigravity session...")
|
|
payload = await _fetch_antigravity_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 an antigravity-native session."
|
|
)
|
|
bridge_id = str(labels.get(ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY) or session_id)
|
|
existing = await _find_running_antigravity_terminal(client, session_id)
|
|
if existing is not None:
|
|
if antigravity_args or model is not None:
|
|
click.echo(
|
|
"Ignoring Antigravity launch args/model for an already-running "
|
|
"terminal; restart the session terminal to apply them.",
|
|
err=True,
|
|
)
|
|
_update_progress(startup_progress, "Antigravity terminal ready.")
|
|
return PreparedAntigravityTerminal(
|
|
session_id=session_id,
|
|
terminal_id=existing.terminal_id,
|
|
bridge_dir=bridge_dir_for_bridge_id(bridge_id),
|
|
tmux_socket=existing.tmux_socket,
|
|
tmux_target=existing.tmux_target,
|
|
reattached=True,
|
|
)
|
|
external = payload.get("external_session_id") if isinstance(payload, dict) else None
|
|
conversation_id = external if isinstance(external, str) and external else bridge_id
|
|
resume = isinstance(external, str) and bool(external)
|
|
|
|
if runner_id is not None:
|
|
await _bind_session_runner(client, session_id, runner_id)
|
|
# The runner OWNS the antigravity terminal: binding triggers its
|
|
# idempotent auto-create (``runner/app.py``
|
|
# ``_auto_create_antigravity_terminal``), which fires for every
|
|
# antigravity-native session — including on the local server, whose
|
|
# CLI runner subprocess runs the same auto-create. Reattach to that
|
|
# runner-owned terminal instead of launching our own: a redundant
|
|
# ``_launch_and_record`` 500s ("terminal antigravity:main … already
|
|
# observed as required") AND its ``clear_bridge_state`` wipes the bridge
|
|
# state the runner wrote (every web turn then fails "Antigravity native
|
|
# bridge state is missing"), and on ``reattached=False``
|
|
# ``_attach_terminal`` starts a SECOND RPC reader → double-mirror. The
|
|
# pre-bind existing-terminal check above can't catch this — the runner
|
|
# only auto-creates AFTER the bind. A CLI launch stays
|
|
# as a defensive fallback when the runner produced no terminal in the
|
|
# window. Mirrors ``_prepare_antigravity_terminal_via_daemon``.
|
|
autocreated = await _await_runner_antigravity_terminal(
|
|
client, session_id, _RUNNER_TERMINAL_AUTOCREATE_TIMEOUT_S
|
|
)
|
|
if autocreated is not None:
|
|
if antigravity_args or model is not None:
|
|
click.echo(
|
|
"Ignoring Antigravity launch args/model for the runner-owned "
|
|
"terminal; restart the session terminal to apply them.",
|
|
err=True,
|
|
)
|
|
_update_progress(startup_progress, "Antigravity terminal ready.")
|
|
return PreparedAntigravityTerminal(
|
|
session_id=session_id,
|
|
terminal_id=autocreated.terminal_id,
|
|
bridge_dir=bridge_dir_for_bridge_id(bridge_id),
|
|
tmux_socket=autocreated.tmux_socket,
|
|
tmux_target=autocreated.tmux_target,
|
|
reattached=True,
|
|
)
|
|
launched = await _launch_and_record(
|
|
client,
|
|
session_id=session_id,
|
|
bridge_id=bridge_id,
|
|
conversation_id=conversation_id,
|
|
resume=resume,
|
|
antigravity_args=antigravity_args,
|
|
command=command,
|
|
model=model,
|
|
permission_mode=permission_mode,
|
|
headless=headless,
|
|
startup_progress=startup_progress,
|
|
)
|
|
return PreparedAntigravityTerminal(
|
|
session_id=session_id,
|
|
terminal_id=launched.terminal_id,
|
|
bridge_dir=bridge_dir_for_bridge_id(bridge_id),
|
|
tmux_socket=launched.tmux_socket,
|
|
tmux_target=launched.tmux_target,
|
|
reattached=False,
|
|
)
|
|
|
|
|
|
# Binding a runner triggers the runner's idempotent auto-create of the
|
|
# antigravity terminal (``runner/app.py`` ``_auto_create_antigravity_terminal``),
|
|
# which OWNS the terminal for every antigravity-native session. After bind, wait
|
|
# this long for that runner-owned terminal to appear before falling back to a
|
|
# CLI-side launch — the fallback only fires when the runner produced none.
|
|
_RUNNER_TERMINAL_AUTOCREATE_TIMEOUT_S = 20.0
|
|
_RUNNER_TERMINAL_POLL_INTERVAL_S = 0.25
|
|
|
|
|
|
async def _await_runner_antigravity_terminal(
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
timeout_s: float,
|
|
) -> LaunchedAntigravityTerminal | None:
|
|
"""
|
|
Poll for the runner-auto-created agy terminal after a runner bind.
|
|
|
|
The runner auto-creates the antigravity terminal asynchronously once a runner
|
|
binds, so a fresh/cold-resume CLI launch must wait for it and reattach instead
|
|
of launching its own (a double-launch 500s AND ``_launch_and_record``'s
|
|
``clear_bridge_state`` would wipe the bridge state the runner wrote, breaking
|
|
web-turn injection). Uses ``time.monotonic`` for the deadline.
|
|
|
|
:param client: HTTP client pointed at the Omnigent server.
|
|
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
|
|
:param timeout_s: Max seconds to wait for the runner-owned terminal.
|
|
:returns: The runner-owned terminal details, or ``None`` if none appeared
|
|
within *timeout_s* (the caller then launches one itself).
|
|
"""
|
|
deadline = time.monotonic() + timeout_s
|
|
while True:
|
|
found = await _find_running_antigravity_terminal(client, session_id)
|
|
if found is not None:
|
|
return found
|
|
if time.monotonic() >= deadline:
|
|
return None
|
|
await asyncio.sleep(_RUNNER_TERMINAL_POLL_INTERVAL_S)
|
|
|
|
|
|
async def _prepare_antigravity_terminal_via_daemon(
|
|
*,
|
|
base_url: str,
|
|
headers: dict[str, str],
|
|
session_id: str | None,
|
|
session_bundle: bytes | None,
|
|
antigravity_args: tuple[str, ...],
|
|
command: str,
|
|
model: str | None,
|
|
permission_mode: str | None = None,
|
|
headless: bool = False,
|
|
host_id: str,
|
|
workspace: str,
|
|
startup_progress: RunnerStartupProgress | None = None,
|
|
) -> PreparedAntigravityTerminal:
|
|
"""
|
|
Create/resolve a session through a daemon runner and attach to agy.
|
|
|
|
Binds a daemon-spawned runner to the session, then reattaches to the
|
|
runner-owned agy terminal that the bind auto-creates (the runner owns the
|
|
terminal for every antigravity-native session). Only launches the terminal
|
|
itself (:func:`_launch_and_record`) as a fallback when the runner produced
|
|
none within :data:`_RUNNER_TERMINAL_AUTOCREATE_TIMEOUT_S`.
|
|
|
|
:param base_url: Omnigent server base URL.
|
|
: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 agent bundle. Required when
|
|
*session_id* is ``None``.
|
|
:param antigravity_args: Raw pass-through agy args.
|
|
:param command: agy executable to run.
|
|
:param model: Optional agy model id.
|
|
:param permission_mode: Optional Omnigent permission mode threaded into the
|
|
agy argv assembly.
|
|
:param headless: ``True`` when no interactive client will attach (forces
|
|
the agy permission-bypass flag).
|
|
:param host_id: Local host daemon id, e.g. ``"host_abc123"``.
|
|
:param workspace: Absolute workspace path for the runner cwd.
|
|
:param startup_progress: Optional user-visible progress renderer.
|
|
:returns: Prepared terminal details for attaching.
|
|
:raises click.ClickException: If setup fails.
|
|
"""
|
|
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
|
|
conversation_id: str
|
|
resume = False
|
|
if session_id is None:
|
|
if session_bundle is None:
|
|
raise click.ClickException(
|
|
"Creating an Antigravity session requires a session bundle."
|
|
)
|
|
_update_progress(startup_progress, "Creating Antigravity session...")
|
|
bridge_id = _mint_agy_conversation_id()
|
|
conversation_id = bridge_id
|
|
session_id = await _create_antigravity_session(
|
|
client,
|
|
session_bundle,
|
|
bridge_id=bridge_id,
|
|
)
|
|
else:
|
|
_update_progress(startup_progress, "Loading Antigravity session...")
|
|
payload = await _fetch_antigravity_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 an antigravity-native session."
|
|
)
|
|
bridge_id = str(labels.get(ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY) or session_id)
|
|
# Reattach to an already-running runner-owned agy terminal instead of
|
|
# relaunching. Without this the daemon resume path always falls to
|
|
# ``_launch_and_record`` → unconditional ``clear_bridge_state``,
|
|
# which wipes the runner reader's bound ``conversation_id``;
|
|
# and ``reattached=False`` would make teardown close a terminal a
|
|
# different launcher owns. Mirrors the local-server prepare path and
|
|
# ``codex_native`` daemon resume. Runs before host-online/bind: a
|
|
# live terminal means a runner is already serving (the GET reaches
|
|
# it); a cold resume returns ``None`` and falls through to launch.
|
|
existing = await _find_running_antigravity_terminal(client, session_id)
|
|
if existing is not None:
|
|
if antigravity_args or model is not None:
|
|
click.echo(
|
|
"Ignoring Antigravity launch args/model for an already-running "
|
|
"terminal; restart the session terminal to apply them.",
|
|
err=True,
|
|
)
|
|
_update_progress(startup_progress, "Antigravity terminal ready.")
|
|
return PreparedAntigravityTerminal(
|
|
session_id=session_id,
|
|
terminal_id=existing.terminal_id,
|
|
bridge_dir=bridge_dir_for_bridge_id(bridge_id),
|
|
tmux_socket=existing.tmux_socket,
|
|
tmux_target=existing.tmux_target,
|
|
reattached=True,
|
|
)
|
|
external = payload.get("external_session_id") if isinstance(payload, dict) else None
|
|
conversation_id = external if isinstance(external, str) and external else bridge_id
|
|
resume = isinstance(external, str) and bool(external)
|
|
|
|
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
|
_update_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_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 reject
|
|
# the bind. Mirrors the Codex/Claude daemon prepare ordering.
|
|
await _bind_session_runner(client, session_id, runner_id)
|
|
# The runner OWNS the antigravity terminal: binding triggers its idempotent
|
|
# auto-create (``runner/app.py`` ``_auto_create_antigravity_terminal``,
|
|
# which fires for every antigravity-native session). Reattach to that
|
|
# runner-owned terminal instead of launching our own. Launching here would
|
|
# (a) 500 ("terminal antigravity:main … already observed as required") and
|
|
# (b) ``_launch_and_record``'s ``clear_bridge_state`` would wipe the bridge
|
|
# state the runner wrote, so every web turn would fail with "Antigravity
|
|
# native bridge state is missing". The pre-bind reattach check can't catch
|
|
# this — the runner only auto-creates AFTER the bind. Falling through to a
|
|
# CLI launch stays as a defensive fallback for the (unexpected) case where
|
|
# the runner produced no terminal in the window.
|
|
autocreated = await _await_runner_antigravity_terminal(
|
|
client, session_id, _RUNNER_TERMINAL_AUTOCREATE_TIMEOUT_S
|
|
)
|
|
if autocreated is not None:
|
|
if antigravity_args or model is not None:
|
|
click.echo(
|
|
"Ignoring Antigravity launch args/model for the runner-owned "
|
|
"terminal; restart the session terminal to apply them.",
|
|
err=True,
|
|
)
|
|
_update_progress(startup_progress, "Antigravity terminal ready.")
|
|
return PreparedAntigravityTerminal(
|
|
session_id=session_id,
|
|
terminal_id=autocreated.terminal_id,
|
|
bridge_dir=bridge_dir_for_bridge_id(bridge_id),
|
|
tmux_socket=autocreated.tmux_socket,
|
|
tmux_target=autocreated.tmux_target,
|
|
reattached=True,
|
|
)
|
|
launched = await _launch_and_record(
|
|
client,
|
|
session_id=session_id,
|
|
bridge_id=bridge_id,
|
|
conversation_id=conversation_id,
|
|
resume=resume,
|
|
antigravity_args=antigravity_args,
|
|
command=command,
|
|
model=model,
|
|
permission_mode=permission_mode,
|
|
headless=headless,
|
|
startup_progress=startup_progress,
|
|
)
|
|
return PreparedAntigravityTerminal(
|
|
session_id=session_id,
|
|
terminal_id=launched.terminal_id,
|
|
bridge_dir=bridge_dir_for_bridge_id(bridge_id),
|
|
tmux_socket=launched.tmux_socket,
|
|
tmux_target=launched.tmux_target,
|
|
reattached=False,
|
|
)
|
|
|
|
|
|
async def _launch_and_record(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
bridge_id: str,
|
|
conversation_id: str,
|
|
resume: bool,
|
|
antigravity_args: tuple[str, ...],
|
|
command: str,
|
|
model: str | None,
|
|
permission_mode: str | None = None,
|
|
headless: bool = False,
|
|
startup_progress: RunnerStartupProgress | None,
|
|
) -> LaunchedAntigravityTerminal:
|
|
"""
|
|
Prepare the bridge, launch the agy terminal, and seed bridge state.
|
|
|
|
Builds the agy argv (``--conversation <real-id>`` on resume), POSTs the
|
|
terminal resource, and seeds the shared bridge state the
|
|
``antigravity-native`` harness reads. agy's real id is NOT captured here: agy
|
|
mints its own UUID and ignores the launcher's id, so a fresh launch seeds an
|
|
``agy_conv_*`` placeholder that the attach-time cold-start
|
|
(:func:`_cold_start_agy_conversation`) replaces with agy's real cascade id in
|
|
bridge state once agy is live. Seeding a guessed id here would make a later
|
|
resume pass an id agy cannot find.
|
|
|
|
No agy process pid is captured here (and there is no ``agy_pid`` field in
|
|
bridge state). The terminal is launched with ``tmux_start_on_attach=True``,
|
|
so at launch the pane runs a ``tmux wait-for`` shell — the agy process does
|
|
not exist until the first client attaches, and there is no pid to record.
|
|
The executor therefore discovers agy's connect-RPC port at injection time by
|
|
enumerating agy processes and validating each against the bridge's
|
|
conversation id via ``GetConversationMetadata`` (see
|
|
:func:`omnigent.antigravity_native_rpc.resolve_language_server_port`). A pid
|
|
fast-path is deliberately omitted: it would never fire (no pid at launch)
|
|
and trusting a recycled pid without the conversation check would risk
|
|
injecting into a different live agy.
|
|
|
|
No durable read cursor is seeded: the RPC read driver that mirrors agy's
|
|
conversation (:mod:`omnigent.antigravity_native_reader`) keeps an in-memory
|
|
seen-set only.
|
|
|
|
:param client: HTTP client pointed at the Omnigent server.
|
|
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
|
|
:param bridge_id: Opaque bridge id keying the bridge directory.
|
|
:param conversation_id: On resume, agy's real (discovered) conversation id
|
|
to pass as ``--conversation``. On a fresh launch, the minted
|
|
``agy_conv_*`` placeholder used only to seed bridge state until the
|
|
cold-start mints agy's real id.
|
|
:param resume: ``True`` to resume an existing agy conversation.
|
|
:param antigravity_args: Raw pass-through agy args.
|
|
:param command: agy executable to run.
|
|
:param model: Optional agy model id.
|
|
:param permission_mode: Optional Omnigent permission mode threaded into the
|
|
agy argv assembly (maps ``"bypassPermissions"`` to the bypass flag).
|
|
:param headless: ``True`` when no interactive client will attach (forces
|
|
the agy permission-bypass flag so an unattended turn does not hang).
|
|
:param startup_progress: Optional user-visible progress renderer.
|
|
:returns: Launched terminal resource details.
|
|
:raises click.ClickException: If the terminal launch fails.
|
|
"""
|
|
bridge_dir = prepare_bridge_dir(bridge_id)
|
|
# Clear stale turn/conversation state so a fresh launch rediscovers this run's
|
|
# real agy conversation id instead of binding to the previous run's.
|
|
await asyncio.to_thread(clear_bridge_state, bridge_dir)
|
|
# Pre-accept agy's first-run onboarding wizard (HOME-global) so a headless /
|
|
# detached launch does not hang waiting for a TTY answer. Idempotent and
|
|
# offloaded to a thread (file I/O), mirroring the bridge-state writes below.
|
|
await asyncio.to_thread(ensure_agy_onboarding_complete)
|
|
argv, env_overrides = build_agy_launch(
|
|
conversation_id=conversation_id if resume else None,
|
|
model=model,
|
|
resume=resume,
|
|
permission_mode=permission_mode,
|
|
headless=headless,
|
|
extra_args=antigravity_args,
|
|
)
|
|
# agy's feedback survey shares its "esc to cancel" footer with the running-turn
|
|
# marker, so a web turn injected into this CLI-launched session while the survey
|
|
# is up would be silently lost (#1494). Disable it in the launch HOME before agy
|
|
# starts. This path emits no HOME override, so agy runs under the real ~/.gemini.
|
|
await asyncio.to_thread(
|
|
ensure_agy_feedback_survey_disabled,
|
|
Path(env_overrides.get("HOME") or Path.home()),
|
|
)
|
|
_update_progress(startup_progress, "Starting Antigravity terminal...")
|
|
launched = await _launch_antigravity_terminal(
|
|
client,
|
|
session_id,
|
|
argv=argv,
|
|
env=env_overrides,
|
|
command=command,
|
|
)
|
|
# Advertise the tmux pane so a web turn to this CLI-launched session can be
|
|
# bootstrapped into the idle agy TUI by the executor (agy mints its
|
|
# conversation only after it processes a turn; until then connect-RPC has
|
|
# nothing to address). Only when the runner exposed a local pane.
|
|
if launched.tmux_socket is not None and launched.tmux_target is not None:
|
|
await asyncio.to_thread(
|
|
write_tmux_target,
|
|
bridge_dir,
|
|
socket_path=launched.tmux_socket,
|
|
tmux_target=launched.tmux_target,
|
|
)
|
|
# Seed bridge state with the conversation id known so far (the real id on
|
|
# resume; the ``agy_conv_*`` placeholder on a fresh launch — the attach-time
|
|
# cold-start replaces it with agy's real cascade id once agy is live, so the
|
|
# RPC reader binds the real conversation). No durable read cursor is seeded
|
|
# (the reader keeps an in-memory seen-set).
|
|
await asyncio.to_thread(
|
|
write_bridge_state,
|
|
bridge_dir,
|
|
AntigravityNativeBridgeState(
|
|
session_id=session_id,
|
|
conversation_id=conversation_id,
|
|
),
|
|
)
|
|
_update_progress(startup_progress, "Antigravity terminal ready.")
|
|
return launched
|
|
|
|
|
|
async def _attach_terminal(
|
|
*,
|
|
base_url: str,
|
|
headers: dict[str, str],
|
|
prepared: PreparedAntigravityTerminal,
|
|
recover: Callable[[], Awaitable[None]] | None,
|
|
) -> None:
|
|
"""
|
|
Attach to the prepared agy terminal, tearing it down on real exit.
|
|
|
|
Prefers a direct local tmux attach when the runner shares this host;
|
|
otherwise relays over the WebSocket PTY bridge with reconnect. On a
|
|
real exit (not a tmux detach) the AP-side terminal resource is
|
|
best-effort closed, unless this invocation reattached to a terminal
|
|
another launcher owns.
|
|
|
|
**Read/write wiring on the CLI fallback only.** When the runner produced no
|
|
terminal and the CLI launched its own (``prepared.reattached is False``), this
|
|
spawns — for the attach's lifetime, cancelled in ``finally`` — the RPC read
|
|
driver (:func:`omnigent.antigravity_native_reader.run_reader_with_bridge`,
|
|
which mirrors agy's conversation into the Omnigent chat view and surfaces
|
|
WAITING interactions as real-time elicitations) and a one-shot cold-start
|
|
(:func:`_cold_start_agy_conversation`) that mints agy's real cascade id so the
|
|
reader can bind it. These run CONCURRENTLY with the attach because the CLI
|
|
terminal uses ``tmux_start_on_attach=True`` — agy does not exist until this
|
|
attach starts the pane, so cold-start cannot precede it; the cold-start's
|
|
port poll and the reader's discovery poll both wait agy out. When
|
|
``prepared.reattached is True`` the runner OWNS the terminal and already runs
|
|
its own reader (``runner/app.py`` ``_auto_create_antigravity_terminal``), so
|
|
the CLI starts neither — a second reader would double-mirror every step.
|
|
|
|
.. note::
|
|
On this CLI fallback the human attaches to agy's TUI, which shows the
|
|
empty ``>`` banner: the cold-started conversation is a HEADLESS RPC
|
|
cascade that does not surface in the agy TUI (established by the cold-start
|
|
spike). The real conversation is that headless RPC one, driven by the web
|
|
UI through the reader + executor — so the TUI looking empty while web
|
|
turns flow is inherent to the RPC model, not a bug.
|
|
|
|
The reader does NOT have refresh-capable auth here: it snapshots
|
|
``headers`` (the local server has none; a remote server's bearer is used
|
|
for its lifetime — ``recover`` refreshes the *attach* headers on reconnect
|
|
but not the reader's client). There is no pre-tool policy audit on this
|
|
path; real-time elicitation is the enforcement surface now.
|
|
|
|
:param base_url: Omnigent server base URL.
|
|
:param headers: HTTP auth headers (mutated in place by ``recover``).
|
|
:param prepared: Prepared terminal details.
|
|
:param recover: Optional async reconnect-recovery callback. ``None``
|
|
disables reconnect (the local-server flow owns the server
|
|
lifecycle and has nothing to reconnect to).
|
|
:returns: None after the attach exits.
|
|
"""
|
|
reader: asyncio.Task[None] | None = None
|
|
cold_start: asyncio.Task[None] | None = None
|
|
if not prepared.reattached:
|
|
reader = asyncio.create_task(
|
|
run_reader_with_bridge(
|
|
base_url=base_url,
|
|
headers=headers,
|
|
# The CLI has no refresh-capable auth flow (the bearer in
|
|
# ``headers``, if any, is used as-is); only the runner threads an
|
|
# ``httpx.Auth``.
|
|
auth=None,
|
|
session_id=prepared.session_id,
|
|
bridge_dir=prepared.bridge_dir,
|
|
),
|
|
name="antigravity-native-rpc-reader",
|
|
)
|
|
# Scope the StartCascade port to THIS session's pane agy so a multi-agy
|
|
# host cannot cross-bind to a foreign agy — but ONLY when the tmux socket
|
|
# exists on THIS host. A remote runner advertises a server-side socket
|
|
# PATH that is not local; running ``tmux -S <remote-path> display-message``
|
|
# against it would fail on every poll (~80 doomed spawns over the budget),
|
|
# so gate on local existence (mirroring ``_can_attach_direct_tmux``) and
|
|
# route the remote case to the no-pane -> candidate fallback instead.
|
|
pane_local = (
|
|
prepared.tmux_socket is not None
|
|
and prepared.tmux_target is not None
|
|
and prepared.tmux_socket.exists()
|
|
)
|
|
cold_start = asyncio.create_task(
|
|
_cold_start_agy_conversation(
|
|
prepared.bridge_dir,
|
|
prepared.session_id,
|
|
base_url=base_url,
|
|
headers=headers,
|
|
tmux_socket=prepared.tmux_socket if pane_local else None,
|
|
tmux_target=prepared.tmux_target if pane_local else None,
|
|
),
|
|
name="antigravity-native-cold-start",
|
|
)
|
|
outcome = _AttachOutcome.EXITED
|
|
try:
|
|
if _can_attach_direct_tmux(prepared):
|
|
if prepared.tmux_socket is None or prepared.tmux_target is None:
|
|
raise click.ClickException("Antigravity tmux attach metadata was incomplete.")
|
|
outcome = await _attach_direct_tmux(prepared.tmux_socket, prepared.tmux_target)
|
|
else:
|
|
outcome = 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,
|
|
close_attach_on_terminal_gone=True,
|
|
)
|
|
finally:
|
|
for task in (cold_start, reader):
|
|
if task is not None:
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
if not prepared.reattached and outcome is not _AttachOutcome.DETACHED:
|
|
await _close_antigravity_terminal(
|
|
base_url=base_url,
|
|
headers=headers,
|
|
session_id=prepared.session_id,
|
|
terminal_id=prepared.terminal_id,
|
|
)
|
|
|
|
|
|
# Cold-start port-discovery budget for the CLI fallback. agy's connect-RPC server
|
|
# binds its loopback port a moment AFTER the process starts (per-process, BEFORE
|
|
# any conversation exists), and on this path agy only starts when the attach opens
|
|
# its pane — so the bootstrap polls. The wait is bounded so a never-binding agy
|
|
# cannot pin the task; the reader's own discovery keeps polling afterward as a
|
|
# fallback. Mirrors the runner's ``_AGY_COLD_START_PORT_TIMEOUT_S``.
|
|
_AGY_COLD_START_PORT_TIMEOUT_S = 20.0
|
|
_AGY_COLD_START_PORT_POLL_INTERVAL_S = 0.25
|
|
|
|
|
|
async def _agy_cold_start_poll_sleep(seconds: float) -> None:
|
|
"""
|
|
Sleep between agy cold-start port-discovery polls.
|
|
|
|
Indirection point so tests can stub the poll backoff without patching the
|
|
process-wide ``asyncio.sleep``. Mirrors
|
|
:func:`omnigent.runner.app._agy_cold_start_poll_sleep`.
|
|
|
|
:param seconds: Seconds to wait before the next port probe, e.g. ``0.25``.
|
|
:returns: None.
|
|
"""
|
|
await asyncio.sleep(seconds)
|
|
|
|
|
|
async def _cold_start_agy_conversation(
|
|
bridge_dir: Path,
|
|
session_id: str,
|
|
*,
|
|
base_url: str,
|
|
headers: dict[str, str],
|
|
tmux_socket: Path | None = None,
|
|
tmux_target: str | None = None,
|
|
timeout_s: float = _AGY_COLD_START_PORT_TIMEOUT_S,
|
|
) -> None:
|
|
"""
|
|
Cold-start agy's conversation over connect-RPC for the CLI fallback (best-effort).
|
|
|
|
The CLI-fallback analogue of the runner's
|
|
:func:`omnigent.runner.app._cold_start_agy_conversation`: once agy is live
|
|
(the attach started its pane), mint a real cascade over ``StartCascade`` and
|
|
write that id into bridge state, replacing the ``agy_conv_*`` placeholder
|
|
:func:`_launch_and_record` seeded — so the RPC reader binds the real
|
|
conversation and web turns resolve, instead of the reader polling the
|
|
placeholder forever. The connect-RPC port is resolved by
|
|
:func:`omnigent.antigravity_native_rpc.resolve_cold_start_agy_rpc_port`:
|
|
scoped to THIS session's own agy via its tmux pane (``tmux_socket`` /
|
|
``tmux_target``) so a host running several agy instances cannot
|
|
``StartCascade`` onto a FOREIGN agy (the conversation-ownership check that
|
|
normally disambiguates is not usable yet — no conversation exists). Crucially
|
|
on this CLI path the terminal uses ``tmux_start_on_attach=True``, so agy is
|
|
not ``exec``-ed until the human attaches while this poll runs concurrently:
|
|
until our agy appears in the pane the resolver returns no port and this KEEPS
|
|
POLLING rather than falling back to a candidate (which could be a foreign
|
|
agy). It falls back to the lowest ``Heartbeat``-answering candidate only when
|
|
no local pane was supplied, or once our agy is up but its port is not
|
|
lsof-attributable. This polls that resolver until a port binds, then
|
|
``StartCascade``s a generated ``uuid4``.
|
|
|
|
The cold-started id is also PATCHed onto the Omnigent session as
|
|
``external_session_id`` (best-effort, mirroring the runner cold-start and
|
|
codex/pi) so a later ``omnigent antigravity --resume`` reads it back and passes
|
|
``--conversation <id>`` to continue agy's actual conversation — the read-path
|
|
replacement for the retired forwarder's ``_patch_external_session_id``.
|
|
|
|
Resume launches already hold agy's real id (seeded as a non-placeholder
|
|
``conversation_id`` and passed as ``--conversation``), so cold-starting would
|
|
create a second empty conversation and overwrite the resumed id — this no-ops
|
|
when the seeded id is NOT a placeholder (the guard that makes ``--resume``
|
|
actually continue the prior conversation).
|
|
|
|
**Best-effort, never raises.** A failure (no state, no port within
|
|
*timeout_s*, ``StartCascade`` erroring, or the PATCH failing) leaves the
|
|
placeholder; the reader's discovery then binds agy's real id once a turn
|
|
creates the conversation. The sync RPC/poll work runs in
|
|
:func:`asyncio.to_thread` so the event loop is never blocked.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory whose ``state.json``
|
|
the real cold-started id is written into.
|
|
:param session_id: Owning session/conversation id (for log correlation and
|
|
the ``external_session_id`` PATCH target).
|
|
:param base_url: Omnigent server base URL for the ``external_session_id``
|
|
PATCH.
|
|
:param headers: HTTP auth headers for the PATCH (the local server has none; a
|
|
remote server's bearer is used as-is).
|
|
:param tmux_socket: This session's tmux socket path, used to scope the
|
|
``StartCascade`` port to the agy running under this session's pane.
|
|
``None`` (no local pane) falls back to the candidate scan.
|
|
:param tmux_target: This session's tmux target (e.g. ``"main"``), paired with
|
|
``tmux_socket`` for the pane-scoped port resolution.
|
|
:param timeout_s: Total seconds to wait for agy's connect-RPC port to bind.
|
|
:returns: None.
|
|
"""
|
|
state = await asyncio.to_thread(read_bridge_state, bridge_dir)
|
|
if state is None:
|
|
return
|
|
if not is_placeholder_conversation_id(state.conversation_id):
|
|
# Resume: agy's real id is already seeded (and passed as --conversation);
|
|
# cold-starting would create a second empty conversation and clobber the
|
|
# resumed id, defeating --resume.
|
|
return
|
|
|
|
deadline = time.monotonic() + timeout_s
|
|
port: int | None = None
|
|
while True:
|
|
# Scope to THIS session's pane agy (avoids binding a foreign agy on a
|
|
# multi-agy host); falls back to the lowest validated candidate when no
|
|
# local pane is reachable or the pane is not resolvable yet.
|
|
port = await asyncio.to_thread(resolve_cold_start_agy_rpc_port, tmux_socket, tmux_target)
|
|
if port is not None:
|
|
break
|
|
if time.monotonic() >= deadline:
|
|
_logger.warning(
|
|
"Antigravity cold-start: no agy connect-RPC port bound within %.0fs for "
|
|
"session %s; leaving the placeholder for the reader to bind once a turn "
|
|
"creates the conversation.",
|
|
timeout_s,
|
|
session_id,
|
|
)
|
|
return
|
|
await _agy_cold_start_poll_sleep(_AGY_COLD_START_PORT_POLL_INTERVAL_S)
|
|
|
|
cascade_id = str(uuid.uuid4())
|
|
try:
|
|
await asyncio.to_thread(start_cascade, port, cascade_id)
|
|
except AntigravityRpcError:
|
|
_logger.warning(
|
|
"Antigravity cold-start: StartCascade failed on port %s for session %s; leaving "
|
|
"the placeholder for the reader to bind.",
|
|
port,
|
|
session_id,
|
|
exc_info=True,
|
|
)
|
|
return
|
|
# Persist the real id (replacing the placeholder) so ``read_bridge_state``
|
|
# returns it and the reader/executor address the cold-started conversation.
|
|
# Offloaded (file I/O).
|
|
if not await asyncio.to_thread(update_conversation_id, bridge_dir, cascade_id):
|
|
_logger.warning(
|
|
"Antigravity cold-start: could not persist cold-started conversation id %s for "
|
|
"session %s (no bridge state to update); the reader will stay on the placeholder id.",
|
|
cascade_id,
|
|
session_id,
|
|
)
|
|
# Do NOT record this cold-start cascade as the session's external_session_id:
|
|
# it is the headless ``StartCascade`` bootstrap that the agy TUI never shows.
|
|
# The TUI mints its OWN cascade on the first typed turn, which the read driver
|
|
# ADOPTS in place and records as external_session_id
|
|
# (``antigravity_native_reader._record_external_session_id``). Recording the
|
|
# phantom here used to lose the whole conversation on resume (``--resume``
|
|
# launched ``--conversation <phantom>`` -> EMPTY conversation); external_session_id
|
|
# is set-once, so it MUST be left unset here for the reader's adoption to set it.
|
|
del base_url, headers # retained for signature parity; no longer PATCH here
|
|
_logger.info(
|
|
"Antigravity cold-start: created conversation %s on port %s for session %s",
|
|
cascade_id,
|
|
port,
|
|
session_id,
|
|
)
|
|
|
|
|
|
def _can_attach_direct_tmux(prepared: PreparedAntigravityTerminal) -> bool:
|
|
"""
|
|
Return whether this process can attach to the runner tmux directly.
|
|
|
|
``True`` only when the runner advertised a tmux socket + target, the
|
|
socket exists on this host (same machine), and ``tmux`` is on PATH. A
|
|
remote runner's socket won't exist locally, so this returns ``False``
|
|
and the caller falls back to the WebSocket attach. Mirrors
|
|
:func:`omnigent.codex_native._can_attach_direct_tmux`.
|
|
|
|
:param prepared: Prepared terminal details.
|
|
:returns: ``True`` when a direct local tmux attach is possible.
|
|
"""
|
|
return (
|
|
prepared.tmux_socket is not None
|
|
and prepared.tmux_target is not None
|
|
and prepared.tmux_socket.exists()
|
|
and shutil.which("tmux") is not None
|
|
)
|
|
|
|
|
|
async def _attach_direct_tmux(socket_path: Path, tmux_target: str) -> _AttachOutcome:
|
|
"""
|
|
Attach the current terminal directly to the runner-owned tmux pane.
|
|
|
|
Lower latency than the WebSocket PTY relay because there is no server
|
|
round-trip. ``TMUX`` is dropped from the child environment so a user
|
|
who runs ``omnigent antigravity`` from inside their own tmux can still
|
|
attach to Omnigent's private tmux server. After the attach child
|
|
exits, a ``has-session`` probe distinguishes a user *detach* (session
|
|
still alive) from agy *exiting* (session gone).
|
|
|
|
:param socket_path: Runner tmux server socket path.
|
|
:param tmux_target: tmux ``-t`` target to attach, e.g. ``"main"``.
|
|
:returns: :attr:`_AttachOutcome.DETACHED` when the tmux session
|
|
outlives the attach (user detached), else
|
|
:attr:`_AttachOutcome.EXITED`.
|
|
"""
|
|
from omnigent.terminals.ws_bridge import _tmux_session_alive
|
|
|
|
env = os.environ.copy()
|
|
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()
|
|
if await _tmux_session_alive(str(socket_path), tmux_target):
|
|
return _AttachOutcome.DETACHED
|
|
return _AttachOutcome.EXITED
|
|
|
|
|
|
async def _create_antigravity_session(
|
|
client: httpx.AsyncClient,
|
|
bundle: bytes,
|
|
*,
|
|
bridge_id: str,
|
|
) -> str:
|
|
"""
|
|
Create a bundled terminal-first Antigravity session.
|
|
|
|
Stamps the wrapper + terminal-UI labels and the bridge-id label.
|
|
``external_session_id`` is left unset here: agy mints its own UUID and ignores
|
|
any id the launcher assigns, so the real id is established at runtime by the
|
|
cold-start (:func:`_cold_start_agy_conversation`), which writes it to bridge
|
|
state AND PATCHes it onto the session as ``external_session_id`` — the
|
|
read-path replacement for the retired forwarder's id capture, so a later
|
|
``--resume`` continues agy's actual conversation.
|
|
|
|
:param client: HTTP client pointed at the Omnigent server.
|
|
:param bundle: Gzipped Antigravity wrapper agent bundle.
|
|
:param bridge_id: Opaque bridge id to write on the session labels.
|
|
:returns: New Omnigent session id, e.g. ``"conv_abc123"``.
|
|
:raises click.ClickException: If creation fails.
|
|
"""
|
|
labels = dict(_SESSION_LABELS)
|
|
labels[ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY] = bridge_id
|
|
metadata: dict[str, object] = {"labels": labels}
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
data={"metadata": json.dumps(metadata)},
|
|
files={"bundle": ("antigravity-native-ui.tar.gz", bundle, "application/gzip")},
|
|
timeout=120.0,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise click.ClickException(
|
|
f"Antigravity 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(
|
|
"Antigravity session creation response did not include session_id."
|
|
)
|
|
return new_session_id
|
|
|
|
|
|
async def _fetch_antigravity_session(
|
|
client: httpx.AsyncClient, session_id: str
|
|
) -> dict[str, object]:
|
|
"""
|
|
Fetch an existing Omnigent session snapshot.
|
|
|
|
:param client: HTTP client pointed at the Omnigent server.
|
|
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
|
|
:returns: Decoded session payload.
|
|
:raises click.ClickException: If the lookup fails or returns non-object JSON.
|
|
"""
|
|
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
|
|
|
|
|
|
async def _launch_antigravity_terminal(
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
*,
|
|
argv: list[str],
|
|
env: dict[str, str],
|
|
command: str,
|
|
) -> LaunchedAntigravityTerminal:
|
|
"""
|
|
Launch the server-backed agy terminal resource.
|
|
|
|
:param client: HTTP client pointed at the Omnigent server.
|
|
:param session_id: Omnigent session id.
|
|
:param argv: Full agy command list from :func:`build_agy_launch`. The
|
|
first element is the agy binary; the rest are its args.
|
|
:param env: Environment overrides for the terminal process from
|
|
:func:`build_agy_launch`.
|
|
:param command: agy executable to run (matches ``argv[0]``).
|
|
:returns: Launched terminal resource details.
|
|
:raises click.ClickException: If the terminal launch fails.
|
|
"""
|
|
spec: dict[str, object] = {
|
|
"command": command,
|
|
"args": list(argv[1:]),
|
|
"os_env_type": "caller_process",
|
|
# Pin the terminal cwd to the user's launch directory. This IS agy's
|
|
# workspace: agy runs its tools in the process cwd (verified
|
|
# empirically — without it, tools run in agy's default ``scratch`` dir),
|
|
# so no ``--add-dir`` flag is needed. The runner is local, so
|
|
# ``Path.cwd()`` here equals the runner workspace. See the same comment
|
|
# in ``claude_native._claude_terminal_request``.
|
|
"cwd": str(Path.cwd().resolve()),
|
|
"env": env,
|
|
"scrollback": _ANTIGRAVITY_TERMINAL_SCROLLBACK_LINES,
|
|
"tmux_allow_passthrough": True,
|
|
"tmux_start_on_attach": True,
|
|
}
|
|
body = {
|
|
"terminal": _TERMINAL_NAME,
|
|
"session_key": _TERMINAL_SESSION_KEY,
|
|
"spec": spec,
|
|
# Native-bootstrap allowlist marker only: it lets the server's
|
|
# create-terminal gate admit this undeclared terminal name (see
|
|
# ``omnigent/server/routes/sessions.py`` ``is_native_bootstrap``).
|
|
#
|
|
# Deliberately NOT ``bridge_inject_dir``: on the runner, that marker
|
|
# triggers Claude-native machinery — it starts the Claude comment relay,
|
|
# tags the terminal ``CLAUDE_NATIVE_TERMINAL_ROLE`` (which drives the
|
|
# session's PTY-derived working status), and publishes Claude tmux
|
|
# metadata. None of that is owned by antigravity teardown, and
|
|
# antigravity derives its working status from the RPC reader's
|
|
# ``external_session_status`` edges, not PTY activity.
|
|
# ``ensure_native_terminal`` is allowlisted the same
|
|
# way but the runner's claude/codex ``ensure`` branches are gated on
|
|
# those terminal names, so for ``antigravity`` it falls through to the
|
|
# plain generic launch with no Claude side effects. The antigravity
|
|
# harness reads its bridge dir from its own spawn env
|
|
# (``build_antigravity_native_spawn_env``), so no terminal-launch bridge
|
|
# injection is needed.
|
|
"ensure_native_terminal": 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"Antigravity terminal launch failed ({resp.status_code}): {error_text(resp)}"
|
|
)
|
|
return _launched_antigravity_terminal_from_payload(resp.json())
|
|
|
|
|
|
def _launched_antigravity_terminal_from_payload(payload: object) -> LaunchedAntigravityTerminal:
|
|
"""
|
|
Decode terminal launch metadata returned by the runner.
|
|
|
|
:param payload: Decoded terminal resource JSON object, e.g.
|
|
``{"id": "terminal_antigravity_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("Antigravity terminal launch returned non-object JSON.")
|
|
terminal_id = payload.get("id")
|
|
if not isinstance(terminal_id, str) or not terminal_id:
|
|
raise click.ClickException(
|
|
"Antigravity 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 LaunchedAntigravityTerminal(
|
|
terminal_id=terminal_id,
|
|
tmux_socket=tmux_socket,
|
|
tmux_target=tmux_target,
|
|
)
|
|
|
|
|
|
async def _find_running_antigravity_terminal(
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
) -> LaunchedAntigravityTerminal | None:
|
|
"""
|
|
Return the existing running agy terminal id if present.
|
|
|
|
:param client: HTTP client pointed at the Omnigent server.
|
|
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
|
|
:returns: Terminal details, or ``None`` when the wrapper should launch
|
|
a new terminal (missing, stopped, or runner unavailable).
|
|
:raises click.ClickException: If the server rejects the lookup for a
|
|
reason other than "not currently attachable".
|
|
"""
|
|
terminal_id = antigravity_terminal_resource_id()
|
|
resp = await client.get(
|
|
f"/v1/sessions/{url_component(session_id)}"
|
|
f"/resources/terminals/{url_component(terminal_id)}"
|
|
)
|
|
if resp.status_code in {404, 409, 502, 503}:
|
|
return None
|
|
if resp.status_code >= 400:
|
|
raise click.ClickException(
|
|
f"Failed to fetch Antigravity 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_antigravity_terminal_from_payload(payload)
|
|
|
|
|
|
async def _close_antigravity_terminal(
|
|
*,
|
|
base_url: str,
|
|
headers: dict[str, str],
|
|
session_id: str,
|
|
terminal_id: str,
|
|
) -> None:
|
|
"""
|
|
Best-effort close of the AP-side agy 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.
|
|
"""
|
|
try:
|
|
async with httpx.AsyncClient(
|
|
base_url=base_url,
|
|
headers=headers,
|
|
timeout=httpx.Timeout(10.0),
|
|
) as client:
|
|
response = await client.delete(
|
|
f"/v1/sessions/{url_component(session_id)}"
|
|
f"/resources/terminals/{url_component(terminal_id)}"
|
|
)
|
|
if response.status_code >= 400:
|
|
_logger.warning(
|
|
"agy terminal close returned %s: session=%s terminal=%s",
|
|
response.status_code,
|
|
session_id,
|
|
terminal_id,
|
|
)
|
|
except (httpx.HTTPError, OSError) as exc:
|
|
# Best-effort teardown: a transport/OS failure must not mask the exit
|
|
# path, but log it so a leaked terminal is diagnosable. A programmer
|
|
# error (e.g. a malformed URL) propagates instead of being silently eaten.
|
|
_logger.warning(
|
|
"agy terminal close failed: session=%s terminal=%s error=%r",
|
|
session_id,
|
|
terminal_id,
|
|
exc,
|
|
)
|
|
|
|
|
|
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 antigravity-native session id.
|
|
|
|
:param base_url: Omnigent server base URL.
|
|
:param headers: HTTP auth headers; ``{}`` for the local server.
|
|
: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 antigravity-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 _mint_agy_conversation_id() -> str:
|
|
"""
|
|
Mint a fresh agy conversation id for a new session.
|
|
|
|
:returns: An ``"agy_conv_<hex>"`` id, e.g.
|
|
``"agy_conv_5e1f...".``
|
|
"""
|
|
return f"{AGY_PLACEHOLDER_CONVERSATION_PREFIX}{uuid.uuid4().hex}"
|
|
|
|
|
|
def antigravity_terminal_resource_id() -> str:
|
|
"""
|
|
Return the deterministic terminal resource id for Antigravity.
|
|
|
|
:returns: Terminal resource id, e.g. ``"terminal_antigravity_main"``.
|
|
"""
|
|
return terminal_resource_id(_TERMINAL_NAME, _TERMINAL_SESSION_KEY)
|
|
|
|
|
|
def _update_progress(
|
|
startup_progress: RunnerStartupProgress | None,
|
|
message: str,
|
|
) -> None:
|
|
"""
|
|
Show one concise startup milestone when a renderer is active.
|
|
|
|
:param startup_progress: Optional progress renderer.
|
|
:param message: User-facing status text, e.g.
|
|
``"Starting Antigravity terminal..."``.
|
|
:returns: None.
|
|
"""
|
|
if startup_progress is not None:
|
|
startup_progress.update(message)
|
|
|
|
|
|
def _preflight_local_tools() -> None:
|
|
"""
|
|
Verify local executables required by the native Antigravity 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 Antigravity wrapper "
|
|
"attaches to the runner-owned agy tmux terminal."
|
|
)
|
|
|
|
|
|
def _launch_is_headless() -> bool:
|
|
"""
|
|
Return whether this agy launch is headless (no interactive client attaches).
|
|
|
|
``omnigent antigravity`` attaches the local TTY to the agy tmux terminal so
|
|
the user drives agy interactively. agy's default ``request-review``
|
|
permission prompt is fine for that attended case, but it would **hang an
|
|
unattended/headless turn forever** waiting for a terminal answer (sandbox /
|
|
autonomous / detached / piped invocation). The standard CLI signal for "an
|
|
interactive client will attach" is a controlling terminal on both stdin and
|
|
stdout; when either is not a TTY (CI, ``nohup``, a pipe, a detached spawn)
|
|
the launch is treated as headless so the caller can auto-bypass agy's prompt
|
|
(see :func:`omnigent.antigravity_native_launch.should_skip_permissions`).
|
|
|
|
.. note:: This TTY signal governs ONLY the human-invoked CLI launch path
|
|
(``run_antigravity_native`` → here, the single call site). The
|
|
server-spawned / web-attached path
|
|
(:func:`omnigent.runner.app._auto_create_antigravity_terminal`, the
|
|
claude/codex auto-create analogue) does NOT consult this function — it
|
|
passes ``headless=False`` to ``build_agy_launch`` directly, because the
|
|
web client attaches to the agy pane through the runner tunnel and answers
|
|
agy's ``request-review`` prompt there. **Keep that invariant:** a
|
|
server-spawned launch must never key headlessness on the runner process's
|
|
(absent) controlling TTY, which would conflate "no CLI tty" with "no
|
|
client attached" and silently disable agy's per-tool prompt for a watching
|
|
web user.
|
|
|
|
:returns: ``True`` when no interactive terminal is attached (headless).
|
|
"""
|
|
try:
|
|
return not (sys.stdin.isatty() and sys.stdout.isatty())
|
|
except (ValueError, OSError):
|
|
# A closed/detached stream raises rather than returning False; treat any
|
|
# such failure as "no interactive client" — the safe, non-hanging choice.
|
|
return True
|