1479 lines
66 KiB
Python
1479 lines
66 KiB
Python
"""Bridge state for native Antigravity TUI sessions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import secrets
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY = "omnigent.antigravity_native.bridge_id"
|
|
ANTIGRAVITY_NATIVE_BRIDGE_DIR_ENV_VAR = "HARNESS_ANTIGRAVITY_NATIVE_BRIDGE_DIR"
|
|
ANTIGRAVITY_NATIVE_REQUEST_SESSION_ID_ENV_VAR = "HARNESS_ANTIGRAVITY_NATIVE_REQUEST_SESSION_ID"
|
|
|
|
_STATE_FILE = "state.json"
|
|
# Advertises the runner-owned tmux pane (socket + target) hosting agy, so the
|
|
# executor can deliver a FIRST web turn into the idle agy TUI via send-keys (see
|
|
# the tmux/TUI section at the end of this module). Written by the runner after
|
|
# the agy terminal launches; read by the executor's first-turn bootstrap.
|
|
_TMUX_FILE = "tmux.json"
|
|
_BRIDGE_ROOT = Path.home() / ".omnigent" / "antigravity-native"
|
|
|
|
# Prefix of the launcher-minted placeholder conversation id (see
|
|
# ``antigravity_native._mint_agy_conversation_id``). agy mints its own real
|
|
# UUID, so the placeholder only seeds bridge state until the forwarder discovers
|
|
# and overwrites it; consumers (e.g. the executor) must treat a placeholder id as
|
|
# "not ready yet" rather than resolving an RPC against it.
|
|
AGY_PLACEHOLDER_CONVERSATION_PREFIX = "agy_conv_"
|
|
|
|
|
|
def is_placeholder_conversation_id(conversation_id: str) -> bool:
|
|
"""Return whether *conversation_id* is the launcher placeholder, not agy's real id.
|
|
|
|
:param conversation_id: A bridge-state conversation id.
|
|
:returns: ``True`` when it is an ``agy_conv_*`` placeholder (the forwarder has
|
|
not yet discovered agy's real UUID); ``False`` for a real agy id.
|
|
"""
|
|
return conversation_id.startswith(AGY_PLACEHOLDER_CONVERSATION_PREFIX)
|
|
|
|
|
|
# Canonical root of agy's per-user app-data tree (``~/.gemini/antigravity-cli``).
|
|
# Single-sourced here and imported by the forwarder (transcript / brain
|
|
# discovery) so a change to agy's data-dir layout is a one-line edit. (The
|
|
# onboarding/auth layer keeps its own ``~/.gemini`` OAuth root in ``gemini_auth``
|
|
# — a broader, distinct concern that must not depend on this harness module.)
|
|
AGY_APP_DATA_DIR = Path.home() / ".gemini" / "antigravity-cli"
|
|
|
|
# agy persists onboarding completion in this HOME-global cache file. On a first
|
|
# run where it is absent, agy launches an interactive TUI onboarding wizard
|
|
# (login method, then color-scheme / telemetry steps) that has no pre-emptive
|
|
# flag and no PermissionRequest-style hook. On a host-spawned (web-driven) or
|
|
# otherwise headless launch there is no TTY to answer it, so agy hangs and the
|
|
# web UI shows nothing. Seeding ``onboardingComplete`` here suppresses the
|
|
# wizard, mirroring how ``claude_native_bridge.ensure_claude_workspace_trusted``
|
|
# pre-accepts Claude's ``hasCompletedOnboarding`` gate. The agy OAuth token is a
|
|
# separate per-host secret (seeded outside this code path); this file carries no
|
|
# credential — only the three onboarding-state booleans.
|
|
_AGY_ONBOARDING_MARKER = AGY_APP_DATA_DIR / "cache" / "onboarding.json"
|
|
# The exact keys agy itself writes on a completed consumer (subscription)
|
|
# onboarding — captured ground-truth from a real onboarded profile. Enterprise
|
|
# onboarding is a distinct flow Omnigent does not drive, so it stays ``False``.
|
|
_AGY_ONBOARDING_COMPLETE_STATE: dict[str, object] = {
|
|
"consumerOnboardingComplete": True,
|
|
"enterpriseOnboardingComplete": False,
|
|
"onboardingComplete": True,
|
|
}
|
|
|
|
|
|
def ensure_agy_onboarding_complete() -> None:
|
|
"""Pre-accept agy's first-run onboarding wizard so a headless launch never blocks.
|
|
|
|
Idempotently seeds ``onboardingComplete`` (and the sibling consumer/enterprise
|
|
flags) into agy's ``~/.gemini/antigravity-cli/cache/onboarding.json`` so the
|
|
interactive TUI onboarding wizard does not stall a host-spawned or headless
|
|
``agy`` launch that has no TTY to answer it. Call once before launching agy
|
|
(see :func:`omnigent.runner.app._auto_create_antigravity_terminal` and the CLI
|
|
``_launch_and_record`` path).
|
|
|
|
Any unrecognised keys already in the file are preserved (the three known keys
|
|
are merged over them), and the write is skipped entirely when all three
|
|
already hold their exact boolean values — so a returning user's agy state is
|
|
never churned. Unlike
|
|
``~/.claude.json`` (which holds the Claude OAuth account block and is treated
|
|
fail-loud), this file is a regenerable, non-secret agy cache: an existing
|
|
file that is unreadable or not a JSON object is treated as absent and
|
|
overwritten with the known-complete state rather than raising.
|
|
|
|
:returns: None.
|
|
:raises OSError: If the marker directory cannot be created or the file cannot
|
|
be written (e.g. an unwritable home directory). Surfaced rather than
|
|
swallowed: a missing marker means agy will hang on the wizard, so a write
|
|
failure is a real, launch-blocking fault worth failing loudly on.
|
|
"""
|
|
marker = _AGY_ONBOARDING_MARKER
|
|
existing: dict[str, object] = {}
|
|
if marker.is_file():
|
|
try:
|
|
loaded = json.loads(marker.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
# Regenerable non-secret cache: an unreadable (OSError), non-UTF-8
|
|
# (UnicodeDecodeError) or malformed-JSON (json.JSONDecodeError) marker
|
|
# would make agy re-run onboarding anyway, so overwrite from an empty
|
|
# base. ValueError is the common supertype of both decode failures.
|
|
loaded = None
|
|
if isinstance(loaded, dict):
|
|
existing = loaded
|
|
# Type-strict identity (``is``, not ``==``) so a stored numeric ``1``/``0``
|
|
# (which ``==`` True/False in Python) is NOT accepted as the boolean state but
|
|
# is normalised on write — matching this module's ``read_bridge_state``, which
|
|
# likewise rejects bool/int conflation.
|
|
if all(existing.get(key) is value for key, value in _AGY_ONBOARDING_COMPLETE_STATE.items()):
|
|
return
|
|
merged: dict[str, object] = {**existing, **_AGY_ONBOARDING_COMPLETE_STATE}
|
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp_name = tempfile.mkstemp(prefix=f"{marker.name}.", dir=str(marker.parent))
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(merged, handle, sort_keys=True)
|
|
handle.write("\n")
|
|
# fsync before the atomic replace so a crash/power-loss cannot leave a
|
|
# present-but-empty marker that would send agy back into the wizard
|
|
# (matches ``claude_native_bridge._atomic_write_user_json``).
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(tmp_name, marker)
|
|
finally:
|
|
if os.path.exists(tmp_name):
|
|
os.unlink(tmp_name)
|
|
_logger.info("Seeded agy onboarding-complete marker at %s", marker)
|
|
|
|
|
|
def bridge_root() -> Path:
|
|
"""
|
|
Return the configured Antigravity-native bridge root.
|
|
|
|
Tests may monkeypatch :data:`_BRIDGE_ROOT` to isolate bridge files.
|
|
|
|
:returns: Absolute root for Antigravity-native bridge directories, e.g.
|
|
``Path("~/.omnigent/antigravity-native")``.
|
|
"""
|
|
return _BRIDGE_ROOT
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AntigravityNativeBridgeState:
|
|
"""
|
|
Runtime state shared by the native Antigravity wrapper and harness.
|
|
|
|
:param session_id: Omnigent conversation id, e.g.
|
|
``"conv_abc123"``.
|
|
:param conversation_id: agy's real conversation id, e.g.
|
|
``"68caaeac-2eaf-4e2c-9b95-721b022f4903"``. Discovered by the forwarder
|
|
via by-pid ownership of this session's agy process (agy mints its own
|
|
UUID and ignores any ``ANTIGRAVITY_CONVERSATION_ID`` the launcher sets).
|
|
The launcher seeds this with its minted ``agy_conv_*`` placeholder, which
|
|
the forwarder overwrites once it discovers the real id.
|
|
:param active_turn_id: Current agy turn id, if one is running,
|
|
e.g. ``"turn_abc123"``.
|
|
|
|
.. note::
|
|
There is intentionally no durable read cursor. The retired transcript
|
|
forwarder persisted a ``forwarded_steps`` set (+ a ``forwarded_step_index``
|
|
mirror) so a tail (re)start did not re-mirror the whole JSONL. The RPC read
|
|
driver that superseded it (:mod:`omnigent.antigravity_native_reader`) keeps
|
|
an *in-memory* seen-set only and is recreated per session by the runner, so
|
|
no on-disk cursor is needed. Legacy ``forwarded_step_index`` /
|
|
``forwarded_steps`` keys in an on-disk ``state.json`` are tolerated and
|
|
ignored on read.
|
|
|
|
.. note::
|
|
There is intentionally no ``agy_pid`` field. agy's pid is not stable to
|
|
capture at launch — the CLI terminal uses ``tmux_start_on_attach=True``
|
|
(agy does not exist until the human TTY attaches), and even the
|
|
runner-owned web terminal (``tmux_start_on_attach=False``, agy started
|
|
immediately) re-execs/supervises, so a captured pid can go stale. The
|
|
executor instead discovers agy's connect-RPC port at injection time by
|
|
enumerating agy processes and validating each against
|
|
:attr:`conversation_id` via ``GetConversationMetadata`` (see
|
|
:func:`omnigent.antigravity_native_rpc.resolve_language_server_port`).
|
|
A legacy ``agy_pid`` key in an on-disk ``state.json`` is tolerated and
|
|
ignored on read.
|
|
"""
|
|
|
|
session_id: str
|
|
conversation_id: str
|
|
active_turn_id: str | None = None
|
|
|
|
|
|
def bridge_dir_for_bridge_id(bridge_id: str) -> Path:
|
|
"""
|
|
Return the bridge directory for a native Antigravity bridge id.
|
|
|
|
:param bridge_id: Opaque bridge id, e.g. ``"bridge_abc123"``.
|
|
:returns: Absolute bridge directory under
|
|
``~/.omnigent/antigravity-native``.
|
|
"""
|
|
digest = hashlib.sha256(bridge_id.encode("utf-8")).hexdigest()[:32]
|
|
return _BRIDGE_ROOT / digest
|
|
|
|
|
|
def build_antigravity_native_spawn_env(
|
|
conversation_id: str,
|
|
*,
|
|
bridge_id: str | None = None,
|
|
) -> dict[str, str]:
|
|
"""
|
|
Build spawn env for the ``antigravity-native`` harness process.
|
|
|
|
:param conversation_id: Omnigent conversation id, e.g.
|
|
``"conv_abc123"``.
|
|
:param bridge_id: Opaque bridge id from
|
|
:data:`ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY`, e.g.
|
|
``"bridge_abc123"``. ``None`` uses *conversation_id*.
|
|
:returns: Environment variables needed by the Antigravity-native
|
|
harness executor.
|
|
"""
|
|
resolved_bridge_id = bridge_id or conversation_id
|
|
return {
|
|
ANTIGRAVITY_NATIVE_BRIDGE_DIR_ENV_VAR: str(bridge_dir_for_bridge_id(resolved_bridge_id)),
|
|
ANTIGRAVITY_NATIVE_REQUEST_SESSION_ID_ENV_VAR: conversation_id,
|
|
}
|
|
|
|
|
|
def prepare_bridge_dir(bridge_id: str) -> Path:
|
|
"""
|
|
Create the bridge directory for *bridge_id*.
|
|
|
|
:param bridge_id: Opaque bridge id, e.g. ``"bridge_abc123"``.
|
|
:returns: Prepared absolute bridge directory.
|
|
"""
|
|
bridge_dir = bridge_dir_for_bridge_id(bridge_id)
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
os.chmod(bridge_dir, 0o700)
|
|
return bridge_dir
|
|
|
|
|
|
# ── Omnigent MCP relay wiring (sys_* tools) ──────────────────────────────────
|
|
#
|
|
# agy is otherwise tool-isolated from Omnigent: unlike claude/codex/cursor it
|
|
# wires no MCP relay, so the wrapped agy cannot reach any ``sys_*`` tool (spawn
|
|
# sub-agent sessions, drive Omnigent terminals, list agents/models, ``sys_os_*``).
|
|
# This section wires the SAME relay cursor #742 uses — the shared
|
|
# ``omnigent.claude_native_bridge serve-mcp`` stdio server — into agy.
|
|
#
|
|
# THE CONFIG-SCOPING FOOTGUN. agy has no ``--mcp-config`` flag and ignores every
|
|
# ``ANTIGRAVITY_*`` env knob; it loads MCP servers from a single HOME-GLOBAL file,
|
|
# ``$HOME/.gemini/config/mcp_config.json`` — the *same* file the user's
|
|
# interactive agy reads (verified empirically against agy 1.0.12). Writing that
|
|
# file directly would (a) clobber/pollute the user's interactive agy config and
|
|
# (b) be incorrect under concurrency: the relay command is bridge-dir-specific, so
|
|
# two antigravity-native sessions (or a session + the user's interactive agy)
|
|
# would fight over the one global file.
|
|
#
|
|
# CHOSEN DESIGN — per-session ISOLATED GEMINI DIR. The runner keeps agy's real
|
|
# ``HOME`` intact (needed for platform auth such as macOS keyring-backed tokens)
|
|
# and launches agy with its hidden ``--gemini_dir=<bridge_dir>/agy-home/.gemini``
|
|
# flag. That isolated Gemini dir is seeded with onboarding/migration markers and
|
|
# a bridge-scoped ``config/mcp_config.json`` written by :func:`write_mcp_config`.
|
|
# This (1) NEVER touches the user's real ``~/.gemini/config/mcp_config.json`` and
|
|
# (2) gives each session its own config so concurrent sessions never clobber one
|
|
# another. Known file-based OAuth markers are copied best-effort for platforms
|
|
# whose agy auth provider uses them, but HOME is deliberately not relocated.
|
|
#
|
|
# WHY NOT RELOCATE HOME (#1477). An earlier isolation design pointed ``HOME`` at
|
|
# the per-session tree. That broke auth on macOS: agy stores its OAuth token in
|
|
# the OS keyring (verified against agy 1.0.12 — the binary's auth path is
|
|
# ``keyring`` / "load token from keyring", NOT a ``~/.gemini`` file), and the
|
|
# keyring item is bound to the real login HOME, so any relocated HOME stalled
|
|
# every turn at the interactive OAuth prompt. The alternative of dropping HOME
|
|
# isolation entirely on macOS (#1493) restored auth but reintroduced the
|
|
# HOME-global ``mcp_config.json`` footgun (#1194) there — concurrent sessions
|
|
# would clobber one another's relay config. ``--gemini_dir`` resolves both at
|
|
# once: real HOME ⇒ keyring auth works on every platform; isolated gemini dir ⇒
|
|
# per-session MCP config. Verified live (agy 1.0.12): ``agy --gemini_dir=<dir>``
|
|
# materializes its ``antigravity-cli`` + ``config`` state under ``<dir>`` while
|
|
# authenticating from the real-HOME keyring.
|
|
_MCP_CONFIG_DIR = "config"
|
|
_MCP_CONFIG_FILE = "mcp_config.json"
|
|
_BRIDGE_CONFIG_FILE = "bridge.json"
|
|
_MCP_SERVER_NAME = "omnigent"
|
|
# agy auto-approves a relay tool when it is named in the server's ``enabledTools``
|
|
# allowlist (agy's per-server MCP schema; mirrors cursor's ``autoApprove``).
|
|
# Omnigent's own TOOL_CALL policy + elicitation gate still applies on the server
|
|
# side, so auto-approving the agy-side MCP gate only avoids a hidden in-terminal
|
|
# prompt blocking the call before Omnigent ever sees it.
|
|
_AGY_ENABLED_TOOLS = [
|
|
"list_comments",
|
|
"sys_add_policy",
|
|
"sys_agent_download",
|
|
"sys_agent_get",
|
|
"sys_agent_list",
|
|
"sys_call_async",
|
|
"sys_cancel_async",
|
|
"sys_cancel_task",
|
|
"sys_list_models",
|
|
"sys_os_edit",
|
|
"sys_os_read",
|
|
"sys_os_shell",
|
|
"sys_os_write",
|
|
"sys_policy_registry",
|
|
"sys_session_close",
|
|
"sys_session_create",
|
|
"sys_session_get_history",
|
|
"sys_session_get_info",
|
|
"sys_session_list",
|
|
"sys_session_send",
|
|
"sys_terminal_close",
|
|
"sys_terminal_launch",
|
|
"sys_terminal_list",
|
|
"sys_terminal_read",
|
|
"sys_terminal_send",
|
|
"update_comment",
|
|
]
|
|
|
|
# Files copied from the user's real ``~/.gemini`` into the per-session isolated
|
|
# Gemini dir for platforms whose agy auth provider uses file-backed credentials,
|
|
# plus installation markers. Keep the OAuth credential paths in sync with
|
|
# ``omnigent.onboarding.gemini_auth``: macOS writes ``oauth_creds.json`` while
|
|
# Linux writes ``antigravity-cli/antigravity-oauth-token``. The OAuth credential
|
|
# is the only secret; it is COPIED (the real file is never moved or modified).
|
|
# Missing files are skipped (agy regenerates onboarding/migration state, and a
|
|
# missing credential only means agy re-auths in that session — never corruption
|
|
# of the real tree).
|
|
_AGY_SEED_FILES = (
|
|
Path("oauth_creds.json"),
|
|
Path("antigravity-cli") / "antigravity-oauth-token",
|
|
Path("installation_id"),
|
|
Path("antigravity-cli") / "installation_id",
|
|
)
|
|
|
|
|
|
def agy_home_dir(bridge_dir: Path) -> Path:
|
|
"""Return the parent directory for this session's isolated agy state.
|
|
|
|
The actual agy config/state root is :func:`agy_gemini_dir`, passed to agy via
|
|
``--gemini_dir``. Keeping this parent below *bridge_dir* makes it naturally
|
|
per-session and easy to tear down with the bridge, while preserving the real
|
|
``HOME`` for auth providers that depend on platform state such as macOS
|
|
Keychain.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory.
|
|
:returns: Absolute parent path for this session's isolated agy state.
|
|
"""
|
|
return bridge_dir / "agy-home"
|
|
|
|
|
|
def agy_gemini_dir(bridge_dir: Path) -> Path:
|
|
"""Return the per-session ``--gemini_dir`` path for an agy launch.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory.
|
|
:returns: Absolute ``.gemini`` directory that should receive agy's session
|
|
config/state and the Omnigent MCP config.
|
|
"""
|
|
return agy_home_dir(bridge_dir) / ".gemini"
|
|
|
|
|
|
def build_mcp_config(
|
|
bridge_dir: Path,
|
|
*,
|
|
python_executable: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build agy's ``mcp_config.json`` payload for the Omnigent relay server.
|
|
|
|
Mirrors cursor #742's :func:`omnigent.cursor_native_bridge.build_mcp_config`
|
|
but emits agy's per-server MCP schema (lowercase ``command`` / ``args`` /
|
|
``env`` keys plus ``enabledTools``, agy's auto-approve allowlist — verified
|
|
against agy 1.0.12's config struct) under the top-level ``mcpServers`` key.
|
|
The server command is the SAME shared relay claude/codex/cursor use:
|
|
``<python> -I -m omnigent.claude_native_bridge serve-mcp --bridge-dir <dir>``.
|
|
|
|
**HOME pinning.** agy spawns this relay as a child. The relay validates its
|
|
``--bridge-dir`` against ``bridge_root()`` (``$HOME/.omnigent/antigravity-native``),
|
|
which must resolve to the RUNNER's real home where the bridge dir actually
|
|
lives. Pinning ``HOME`` in the relay env keeps that invariant true even when a
|
|
future agy launch path customizes process environment. ``-I`` does not clear
|
|
``HOME``; it only ignores ``PYTHON*`` vars and user site-packages.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory whose relay this
|
|
config points at.
|
|
:param python_executable: Python interpreter for the relay command;
|
|
defaults to the current interpreter (``sys.executable``).
|
|
:returns: The ``mcp_config.json`` payload as a dict.
|
|
"""
|
|
python = python_executable or sys.executable
|
|
return {
|
|
"mcpServers": {
|
|
_MCP_SERVER_NAME: {
|
|
"command": python,
|
|
"args": [
|
|
"-I",
|
|
"-m",
|
|
"omnigent.claude_native_bridge",
|
|
"serve-mcp",
|
|
"--bridge-dir",
|
|
str(bridge_dir),
|
|
],
|
|
"enabledTools": list(_AGY_ENABLED_TOOLS),
|
|
"env": {
|
|
"TMPDIR": os.environ.get("TMPDIR", "/tmp"),
|
|
# Pin the relay to the RUNNER's real home so its bridge-root
|
|
# validation matches where the bridge dir lives. See docstring.
|
|
"HOME": str(Path.home()),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
def write_mcp_bridge_config(bridge_dir: Path) -> None:
|
|
"""Write the token config the shared Omnigent MCP relay requires.
|
|
|
|
The relay's ``serve-mcp`` reads ``<bridge_dir>/bridge.json`` for a token that
|
|
authorizes its localhost control endpoint. Idempotent: an existing token is
|
|
left untouched so a resume/clear does not rotate a live relay's token.
|
|
Mirrors :func:`omnigent.cursor_native_bridge.write_mcp_bridge_config`.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory.
|
|
:returns: None.
|
|
"""
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
config_path = bridge_dir / _BRIDGE_CONFIG_FILE
|
|
if config_path.exists():
|
|
return
|
|
payload = {"token": secrets.token_urlsafe(32)}
|
|
tmp = bridge_dir / (_BRIDGE_CONFIG_FILE + ".tmp")
|
|
tmp.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
|
|
os.replace(tmp, config_path)
|
|
|
|
|
|
def write_mcp_config(
|
|
bridge_dir: Path,
|
|
*,
|
|
python_executable: str | None = None,
|
|
) -> Path:
|
|
"""Write the per-session agy MCP config + relay token.
|
|
|
|
Writes (1) ``bridge.json`` (the relay token) into *bridge_dir* and (2) the
|
|
``mcp_config.json`` for the Omnigent relay into the per-session isolated agy
|
|
Gemini dir at ``<bridge_dir>/agy-home/.gemini/config/mcp_config.json`` — the
|
|
path agy loads when launched with ``--gemini_dir``. Because the Gemini dir is
|
|
per-session and not the user's real ``~/.gemini``, this never clobbers the
|
|
user's interactive agy config and two concurrent sessions never share one
|
|
config. Mirrors cursor #742's :func:`omnigent.cursor_native_bridge.write_mcp_config`,
|
|
adapted to agy's hidden ``--gemini_dir`` flag.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory (holds ``bridge.json``
|
|
and the isolated agy Gemini dir).
|
|
:param python_executable: Python interpreter for the relay command;
|
|
defaults to the current interpreter.
|
|
:returns: Absolute path to the written ``mcp_config.json``.
|
|
"""
|
|
write_mcp_bridge_config(bridge_dir)
|
|
config_dir = agy_gemini_dir(bridge_dir) / _MCP_CONFIG_DIR
|
|
config_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
path = config_dir / _MCP_CONFIG_FILE
|
|
payload = build_mcp_config(bridge_dir, python_executable=python_executable)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
os.replace(tmp, path)
|
|
return path
|
|
|
|
|
|
def seed_isolated_agy_home(
|
|
bridge_dir: Path,
|
|
*,
|
|
trusted_workspace: Path | str | None = None,
|
|
) -> dict[str, str]:
|
|
"""Seed the per-session isolated agy Gemini dir and return env overrides.
|
|
|
|
Copies known file-based agy OAuth markers + onboarding/migration state (NEVER
|
|
moving or modifying the real files) into ``<bridge_dir>/agy-home/.gemini``.
|
|
The runner keeps agy's real ``HOME`` intact and passes this directory through
|
|
``--gemini_dir``; on macOS that is required because agy uses keyring-backed
|
|
auth that is not portable to a relocated ``HOME``. The relay's
|
|
``mcp_config.json`` is written separately by :func:`write_mcp_config` so it
|
|
lands in this same isolated tree rather than the user's real ``~/.gemini``
|
|
(the footgun this whole design avoids).
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory.
|
|
:param trusted_workspace: Optional workspace path to pre-trust inside the
|
|
isolated agy settings file. This suppresses agy's unhookable first-run
|
|
"Do you trust this project?" TUI gate for host-spawned sessions, mirroring
|
|
``ensure_claude_workspace_trusted`` while keeping trust scoped to this
|
|
bridge-owned ``--gemini_dir``.
|
|
:returns: Env overrides to layer onto the agy launch environment. Currently
|
|
empty because ``HOME`` must stay real for platform auth.
|
|
"""
|
|
real_home = Path.home()
|
|
iso_gemini = agy_gemini_dir(bridge_dir)
|
|
(iso_gemini / "antigravity-cli" / "cache").mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
(iso_gemini / _MCP_CONFIG_DIR).mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
|
|
# Copy the auth token + installation id (best-effort: a missing token only
|
|
# means agy re-auths this session; the real files are never touched).
|
|
for rel in _AGY_SEED_FILES:
|
|
src = real_home / ".gemini" / rel
|
|
if not src.is_file():
|
|
continue
|
|
dst = iso_gemini / rel
|
|
dst.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
with contextlib.suppress(OSError):
|
|
dst.write_bytes(src.read_bytes())
|
|
os.chmod(dst, 0o600)
|
|
|
|
# Seed the onboarding-complete marker so the first-run wizard never blocks a
|
|
# headless launch (same state ``ensure_agy_onboarding_complete`` writes).
|
|
onboarding = iso_gemini / "antigravity-cli" / "cache" / "onboarding.json"
|
|
with contextlib.suppress(OSError):
|
|
onboarding.write_text(
|
|
json.dumps(
|
|
{
|
|
"consumerOnboardingComplete": True,
|
|
"enterpriseOnboardingComplete": False,
|
|
"onboardingComplete": True,
|
|
},
|
|
sort_keys=True,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Seed the migration marker so agy does not churn a from-scratch migration on
|
|
# first launch under the fresh Gemini dir (cosmetic; agy creates it itself otherwise).
|
|
with contextlib.suppress(OSError):
|
|
(iso_gemini / _MCP_CONFIG_DIR / ".migrated").touch()
|
|
|
|
if trusted_workspace is not None:
|
|
_seed_isolated_agy_workspace_trust(iso_gemini, Path(trusted_workspace))
|
|
|
|
return {}
|
|
|
|
|
|
# agy's periodic engagement survey ("How's the CLI experience so far?") is gated by
|
|
# this ``settings.json`` key. Its modal footer line ``esc to cancel`` is identical
|
|
# to :data:`_AGY_ACTIVE_MARKER` (the running-turn signal the TUI inject path keys
|
|
# on), so a web turn typed into the pane while the survey is up is misread as a
|
|
# mid-turn steer and silently lost (#1494). Disabling the survey removes the
|
|
# trigger. Verified live: toggling agy's ``/config`` "Show Feedback Survey" off
|
|
# writes exactly ``"showFeedbackSurvey": false`` here (``disableFeedback`` is an
|
|
# unrelated internal proto field, NOT a settings key — it would be ignored).
|
|
_AGY_FEEDBACK_SURVEY_SETTING = "showFeedbackSurvey"
|
|
|
|
|
|
def ensure_agy_feedback_survey_disabled(home: Path) -> None:
|
|
"""
|
|
Disable agy's feedback survey in the launch HOME's ``settings.json``.
|
|
|
|
agy periodically renders an engagement survey whose modal footer
|
|
(``esc to cancel``) collides with :data:`_AGY_ACTIVE_MARKER`; with it up, a
|
|
web turn injected into the TUI is mistaken for a running turn and lost
|
|
(#1494). The survey is governed by ``showFeedbackSurvey`` in
|
|
``<home>/.gemini/antigravity-cli/settings.json`` — set it ``false`` before
|
|
launch so the survey never appears (deterministic prevention; text-matching
|
|
the survey itself would be brittle to agy wording changes).
|
|
|
|
Merge-only + idempotent: existing keys (``model``, ``trustedWorkspaces``,
|
|
``enableTelemetry``, …) are preserved; a missing file is created with just
|
|
this key; a malformed / non-object file is left UNTOUCHED rather than
|
|
clobbered. Best-effort — a read/write failure is logged and the launch
|
|
proceeds (the survey is a degradation, not a hard blocker).
|
|
|
|
:param home: The HOME agy launches under (the per-session isolated home, or
|
|
the real home when the harness runs agy under it). Settings live at
|
|
``<home>/.gemini/antigravity-cli/settings.json``.
|
|
:returns: None.
|
|
"""
|
|
# resolve() follows a symlinked settings.json (e.g. a dotfiles-managed file) to
|
|
# its real target, so the atomic os.replace below rewrites the linked file rather
|
|
# than silently replacing the symlink with a regular one. strict=False is a no-op
|
|
# for a normal or not-yet-existing path (it only resolves parent symlinks).
|
|
settings_path = (home / ".gemini" / "antigravity-cli" / "settings.json").resolve(strict=False)
|
|
data: dict[str, object] = {}
|
|
try:
|
|
raw = settings_path.read_text(encoding="utf-8")
|
|
except FileNotFoundError:
|
|
raw = "" # no settings yet -> create a fresh one carrying just this key
|
|
except OSError:
|
|
# An EXISTING but unreadable file (perms, stale NFS, …) must NOT be
|
|
# recreated from scratch — that would drop the user's real settings.
|
|
_logger.warning(
|
|
"could not read agy settings.json at %s; leaving it untouched",
|
|
settings_path,
|
|
exc_info=True,
|
|
)
|
|
return
|
|
except UnicodeDecodeError:
|
|
# read_text raises this (a ValueError, not an OSError) on non-UTF-8 bytes;
|
|
# treat it like malformed JSON and never clobber the file (mirrors the
|
|
# ``(OSError, ValueError)`` read guard in ensure_agy_onboarding_complete).
|
|
_logger.warning(
|
|
"agy settings.json at %s is not valid UTF-8; leaving it untouched",
|
|
settings_path,
|
|
)
|
|
return
|
|
if raw.strip():
|
|
try:
|
|
loaded = json.loads(raw)
|
|
except ValueError:
|
|
# json.JSONDecodeError subclasses ValueError; catch the supertype to
|
|
# match the decode-family guard in ensure_agy_onboarding_complete.
|
|
_logger.warning(
|
|
"agy settings.json at %s is not valid JSON; leaving it untouched "
|
|
"(the feedback survey may still appear)",
|
|
settings_path,
|
|
)
|
|
return
|
|
if not isinstance(loaded, dict):
|
|
_logger.warning(
|
|
"agy settings.json at %s is not a JSON object; leaving it untouched",
|
|
settings_path,
|
|
)
|
|
return
|
|
data = loaded
|
|
if data.get(_AGY_FEEDBACK_SURVEY_SETTING) is False:
|
|
return # already disabled — avoid a needless rewrite
|
|
data[_AGY_FEEDBACK_SURVEY_SETTING] = False
|
|
# The write is atomic (mkstemp + os.replace) so a concurrent reader/writer
|
|
# never sees a torn file. On macOS the harness runs agy under the user's REAL
|
|
# ~/.gemini (the #1477 Keychain trade-off), so this file is shared across
|
|
# concurrent sessions and with agy itself: the atomic replace prevents
|
|
# corruption but not lost updates. That window is self-limiting — the
|
|
# idempotent short-circuit above makes every launch after the first disable
|
|
# read-only, so a racing agy trust/model write can only be clobbered on the
|
|
# one-time first disable, and the clobbered values fail safe (lost trust is
|
|
# re-prompted, lost model defaults). A cross-process lock is intentionally not
|
|
# taken (a separately-launched agy would not honor it).
|
|
try:
|
|
settings_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
fd, tmp_name = tempfile.mkstemp(prefix="settings.json.", dir=str(settings_path.parent))
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(data, handle, indent=2, sort_keys=True)
|
|
handle.write("\n")
|
|
handle.flush()
|
|
os.fsync(handle.fileno()) # crash-safe, mirrors ensure_agy_onboarding_complete
|
|
os.replace(tmp_name, settings_path)
|
|
finally:
|
|
if os.path.exists(tmp_name):
|
|
os.unlink(tmp_name)
|
|
except OSError:
|
|
_logger.warning(
|
|
"could not write agy settings.json at %s to disable the feedback survey",
|
|
settings_path,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
def _seed_isolated_agy_workspace_trust(iso_gemini: Path, workspace: Path) -> None:
|
|
"""Add *workspace* to isolated agy ``trustedWorkspaces`` settings."""
|
|
settings_path = iso_gemini / "antigravity-cli" / "settings.json"
|
|
data: dict[str, object] = {}
|
|
if settings_path.is_file():
|
|
try:
|
|
loaded = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
loaded = None
|
|
if isinstance(loaded, dict):
|
|
data = loaded
|
|
workspace_key = str(workspace.resolve())
|
|
existing = data.get("trustedWorkspaces")
|
|
trusted = list(existing) if isinstance(existing, list) else []
|
|
if workspace_key in trusted:
|
|
return
|
|
trusted.append(workspace_key)
|
|
data["trustedWorkspaces"] = trusted
|
|
settings_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
tmp = settings_path.with_suffix(settings_path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
os.replace(tmp, settings_path)
|
|
|
|
|
|
def write_bridge_state(bridge_dir: Path, state: AntigravityNativeBridgeState) -> None:
|
|
"""
|
|
Persist shared native Antigravity state atomically.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory.
|
|
:param state: State payload to persist.
|
|
:returns: None.
|
|
"""
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
path = bridge_dir / _STATE_FILE
|
|
fd, tmp_name = tempfile.mkstemp(prefix=f"{_STATE_FILE}.", dir=str(bridge_dir))
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(
|
|
{
|
|
"session_id": state.session_id,
|
|
"conversation_id": state.conversation_id,
|
|
"active_turn_id": state.active_turn_id,
|
|
},
|
|
handle,
|
|
sort_keys=True,
|
|
)
|
|
handle.write("\n")
|
|
os.replace(tmp_name, path)
|
|
finally:
|
|
if os.path.exists(tmp_name):
|
|
os.unlink(tmp_name)
|
|
|
|
|
|
def clear_bridge_state(bridge_dir: Path) -> None:
|
|
"""
|
|
Remove stale native Antigravity runtime state for a bridge directory.
|
|
|
|
New agy launches reuse the same bridge directory for a conversation id,
|
|
but the old ``state.json`` still names the *previous* agy run's discovered
|
|
conversation id. Clear it before the new launch so the forwarder
|
|
rediscovers the new run's real conversation id instead of binding to the
|
|
stale one. The advertised tmux pane (``tmux.json``) is cleared for the same
|
|
reason: a new launch opens a new pane (and, after a host restart, a new
|
|
socket), so the executor must not bootstrap the first turn against the prior
|
|
run's pane. The new launch re-advertises its pane via
|
|
:func:`write_tmux_target`.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory.
|
|
:returns: None.
|
|
"""
|
|
for runtime_file in (_STATE_FILE, _TMUX_FILE):
|
|
with contextlib.suppress(FileNotFoundError):
|
|
(bridge_dir / runtime_file).unlink()
|
|
|
|
|
|
def read_bridge_state(bridge_dir: Path) -> AntigravityNativeBridgeState | None:
|
|
"""
|
|
Read shared native Antigravity bridge state.
|
|
|
|
Legacy ``agy_pid`` / ``forwarded_step_index`` / ``forwarded_steps`` keys
|
|
written by an older build are tolerated and ignored: ``agy_pid`` because agy
|
|
does not exist at launch (the executor always discovers the connect-RPC port
|
|
at injection time), and the two cursor keys because the durable read cursor
|
|
was retired with the transcript forwarder (the RPC reader keeps an in-memory
|
|
seen-set only).
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory.
|
|
:returns: Parsed state, or ``None`` when no state exists or the file
|
|
is missing, corrupt, or has invalid field types.
|
|
"""
|
|
path = bridge_dir / _STATE_FILE
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
session_id = raw.get("session_id")
|
|
conversation_id = raw.get("conversation_id")
|
|
active_turn_id = raw.get("active_turn_id")
|
|
# Validate required string fields are non-empty strings.
|
|
if not isinstance(session_id, str) or not session_id:
|
|
return None
|
|
if not isinstance(conversation_id, str) or not conversation_id:
|
|
return None
|
|
parsed_active_turn_id = (
|
|
active_turn_id if isinstance(active_turn_id, str) and active_turn_id else None
|
|
)
|
|
return AntigravityNativeBridgeState(
|
|
session_id=session_id,
|
|
conversation_id=conversation_id,
|
|
active_turn_id=parsed_active_turn_id,
|
|
)
|
|
|
|
|
|
def update_conversation_id(
|
|
bridge_dir: Path,
|
|
conversation_id: str,
|
|
active_turn_id: str | None = None,
|
|
) -> bool:
|
|
"""
|
|
Update the agy conversation id in bridge state.
|
|
|
|
Used when a native agy action creates a fresh conversation while the
|
|
Omnigent session stays the same (e.g. the runner cold-start replacing the
|
|
``agy_conv_*`` placeholder with agy's real cascade id).
|
|
|
|
When there is no existing state to update (a missing or invalid state file),
|
|
the write is SKIPPED and a WARNING is logged naming the dropped id — silently
|
|
dropping it would leave the reader bound to the ``agy_conv_*`` placeholder
|
|
forever, surfaced only as a generic "conversation not ready". The caller
|
|
decides how to react (it stays best-effort; this never raises).
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory.
|
|
:param conversation_id: New agy conversation id, e.g.
|
|
``"agy_conv_abc123"``.
|
|
:param active_turn_id: Active turn id for the new conversation, e.g.
|
|
``"turn_abc123"``, or ``None`` when no turn is running yet.
|
|
:returns: ``True`` when the new id was written, ``False`` when there was no
|
|
existing state to update (the id was dropped; a WARNING was logged).
|
|
"""
|
|
state = read_bridge_state(bridge_dir)
|
|
if state is None:
|
|
_logger.warning(
|
|
"Antigravity bridge: no existing state at %s to update; dropping "
|
|
"conversation_id=%s (the reader will stay on the placeholder id)",
|
|
bridge_dir,
|
|
conversation_id,
|
|
)
|
|
return False
|
|
write_bridge_state(
|
|
bridge_dir,
|
|
AntigravityNativeBridgeState(
|
|
session_id=state.session_id,
|
|
conversation_id=conversation_id,
|
|
active_turn_id=active_turn_id,
|
|
),
|
|
)
|
|
return True
|
|
|
|
|
|
# ── Web-turn TUI delivery (tmux send-keys) ───────────────────────────────────
|
|
#
|
|
# The executor types EVERY web/mobile turn into the agy TUI over the runner-owned
|
|
# tmux pane — never agy's connect-RPC ``SendAgentMessage``, which agy records as a
|
|
# ``SYSTEM_MESSAGE`` the transcript forwarder would not mirror (so the user's
|
|
# message would never be committed). Typing creates a real ``USER_INPUT`` step
|
|
# that the forwarder mirrors in order (validated against agy 1.0.10: a
|
|
# paste-buffer + Enter is recorded as ``USER_INPUT`` whether agy is idle or
|
|
# mid-turn, and on a fresh session it also creates the conversation + brain dir
|
|
# the forwarder then discovers). This mirrors the cursor/claude native send-keys
|
|
# delivery; no shared tmux helper exists, so the small primitives are duplicated
|
|
# per the established per-harness convention.
|
|
|
|
# tmux probe/command timeout. Short: these are local IPC calls to the runner's
|
|
# own tmux server.
|
|
_TMUX_SEND_TIMEOUT_S = 5.0
|
|
# Per-readiness-gate wait (tmux.json advertised, then the agy input box mounted).
|
|
_TMUX_READY_TIMEOUT_S = 30.0
|
|
# Poll cadence for the readiness / paste-commit / submit-verify loops.
|
|
_TMUX_POLL_INTERVAL_S = 0.2
|
|
# How long to wait for the pasted draft to render in the input box before Enter.
|
|
_PASTE_COMMIT_TIMEOUT_S = 5.0
|
|
# Settle pause after the draft renders, before the submit Enter.
|
|
_PASTE_SETTLE_S = 0.2
|
|
# How long one submit Enter is given to start a turn before it is re-sent.
|
|
_SUBMIT_VERIFY_TIMEOUT_S = 5.0
|
|
# Re-send budget for a submit Enter that the TUI folded into the paste burst.
|
|
_MAX_SUBMIT_ATTEMPTS = 3
|
|
# Named tmux buffer used to stream the paste (avoids the ~16KB send-keys argv cap).
|
|
_PASTE_BUFFER = "omnigent-agy-paste"
|
|
# agy TUI footer when idle (input box mounted, ready for a turn).
|
|
_AGY_IDLE_MARKER = "? for shortcuts"
|
|
# agy TUI footer while a turn is running. Used as a readiness hint and to detect
|
|
# a mid-turn steer; submission itself is verified by draft disappearance, NOT by
|
|
# this marker (footer text can change across agy builds or be truncated).
|
|
_AGY_ACTIVE_MARKER = "esc to cancel"
|
|
# Patterns redacted from a pane tail before it is surfaced in a delivery-failure
|
|
# error. The agy header shows the signed-in account email, and the transcript
|
|
# region can echo tokens/keys agy printed; redaction is best-effort and biased
|
|
# toward over-redaction so a secret never leaks into a user-facing error.
|
|
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+")
|
|
_SECRET_RES = (
|
|
re.compile(r"\b(?:sk|rk)-[A-Za-z0-9_-]{16,}"), # OpenAI-style keys
|
|
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"), # GitHub tokens
|
|
re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"), # Slack tokens
|
|
re.compile(r"\bya29\.[A-Za-z0-9._-]{10,}"), # Google OAuth access tokens
|
|
re.compile(r"\bAIza[A-Za-z0-9_-]{30,}"), # Google API keys
|
|
re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key ids
|
|
re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}"), # JWTs
|
|
re.compile(r"(?i)\b(?:bearer|token|api[_-]?key|secret|password)\b\s*[:=]?\s*\S{6,}"),
|
|
)
|
|
# TUI horizontal separator around agy's bottom composer.
|
|
_AGY_SEPARATOR_CHAR = "─"
|
|
# Box-drawing glyphs agy may use to frame/decorate the composer rule. A rule is
|
|
# detected when its non-space chars are all box-drawing AND it carries a run of
|
|
# the horizontal rule char, so a future agy that frames the composer with
|
|
# corner/join glyphs (e.g. ``╭────╮``) is still detected, while ordinary text
|
|
# (which carries non-box chars) never matches.
|
|
_AGY_BOX_GLYPHS = frozenset("─━╌╍┄┅┈┉╭╮╰╯┌┐└┘├┤┬┴┼│║╔╗╚╝═╠╣╦╩╬╴╵╶╷")
|
|
|
|
|
|
def write_tmux_target(
|
|
bridge_dir: Path,
|
|
*,
|
|
socket_path: Path,
|
|
tmux_target: str,
|
|
pid: int | None = None,
|
|
) -> None:
|
|
"""
|
|
Advertise the tmux socket + target for the running agy terminal.
|
|
|
|
The runner calls this after launching the agy terminal so the executor can
|
|
shell out to ``tmux send-keys`` against the same private socket to bootstrap
|
|
the first web turn (see :func:`inject_user_message_via_tui`). Written
|
|
atomically next to ``state.json``.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory.
|
|
:param socket_path: Absolute path to the terminal's private tmux socket.
|
|
:param tmux_target: tmux pane target string, e.g. ``"main"``.
|
|
:param pid: Optional agy/pane pid, recorded for diagnostics only.
|
|
:returns: None.
|
|
"""
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
payload: dict[str, object] = {
|
|
"socket_path": str(socket_path),
|
|
"tmux_target": tmux_target,
|
|
"updated_at": time.time(),
|
|
}
|
|
if pid is not None:
|
|
payload["pid"] = pid
|
|
path = bridge_dir / _TMUX_FILE
|
|
fd, tmp_name = tempfile.mkstemp(prefix=f"{_TMUX_FILE}.", dir=str(bridge_dir))
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, sort_keys=True)
|
|
handle.write("\n")
|
|
os.replace(tmp_name, path)
|
|
finally:
|
|
if os.path.exists(tmp_name):
|
|
os.unlink(tmp_name)
|
|
|
|
|
|
def read_tmux_info(bridge_dir: Path) -> dict[str, str] | None:
|
|
"""
|
|
Return the advertised ``{socket_path, tmux_target}`` for the agy terminal.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory.
|
|
:returns: A dict with non-empty ``socket_path`` and ``tmux_target`` string
|
|
values, or ``None`` when ``tmux.json`` is missing, unreadable, malformed,
|
|
or has invalid field types.
|
|
"""
|
|
try:
|
|
raw = (bridge_dir / _TMUX_FILE).read_text(encoding="utf-8")
|
|
except OSError:
|
|
return None
|
|
try:
|
|
data = json.loads(raw)
|
|
except (json.JSONDecodeError, ValueError):
|
|
return None
|
|
if not isinstance(data, dict):
|
|
return None
|
|
socket_path = data.get("socket_path")
|
|
tmux_target = data.get("tmux_target")
|
|
if (
|
|
isinstance(socket_path, str)
|
|
and socket_path
|
|
and isinstance(tmux_target, str)
|
|
and tmux_target
|
|
):
|
|
return {"socket_path": socket_path, "tmux_target": tmux_target}
|
|
return None
|
|
|
|
|
|
def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]:
|
|
"""
|
|
Block until the agy terminal's tmux target is advertised, or raise.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory.
|
|
:param timeout_s: Maximum seconds to wait for ``tmux.json``.
|
|
:returns: The advertised ``{socket_path, tmux_target}``.
|
|
:raises RuntimeError: When the target is not advertised within *timeout_s*.
|
|
"""
|
|
deadline = time.monotonic() + timeout_s
|
|
while True:
|
|
info = read_tmux_info(bridge_dir)
|
|
if info is not None:
|
|
return info
|
|
if time.monotonic() >= deadline:
|
|
raise RuntimeError(
|
|
f"antigravity-native tmux target was not advertised within {timeout_s:.0f}s "
|
|
"(is the agy terminal running on this host?)"
|
|
)
|
|
time.sleep(_TMUX_POLL_INTERVAL_S)
|
|
|
|
|
|
def _run_tmux(socket_path: str, *args: str) -> None:
|
|
"""
|
|
Invoke ``tmux -S <socket> <args...>`` and raise on failure.
|
|
|
|
:param socket_path: tmux server socket path.
|
|
:param args: tmux subcommand and arguments, e.g.
|
|
``("send-keys", "-t", "main", "Enter")``.
|
|
:returns: None.
|
|
:raises RuntimeError: On a non-zero exit or a timeout.
|
|
"""
|
|
try:
|
|
proc = subprocess.run(
|
|
["tmux", "-S", socket_path, *args],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_TMUX_SEND_TIMEOUT_S,
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise RuntimeError(f"tmux command timed out after {_TMUX_SEND_TIMEOUT_S:.0f}s") from exc
|
|
except OSError as exc:
|
|
raise RuntimeError(f"tmux could not be executed: {exc}") from exc
|
|
if proc.returncode != 0:
|
|
detail = proc.stderr.strip() or proc.stdout.strip() or "<no output>"
|
|
raise RuntimeError(f"tmux command failed (rc={proc.returncode}): {detail}")
|
|
|
|
|
|
def _capture_pane(socket_path: str, tmux_target: str) -> str:
|
|
"""
|
|
Capture the visible pane contents; ``""`` on any failure (treat as not-ready).
|
|
|
|
:param socket_path: tmux server socket path.
|
|
:param tmux_target: tmux pane target.
|
|
:returns: The pane text, or ``""`` when the capture fails.
|
|
"""
|
|
try:
|
|
proc = subprocess.run(
|
|
["tmux", "-S", socket_path, "capture-pane", "-p", "-t", tmux_target],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_TMUX_SEND_TIMEOUT_S,
|
|
)
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
return ""
|
|
return proc.stdout if proc.returncode == 0 else ""
|
|
|
|
|
|
def _session_alive(socket_path: str, tmux_target: str) -> bool:
|
|
"""
|
|
Return whether the tmux session/pane still exists (the agy TUI is running).
|
|
|
|
:param socket_path: tmux server socket path.
|
|
:param tmux_target: tmux pane target.
|
|
:returns: ``True`` when ``has-session`` reports the target exists.
|
|
"""
|
|
try:
|
|
proc = subprocess.run(
|
|
["tmux", "-S", socket_path, "has-session", "-t", tmux_target],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_TMUX_SEND_TIMEOUT_S,
|
|
)
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
return False
|
|
return proc.returncode == 0
|
|
|
|
|
|
def _paste_payload_bytes(text: str) -> bytes:
|
|
r"""
|
|
Encode text for ``tmux load-buffer``.
|
|
|
|
Line breaks become CR (0x0D) so the agy TUI keeps a multi-line message as a
|
|
single input under bracketed paste rather than submitting on each newline;
|
|
tabs are kept; other control bytes are dropped (a stray ESC would close the
|
|
bracketed paste early).
|
|
|
|
:param text: Raw message text.
|
|
:returns: The encoded paste payload.
|
|
"""
|
|
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
body = bytearray()
|
|
for ch in normalized:
|
|
if ch == "\n":
|
|
body.append(0x0D)
|
|
continue
|
|
if ch == "\t":
|
|
body.append(0x09)
|
|
continue
|
|
if ord(ch) < 0x20:
|
|
continue
|
|
body.extend(ch.encode("utf-8"))
|
|
return bytes(body)
|
|
|
|
|
|
def _submit_needle(content: str) -> str:
|
|
"""
|
|
A stable single-line substring used to confirm the paste rendered in the pane.
|
|
|
|
:param content: Message content.
|
|
:returns: Up to 24 chars of the first line with at least 4 non-space chars,
|
|
or ``""`` when no such line exists (the caller then skips the
|
|
paste-commit poll and submits after the settle pause).
|
|
"""
|
|
for line in content.splitlines():
|
|
stripped = line.strip()
|
|
if len(stripped) >= 4:
|
|
return stripped[:24]
|
|
stripped = content.strip()
|
|
return stripped[:24] if len(stripped) >= 4 else ""
|
|
|
|
|
|
def _redact_pane_secrets(text: str) -> str:
|
|
"""Redact emails and common secret shapes from a pane tail.
|
|
|
|
Best-effort and biased toward over-redaction: the tail is embedded in a
|
|
user-facing delivery error, and agy may have printed the signed-in account
|
|
email or tokens/keys in the transcript region.
|
|
"""
|
|
redacted = _EMAIL_RE.sub("[REDACTED_EMAIL]", text)
|
|
for pattern in _SECRET_RES:
|
|
redacted = pattern.sub("[REDACTED_SECRET]", redacted)
|
|
return redacted
|
|
|
|
|
|
def _format_pane_debug_tail(pane: str) -> str:
|
|
"""
|
|
Return a short, redacted pane tail for delivery failure diagnostics.
|
|
|
|
The agy pane header can include the authenticated user's email address and
|
|
the transcript region can echo secrets agy printed. Keep this diagnostic safe
|
|
to surface in an executor error by redacting emails + common secret shapes
|
|
(see :func:`_redact_pane_secrets`) and limiting the output to a small tail.
|
|
"""
|
|
lines = [line.rstrip() for line in pane.splitlines() if line.strip()]
|
|
tail = "\n".join(lines[-12:])
|
|
return _redact_pane_secrets(tail) or "<empty pane>"
|
|
|
|
|
|
def _agy_input_region(pane: str) -> str:
|
|
"""
|
|
Return agy's live bottom composer region, excluding transcript history.
|
|
|
|
agy renders the editable composer between horizontal separator lines. After
|
|
a successful submit, the submitted prompt remains in transcript history
|
|
above a fresh empty composer, so checking the full pane would falsely think
|
|
the draft is still present. The last separator pair scopes matching to the
|
|
currently editable input box, mirroring Kiro's input-region guard.
|
|
"""
|
|
lines = pane.splitlines()
|
|
separator_indexes = [index for index, line in enumerate(lines) if _agy_separator_line(line)]
|
|
if len(separator_indexes) >= 2:
|
|
start = separator_indexes[-2] + 1
|
|
end = separator_indexes[-1]
|
|
return "\n".join(lines[start:end])
|
|
return "\n".join(lines[-8:])
|
|
|
|
|
|
def _agy_separator_line(line: str) -> bool:
|
|
"""Return whether *line* is one of agy's composer separator rules.
|
|
|
|
Accepts a pure ``─`` rule (agy 1.0.14) and a box-decorated rule (corner/join
|
|
glyphs) so a future agy that frames the composer is still detected, while an
|
|
ordinary text/draft line (which carries non-box chars) never matches. A short
|
|
dash run (< 8 rule chars) is not treated as a separator.
|
|
"""
|
|
stripped = line.strip()
|
|
if stripped.count(_AGY_SEPARATOR_CHAR) < 8:
|
|
return False
|
|
return all(char in _AGY_BOX_GLYPHS for char in stripped if not char.isspace())
|
|
|
|
|
|
def _draft_in_input_region(pane: str, needle: str, baseline_region: str) -> bool:
|
|
"""
|
|
Return whether the pasted draft is still visible in agy's composer.
|
|
|
|
The baseline region prevents matching the empty prompt or stale text that was
|
|
already present before this paste. Candidate lines are normalized by removing
|
|
agy's leading ``>`` prompt glyph.
|
|
|
|
When *needle* is empty (a message too short for a stable needle, e.g.
|
|
``"ok"``), any draft-like content in a composer that differs from the
|
|
pre-paste baseline counts as present — so short messages are still render- and
|
|
submit-verified by composer state instead of being submitted blind.
|
|
"""
|
|
region = _agy_input_region(pane)
|
|
if region == baseline_region:
|
|
return False
|
|
candidates = _agy_draft_candidate_lines(region)
|
|
normalized_needle = needle.strip() if needle else ""
|
|
if not normalized_needle:
|
|
return bool(candidates)
|
|
return any(
|
|
line == normalized_needle
|
|
or line.startswith(normalized_needle)
|
|
or normalized_needle in line
|
|
for line in candidates
|
|
)
|
|
|
|
|
|
def _agy_draft_candidate_lines(region: str) -> list[str]:
|
|
"""Return composer lines that can represent editable draft text.
|
|
|
|
A line carrying agy's ``>`` prompt glyph is editable draft content and is
|
|
kept verbatim (after the glyph is stripped) even when the user's own text
|
|
contains a word like ``Generating`` or a footer phrase — otherwise a
|
|
legitimate message would be filtered out and misread as "never rendered".
|
|
Status/footer rows (idle/active/"Generating …") carry no ``>`` prompt, so the
|
|
chrome filters apply only to those non-draft lines.
|
|
"""
|
|
candidates: list[str] = []
|
|
for raw_line in region.splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line == ">":
|
|
continue
|
|
if line.startswith(">"):
|
|
draft = line[1:].strip()
|
|
if draft:
|
|
candidates.append(draft)
|
|
continue
|
|
if _AGY_IDLE_MARKER in line or _AGY_ACTIVE_MARKER in line:
|
|
continue
|
|
if "Generating" in line:
|
|
continue
|
|
candidates.append(line)
|
|
return candidates
|
|
|
|
|
|
def _wait_for_agy_prompt_ready(socket_path: str, tmux_target: str, *, timeout_s: float) -> None:
|
|
"""
|
|
Best-effort wait until agy's input box is mounted (its footer is rendered).
|
|
|
|
Polls ``capture-pane`` for EITHER footer marker: :data:`_AGY_IDLE_MARKER`
|
|
(ready for a new turn) or :data:`_AGY_ACTIVE_MARKER` (a turn is running). Both
|
|
mean the input box is mounted and can take a paste — agy accepts a mid-turn
|
|
paste and queues it as the next turn — so a mid-turn steer must NOT wait for
|
|
the idle footer that will not appear until the active turn ends (that would
|
|
burn the whole budget). Falls through after *timeout_s* rather than raising:
|
|
the submit step is the real guard, and a changed footer string in a future
|
|
agy build must not hard-block delivery.
|
|
|
|
:param socket_path: tmux server socket path.
|
|
:param tmux_target: tmux pane target.
|
|
:param timeout_s: Maximum seconds to wait for a footer to render.
|
|
:returns: None.
|
|
"""
|
|
deadline = time.monotonic() + timeout_s
|
|
while time.monotonic() < deadline:
|
|
pane = _capture_pane(socket_path, tmux_target)
|
|
if _AGY_IDLE_MARKER in pane or _AGY_ACTIVE_MARKER in pane:
|
|
return
|
|
time.sleep(_TMUX_POLL_INTERVAL_S)
|
|
|
|
|
|
def _submit_and_verify(
|
|
socket_path: str,
|
|
tmux_target: str,
|
|
*,
|
|
needle: str = "",
|
|
baseline_region: str = "",
|
|
draft_seen: bool = False,
|
|
) -> None:
|
|
"""
|
|
Press Enter to submit the pasted draft, verifying by composer state.
|
|
|
|
This mirrors the stronger Claude/Kiro native bridge contract. Footer text is
|
|
only a readiness hint: it can change across agy builds, be truncated in narrow
|
|
panes, or redraw independently from the editable draft. The reliable local
|
|
submit signal is that the draft which was visible before Enter is no longer
|
|
present in the live bottom composer region (for an empty needle this keys off
|
|
the composer returning to the pre-paste baseline). If the same draft remains
|
|
visible, Enter was likely folded into the paste burst and is re-sent within a
|
|
bounded retry budget. ``inject_user_message_via_tui`` fails earlier when a
|
|
text draft never renders into an idle composer.
|
|
|
|
**Mid-turn steer.** When agy already shows :data:`_AGY_ACTIVE_MARKER`, a turn
|
|
is running: the draft-disappearance signal is unreliable (agy queues the steer
|
|
as the next ``USER_INPUT`` and the composer may not clear promptly) and a
|
|
second Enter could queue a spurious empty turn. So a single best-effort Enter
|
|
is sent and the function returns without re-sending or hard-failing — the
|
|
forwarder is the system-of-record for whether the queued steer registered.
|
|
|
|
:param socket_path: tmux server socket path.
|
|
:param tmux_target: tmux pane target.
|
|
:returns: None.
|
|
:raises RuntimeError: When the agy TUI exits mid-submit, or the visible
|
|
draft remains in the composer after :data:`_MAX_SUBMIT_ATTEMPTS` attempts.
|
|
"""
|
|
if not _session_alive(socket_path, tmux_target):
|
|
raise RuntimeError(
|
|
"the agy terminal exited before the message could be submitted; restart the session"
|
|
)
|
|
# Mid-turn steer: a turn is already running, so verifying via draft
|
|
# disappearance is unreliable and a re-sent Enter could queue an empty turn.
|
|
# Deliver one best-effort Enter and return (see the docstring).
|
|
if _AGY_ACTIVE_MARKER in _capture_pane(socket_path, tmux_target):
|
|
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
|
|
return
|
|
if not draft_seen:
|
|
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
|
|
return
|
|
last_pane = _capture_pane(socket_path, tmux_target)
|
|
for _ in range(_MAX_SUBMIT_ATTEMPTS):
|
|
# Re-check liveness each attempt: if the TUI exited between the paste and
|
|
# now, every capture returns "" and the draft never clears — so fail
|
|
# fast with the real cause instead of spinning the full budget and then
|
|
# blaming paste-coalescing.
|
|
if not _session_alive(socket_path, tmux_target):
|
|
raise RuntimeError(
|
|
"the agy terminal exited before the message could be submitted; "
|
|
"restart the session"
|
|
)
|
|
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
|
|
deadline = time.monotonic() + _SUBMIT_VERIFY_TIMEOUT_S
|
|
while time.monotonic() < deadline:
|
|
if not _session_alive(socket_path, tmux_target):
|
|
raise RuntimeError(
|
|
"the agy terminal exited before the message could be submitted; "
|
|
"restart the session"
|
|
)
|
|
pane = _capture_pane(socket_path, tmux_target)
|
|
last_pane = pane or last_pane
|
|
if pane and not _draft_in_input_region(pane, needle, baseline_region):
|
|
return
|
|
time.sleep(_TMUX_POLL_INTERVAL_S)
|
|
draft_visible = _draft_in_input_region(last_pane, needle, baseline_region)
|
|
raise RuntimeError(
|
|
"agy did not accept the submitted message; the draft is still visible "
|
|
"in the input box; "
|
|
f"draft_visible={draft_visible}; pane_tail:\n{_format_pane_debug_tail(last_pane)}"
|
|
)
|
|
|
|
|
|
def inject_user_message_via_tui(
|
|
bridge_dir: Path,
|
|
*,
|
|
content: str,
|
|
timeout_s: float = _TMUX_READY_TIMEOUT_S,
|
|
) -> None:
|
|
"""
|
|
Deliver a user message into the agy TUI via a tmux bracketed paste + Enter.
|
|
|
|
The executor's delivery path for EVERY web/mobile turn (sequential and
|
|
mid-turn steer): a turn delivered over agy's connect-RPC ``SendAgentMessage``
|
|
is recorded as a ``SYSTEM_MESSAGE`` the forwarder would not mirror, whereas
|
|
typing into the TUI creates a real ``USER_INPUT`` step the forwarder mirrors
|
|
in order. On a fresh session this also creates agy's conversation (agy mints
|
|
its id only after processing a turn), which the forwarder then discovers.
|
|
|
|
Steps: wait for the advertised tmux target and the agy input box (idle OR a
|
|
running turn — agy accepts a mid-turn paste), clear any leftover draft (Home +
|
|
kill-to-end), stream the content through a named tmux buffer
|
|
(``load-buffer``/``paste-buffer -p`` so interior newlines stay data and a
|
|
large message is not capped by the send-keys argv limit), wait for the draft
|
|
to render, then submit and verify the draft left the live composer (see
|
|
:func:`_submit_and_verify`).
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory holding ``tmux.json``.
|
|
:param content: User text from the web UI. Must be non-empty.
|
|
:param timeout_s: Total readiness budget, shared across the two gates
|
|
(``tmux.json`` advertised, then the input box mounted) so the worst case
|
|
is one ``timeout_s``, not two stacked.
|
|
:returns: None.
|
|
:raises RuntimeError: When the tmux target is never advertised, the agy TUI
|
|
has exited, a tmux command fails, or the submit never starts a turn.
|
|
"""
|
|
if not content:
|
|
raise RuntimeError("antigravity-native TUI injection requires non-empty content")
|
|
# One shared deadline across both readiness gates: the prompt-ready gate gets
|
|
# only the budget the tmux-target gate did not consume, capping total
|
|
# readiness latency at timeout_s (the gates were previously each given a full
|
|
# timeout_s, so a stuck launch cost 2x before delivery even began).
|
|
deadline = time.monotonic() + timeout_s
|
|
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
|
socket_path = info["socket_path"]
|
|
tmux_target = info["tmux_target"]
|
|
# Fast-fail if the TUI already exited: otherwise the readiness gate polls a
|
|
# dead pane for the full timeout and the web message is silently lost. A clear
|
|
# error lets the executor surface an ExecutorError so the UI can say "restart".
|
|
if not _session_alive(socket_path, tmux_target):
|
|
raise RuntimeError(
|
|
"the agy terminal is no longer running (the TUI exited); restart the session"
|
|
)
|
|
_wait_for_agy_prompt_ready(
|
|
socket_path, tmux_target, timeout_s=max(0.0, deadline - time.monotonic())
|
|
)
|
|
# Clear any leftover draft before typing: Home (C-a) + kill-to-end (C-k).
|
|
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-a")
|
|
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-k")
|
|
baseline_region = _agy_input_region(_capture_pane(socket_path, tmux_target))
|
|
# ``delete=False`` + name captured BEFORE the write, so a write failure still
|
|
# leaves a path the finally can unlink (no leaked temp file in the bridge dir).
|
|
paste_path: str | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
dir=bridge_dir, prefix="paste_", suffix=".bin", delete=False
|
|
) as paste_file:
|
|
paste_path = paste_file.name
|
|
# Trailing newline absorbs any trailing backslash so it can't escape Enter.
|
|
paste_file.write(_paste_payload_bytes(content + "\n"))
|
|
_run_tmux(socket_path, "load-buffer", "-b", _PASTE_BUFFER, paste_path)
|
|
_run_tmux(
|
|
socket_path,
|
|
"paste-buffer",
|
|
"-p", # bracketed-paste markers — the TUI keeps newlines as data
|
|
"-d", # drop the buffer after pasting (no stale copies server-side)
|
|
"-b",
|
|
_PASTE_BUFFER,
|
|
"-t",
|
|
tmux_target,
|
|
)
|
|
finally:
|
|
if paste_path is not None:
|
|
with contextlib.suppress(OSError):
|
|
os.unlink(paste_path)
|
|
# Wait until the paste is visibly committed to the input box before Enter, so
|
|
# the submit is not folded into the paste burst (see _submit_and_verify). This
|
|
# runs for EVERY message — including short ones with no stable needle — so a
|
|
# paste that never lands in the composer is caught rather than submitted blind
|
|
# (``_draft_in_input_region`` keys off composer change when the needle is "").
|
|
needle = _submit_needle(content)
|
|
# A mid-turn steer (agy already running a turn) is delivered best-effort: agy
|
|
# queues it as the next turn and may not surface the draft in the composer the
|
|
# same way an idle turn does, so a non-render is NOT a swallowed message here.
|
|
# The render hard-fail below is for the idle/sequential case, where a paste
|
|
# that never lands in the composer (e.g. eaten by an unhooked modal such as
|
|
# the first-run trust gate) must fail loudly instead of vanishing.
|
|
mid_turn = _AGY_ACTIVE_MARKER in _capture_pane(socket_path, tmux_target)
|
|
draft_seen = False
|
|
last_commit_pane = ""
|
|
commit_deadline = time.monotonic() + _PASTE_COMMIT_TIMEOUT_S
|
|
while time.monotonic() < commit_deadline:
|
|
pane = _capture_pane(socket_path, tmux_target)
|
|
last_commit_pane = pane or last_commit_pane
|
|
if _draft_in_input_region(pane, needle, baseline_region):
|
|
draft_seen = True
|
|
break
|
|
time.sleep(_TMUX_POLL_INTERVAL_S)
|
|
if not draft_seen and not mid_turn:
|
|
raise RuntimeError(
|
|
"agy did not render the pasted message in its input box before submit; "
|
|
f"pane_tail:\n{_format_pane_debug_tail(last_commit_pane)}"
|
|
)
|
|
time.sleep(_PASTE_SETTLE_S)
|
|
_submit_and_verify(
|
|
socket_path,
|
|
tmux_target,
|
|
needle=needle,
|
|
baseline_region=baseline_region,
|
|
draft_seen=draft_seen,
|
|
)
|
|
|
|
|
|
def send_interaction_keys_via_tui(
|
|
bridge_dir: Path,
|
|
*keys: str,
|
|
) -> None:
|
|
"""
|
|
Send raw key arguments to the agy TUI pane to answer its in-process prompt.
|
|
|
|
The companion to :func:`inject_user_message_via_tui` for the OTHER thing the
|
|
attended agy TUI maintains in parallel with the RPC step state: its
|
|
**in-process permission / question prompt**. When agy runs attended (the
|
|
web/mobile path types every turn into the TUI), an approval surfaces in the
|
|
Omnigent web UI AND as agy's own numbered TUI prompt ("Do you want to
|
|
proceed?", 1.Yes … 4.No). Delivering the verdict over
|
|
:func:`omnigent.antigravity_native_rpc.handle_user_interaction` flips the
|
|
backend trajectory step to DONE and the command runs, but the **TUI prompt
|
|
for that interaction can stay open** — and the next typed turn then lands in
|
|
that stale prompt's filter/amend buffer instead of starting a fresh turn
|
|
(live-verified; see ``docs/claude/antigravity-rpc-spike-notes.md`` §"attended
|
|
TUI"). So the bridge ALSO types the selection into the pane to dismiss the
|
|
prompt, mirroring cursor-native's
|
|
:func:`omnigent.cursor_native_bridge.send_cursor_pane_keys`.
|
|
|
|
Each argument is a tmux ``send-keys`` key argument — a bare ``"1"`` / ``"4"``
|
|
selects a numbered option, ``"Enter"`` confirms, ``"Escape"`` cancels — sent
|
|
in order as ONE ``send-keys`` invocation so the digit and its Enter cannot be
|
|
reordered or split across the pane's input handling. Keys are interpreted by
|
|
tmux (no ``-l``), so named keys like ``Enter`` / ``Escape`` work.
|
|
|
|
:param bridge_dir: Native Antigravity bridge directory holding ``tmux.json``.
|
|
:param keys: Ordered tmux key arguments, e.g. ``("1", "Enter")`` to approve.
|
|
:returns: None.
|
|
:raises RuntimeError: When the tmux target is not advertised, the agy TUI has
|
|
exited, or the ``send-keys`` invocation fails.
|
|
"""
|
|
if not keys:
|
|
raise RuntimeError("antigravity-native TUI selection requires at least one key")
|
|
info = read_tmux_info(bridge_dir)
|
|
if info is None:
|
|
raise RuntimeError(
|
|
"antigravity-native tmux target not advertised (is the agy terminal running?)"
|
|
)
|
|
socket_path = info["socket_path"]
|
|
tmux_target = info["tmux_target"]
|
|
if not _session_alive(socket_path, tmux_target):
|
|
raise RuntimeError(
|
|
"the agy terminal is no longer running (the TUI exited); restart the session"
|
|
)
|
|
_run_tmux(socket_path, "send-keys", "-t", tmux_target, *keys)
|