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

759 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Bridge utilities for native Kiro TUI sessions."""
from __future__ import annotations
import contextlib
import hashlib
import json
import os
import secrets
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
KIRO_NATIVE_BRIDGE_DIR_ENV_VAR = "HARNESS_KIRO_NATIVE_BRIDGE_DIR"
KIRO_ACP_RECORD_PATH_ENV_VAR = "KIRO_ACP_RECORD_PATH"
_BRIDGE_ROOT = Path(os.environ.get("TMPDIR", "/tmp")) / f"omnigent-{os.getuid()}" / "kiro-native"
_TMUX_FILE = "tmux.json"
_FORWARDER_READY_FILE = "kiro_session_forwarder_ready.json"
_ACP_RECORD_FILE = "kiro_acp_record.jsonl"
# Shared Omnigent MCP relay (serve-mcp) registration for kiro.
_MCP_SERVER_NAME = "omnigent"
_MCP_BRIDGE_CONFIG_FILE = "bridge.json"
# kiro reads workspace-scoped MCP servers from ``<workspace>/.kiro/settings/mcp.json``
# (confirmed against kiro-cli 2.10.0). Mirrors cursor-native's ``.cursor/mcp.json``.
_WORKSPACE_MCP_CONFIG_REL = Path(".kiro") / "settings" / "mcp.json"
_TMUX_READY_TIMEOUT_S = 30.0
_TMUX_SEND_TIMEOUT_S = 10.0
_POLL_INTERVAL_S = 0.2
_TYPE_SETTLE_S = 0.3
_TYPE_COMMIT_TIMEOUT_S = 5.0
_SUBMIT_VERIFY_TIMEOUT_S = 5.0
_SUBMIT_RETRY_INTERVAL_S = 0.5
_PERMISSION_KEY_INTERVAL_S = 0.3
_PERMISSION_ENTER_SETTLE_S = 0.5
_KIRO_SEPARATOR = "────"
_KIRO_INPUT_READY_MARKERS = (
"ask a question or describe a task",
"Type to steer",
)
_PASTE_BUFFER = "omnigent-kiro-paste"
_KIRO_PERMISSION_MARKERS = (
"requires approval",
"Yes, single permission",
"Trust, always allow in this session",
"No (Tab to edit)",
)
# Ambient provider/cloud/CI credentials that must not be inherited by Kiro.
KIRO_NATIVE_ENV_UNSET = [
"ANTHROPIC_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AZURE_CLIENT_SECRET",
"CI",
"DATABRICKS_CLIENT_SECRET",
"DATABRICKS_CONFIG_PROFILE",
"DATABRICKS_HOST",
"DATABRICKS_TOKEN",
"GH_TOKEN",
"GITHUB_TOKEN",
"GOOGLE_API_KEY",
"OPENAI_API_KEY",
]
_CHILD_ENV_ALLOWLIST = [
"COLORTERM",
"HOME",
"KIRO_CONFIG_HOME",
"KIRO_HOME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LOGNAME",
"NO_COLOR",
"PATH",
"SHELL",
"TERM",
"TMPDIR",
"USER",
]
def bridge_root() -> Path:
"""Return the uid-scoped Kiro-native bridge root.
Mirrors the sibling harnesses' ``bridge_root`` accessor so the shared
``serve-mcp`` / relay infrastructure in ``claude_native_bridge`` can
recognize Kiro bridge dirs as a trusted root.
"""
return _BRIDGE_ROOT
def bridge_dir_for_session_id(session_id: str) -> Path:
"""Return the per-session Kiro bridge directory."""
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:32]
return _BRIDGE_ROOT / digest
def prepare_bridge_dir(session_id: str) -> Path:
"""Create and return the per-session Kiro bridge directory."""
bridge_dir = bridge_dir_for_session_id(session_id)
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
with contextlib.suppress(OSError):
os.chmod(bridge_dir, 0o700)
return bridge_dir
def acp_record_path(bridge_dir: Path) -> Path:
"""Return the per-session Kiro TUI ACP recorder file path."""
return bridge_dir / _ACP_RECORD_FILE
def write_mcp_bridge_config(bridge_dir: Path) -> None:
"""Write the token config the shared Omnigent MCP bridge requires at boot.
``serve-mcp`` (``omnigent.claude_native_bridge``) reads ``bridge.json`` and
refuses to start without a ``token``. Mirrors cursor-native's writer;
idempotent so a resume reuses the existing token.
"""
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
config_path = bridge_dir / _MCP_BRIDGE_CONFIG_FILE
if config_path.exists():
return
payload = {"token": secrets.token_urlsafe(32)}
tmp = bridge_dir / (_MCP_BRIDGE_CONFIG_FILE + ".tmp")
tmp.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
os.replace(tmp, config_path)
def build_kiro_mcp_config(
bridge_dir: Path, *, python_executable: str | None = None
) -> dict[str, Any]:
"""Build the kiro ``mcpServers`` entry for the Omnigent relay MCP server.
Reuses the shared stdio ``serve-mcp`` server (the same one cursor/claude use)
pointed at this session's bridge dir. kiro's mcp.json schema is
``{"mcpServers": {name: {command, args, env}}}`` (kiro-cli 2.10.0); it has no
per-server auto-approve field, so Omnigent MCP tool calls surface through
kiro's approval prompt (mirrored to the web as elicitation cards) rather than
being auto-trusted here.
"""
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),
],
"env": {"TMPDIR": os.environ.get("TMPDIR", "/tmp")},
}
}
}
def write_kiro_workspace_mcp_config(
workspace: Path,
bridge_dir: Path,
*,
python_executable: str | None = None,
) -> Path:
"""Write the workspace-scoped kiro MCP config declaring the Omnigent server.
kiro-cli has no launch-time mcp-config flag, so the server is declared in the
workspace's ``.kiro/settings/mcp.json`` (mirrors cursor-native's
``.cursor/mcp.json``). The Omnigent entry is *merged* into any existing
workspace config so a user's own workspace MCP servers are preserved. Also
writes the bridge ``token`` ``serve-mcp`` needs. Returns the config path.
"""
write_mcp_bridge_config(bridge_dir)
path = workspace / _WORKSPACE_MCP_CONFIG_REL
path.parent.mkdir(parents=True, exist_ok=True)
config: dict[str, Any] = {}
if path.exists():
with contextlib.suppress(OSError, ValueError):
loaded = json.loads(path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
config = loaded
servers = config.get("mcpServers")
if not isinstance(servers, dict):
servers = {}
servers[_MCP_SERVER_NAME] = build_kiro_mcp_config(
bridge_dir, python_executable=python_executable
)["mcpServers"][_MCP_SERVER_NAME]
config["mcpServers"] = servers
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8")
os.replace(tmp, path)
return path
def build_kiro_native_spawn_env(session_id: str) -> dict[str, str]:
"""Build the ``HARNESS_KIRO_NATIVE_*`` env for the harness executor."""
bridge_dir = prepare_bridge_dir(session_id)
return {KIRO_NATIVE_BRIDGE_DIR_ENV_VAR: str(bridge_dir)}
def build_kiro_native_terminal_env(
session_id: str,
*,
source_env: dict[str, str] | None = None,
) -> dict[str, str]:
"""Build the allowlisted child environment for ``kiro-cli``."""
env = os.environ if source_env is None else source_env
child = {key: env[key] for key in _CHILD_ENV_ALLOWLIST if env.get(key)}
bridge_dir = prepare_bridge_dir(session_id)
child[KIRO_NATIVE_BRIDGE_DIR_ENV_VAR] = str(bridge_dir)
child[KIRO_ACP_RECORD_PATH_ENV_VAR] = str(acp_record_path(bridge_dir))
return child
def write_tmux_target(
bridge_dir: Path,
*,
socket_path: Path,
tmux_target: str,
pid: int | None = None,
requires_forwarder_ready: bool = False,
) -> None:
"""Advertise the tmux socket + target for the running Kiro terminal."""
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
payload: dict[str, Any] = {
"socket_path": str(socket_path),
"tmux_target": tmux_target,
"updated_at": time.time(),
}
if requires_forwarder_ready:
payload["requires_forwarder_ready"] = True
if pid is not None:
payload["pid"] = pid
tmp = bridge_dir / (_TMUX_FILE + ".tmp")
tmp.write_text(json.dumps(payload), encoding="utf-8")
os.replace(tmp, bridge_dir / _TMUX_FILE)
def read_tmux_info(bridge_dir: Path) -> dict[str, str] | None:
"""Return ``{socket_path, tmux_target}`` from ``tmux.json``, or ``None``."""
try:
raw = (bridge_dir / _TMUX_FILE).read_text(encoding="utf-8")
except OSError:
return None
try:
data = json.loads(raw)
except ValueError:
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 write_forwarder_ready(bridge_dir: Path) -> None:
"""Mark the Kiro JSONL forwarder as attached and caught up."""
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
payload = {"updated_at": time.time()}
tmp = bridge_dir / (_FORWARDER_READY_FILE + ".tmp")
tmp.write_text(json.dumps(payload), encoding="utf-8")
os.replace(tmp, bridge_dir / _FORWARDER_READY_FILE)
def _read_bridge_json(bridge_dir: Path, filename: str) -> dict[str, Any] | None:
try:
raw = (bridge_dir / filename).read_text(encoding="utf-8")
except OSError:
return None
try:
data = json.loads(raw)
except ValueError:
return None
return data if isinstance(data, dict) else None
def _wait_for_forwarder_ready_if_required(
bridge_dir: Path,
*,
tmux_info: dict[str, Any],
timeout_s: float,
) -> None:
if tmux_info.get("requires_forwarder_ready") is not True:
return
tmux_updated_at = tmux_info.get("updated_at")
if not isinstance(tmux_updated_at, int | float):
tmux_updated_at = 0.0
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
ready = _read_bridge_json(bridge_dir, _FORWARDER_READY_FILE)
ready_updated_at = ready.get("updated_at") if ready is not None else None
if isinstance(ready_updated_at, int | float) and ready_updated_at >= tmux_updated_at:
return
time.sleep(_POLL_INTERVAL_S)
raise RuntimeError("kiro-native session forwarder was not ready before injection")
def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
info = read_tmux_info(bridge_dir)
if info is not None:
return info
time.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"kiro-native tmux target was not advertised within {timeout_s:.0f}s")
def _run_tmux(socket_path: str, *args: str) -> None:
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}s") 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 _session_alive(socket_path: str, tmux_target: str) -> bool:
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 _capture_pane(socket_path: str, tmux_target: str) -> str:
"""Capture visible pane contents; return empty string on failure."""
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 _submit_needle(content: str) -> str:
"""Return a small marker used to identify the pasted draft."""
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
for line in normalized.split("\n"):
for idx, ch in enumerate(line):
if ord(ch) < 0x20:
line = line[:idx]
break
line = line.strip()
if line:
return line[:24]
return ""
def _kiro_input_region(pane: str) -> str:
"""Return Kiro's bottom input region, excluding transcript history."""
lines = pane.splitlines()
for index in range(len(lines) - 1, -1, -1):
if _KIRO_SEPARATOR in lines[index]:
return "\n".join(lines[index + 1 :])
return "\n".join(lines[-8:])
def _draft_in_input_region(pane: str, needle: str, baseline_region: str) -> bool:
"""Return whether the draft is still visible in Kiro's input region."""
region = _kiro_input_region(pane)
if not needle or region == baseline_region:
return False
normalized_needle = needle.strip()
if not normalized_needle:
return False
return any(
line == normalized_needle or line.startswith(normalized_needle)
for line in _kiro_draft_candidate_lines(region)
)
def _kiro_draft_candidate_lines(region: str) -> list[str]:
"""Return input-region lines that can represent editable draft text."""
candidates: list[str] = []
for raw_line in region.splitlines():
line = raw_line.strip()
if not line:
continue
if line.startswith("kiro_default"):
continue
if line.startswith("/copy"):
continue
if line.startswith("▸ Credits:"):
continue
if any(marker in line for marker in _KIRO_INPUT_READY_MARKERS):
continue
candidates.append(line)
return candidates
def _kiro_input_ready(pane: str) -> bool:
"""Return whether Kiro's bottom input prompt is ready to receive text."""
region = _kiro_input_region(pane)
return any(marker in region for marker in _KIRO_INPUT_READY_MARKERS)
def _kiro_permission_prompt_active(pane: str) -> bool:
"""Return whether Kiro's visible approval prompt is active."""
return all(marker in pane for marker in _KIRO_PERMISSION_MARKERS)
def _kiro_permission_focus_on_one_time_allow(pane: str) -> bool:
"""Return whether Kiro's approval picker is focused on one-time allow."""
return any(line.strip().startswith(" Yes, single permission") for line in pane.splitlines())
def _kiro_permission_focus_on_reject(pane: str) -> bool:
"""Return whether Kiro's approval picker is focused on one-time reject."""
return any(line.strip().startswith(" No (Tab to edit)") for line in pane.splitlines())
def _kiro_active_permission_tool_line(pane: str) -> str:
"""Return the tool line associated with the active approval panel."""
lines = pane.splitlines()
approval_index = -1
for index, line in enumerate(lines):
if "requires approval" in line:
approval_index = index
if approval_index < 0:
return ""
for line in reversed(lines[:approval_index]):
stripped = line.strip()
if not stripped or _KIRO_SEPARATOR in stripped:
continue
return stripped.lstrip("↓●○✓✗ ").strip()
return ""
def _kiro_permission_prompt_matches_title(pane: str, expected_title: str | None) -> bool:
"""Return whether the visible prompt appears to match the parsed request title."""
if not expected_title:
return True
title = expected_title.strip()
if not title:
return True
tool_line = _kiro_active_permission_tool_line(pane)
if not tool_line:
return False
if title == tool_line:
return True
if title.startswith("Running:"):
command = title.removeprefix("Running:").strip()
return bool(command and (tool_line == command or tool_line.endswith(f" {command}")))
return title in tool_line
def _wait_for_kiro_permission_prompt(
socket_path: str,
tmux_target: str,
*,
expected_title: str | None,
timeout_s: float,
) -> None:
"""Wait until Kiro has rendered an approval prompt before typing a verdict."""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
pane = _capture_pane(socket_path, tmux_target)
if (
_kiro_permission_prompt_active(pane)
and _kiro_permission_focus_on_one_time_allow(pane)
and _kiro_permission_prompt_matches_title(pane, expected_title)
):
return
time.sleep(_POLL_INTERVAL_S)
raise RuntimeError(
"kiro-native permission prompt was not safely focused before verdict delivery"
)
def _wait_for_kiro_input_ready(
socket_path: str,
tmux_target: str,
*,
timeout_s: float,
) -> None:
"""Wait until Kiro has rendered an input prompt before typing."""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if _kiro_input_ready(_capture_pane(socket_path, tmux_target)):
return
time.sleep(_POLL_INTERVAL_S)
raise RuntimeError("kiro-native TUI input prompt was not ready before injection")
def _paste_payload_bytes(text: str) -> bytes:
r"""Encode text for ``tmux load-buffer``: line breaks → CR, tabs kept, other
control bytes dropped (a stray ESC would close the bracketed-paste early)."""
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 _paste_literal_text(socket_path: str, tmux_target: str, bridge_dir: Path, text: str) -> None:
"""Deliver text into Kiro via a tmux bracketed paste (multi-line safe).
``send-keys -l`` sends interior newlines as raw Enter keys, so a multi-line
web message submits line-by-line on the first break. ``load-buffer`` +
``paste-buffer -p`` wraps the text in bracketed-paste markers so Kiro's
composer keeps the line breaks (encoded as CR by :func:`_paste_payload_bytes`)
as draft data, not submits. Mirrors cursor-native / goose-native; the trailing
newline absorbs any trailing backslash so it can't escape the follow-up Enter.
"""
with tempfile.NamedTemporaryFile(
dir=bridge_dir, prefix="paste_", suffix=".bin", delete=False
) as paste_file:
paste_file.write(_paste_payload_bytes(text + "\n"))
paste_path = paste_file.name
try:
_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
"-b",
_PASTE_BUFFER,
"-t",
tmux_target,
)
finally:
with contextlib.suppress(OSError):
os.unlink(paste_path)
def inject_user_message(
bridge_dir: Path,
*,
content: str,
timeout_s: float = _TMUX_READY_TIMEOUT_S,
) -> None:
"""Deliver a web-UI user message into the Kiro TUI via tmux typing."""
if not content:
raise RuntimeError("kiro-native injection requires non-empty content")
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
raw_info = _read_bridge_json(bridge_dir, _TMUX_FILE) or {}
_wait_for_forwarder_ready_if_required(
bridge_dir,
tmux_info=raw_info,
timeout_s=timeout_s,
)
socket_path = info["socket_path"]
tmux_target = info["tmux_target"]
if not _session_alive(socket_path, tmux_target):
raise RuntimeError(
"kiro terminal is no longer running (the TUI exited); restart the session"
)
_wait_for_kiro_input_ready(socket_path, tmux_target, timeout_s=timeout_s)
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-a")
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-k")
baseline_region = _kiro_input_region(_capture_pane(socket_path, tmux_target))
_paste_literal_text(socket_path, tmux_target, bridge_dir, content)
needle = _submit_needle(content)
draft_seen = False
if needle:
deadline = time.monotonic() + _TYPE_COMMIT_TIMEOUT_S
while time.monotonic() < deadline:
if _draft_in_input_region(
_capture_pane(socket_path, tmux_target), needle, baseline_region
):
draft_seen = True
break
time.sleep(_POLL_INTERVAL_S)
time.sleep(_TYPE_SETTLE_S)
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
if not draft_seen:
return
deadline = time.monotonic() + _SUBMIT_VERIFY_TIMEOUT_S
last_enter = time.monotonic()
while time.monotonic() < deadline:
time.sleep(_POLL_INTERVAL_S)
if not _draft_in_input_region(
_capture_pane(socket_path, tmux_target), needle, baseline_region
):
return
if time.monotonic() - last_enter >= _SUBMIT_RETRY_INTERVAL_S:
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
last_enter = time.monotonic()
raise RuntimeError("Kiro did not accept the submitted message; the draft is still visible")
def inject_interrupt(bridge_dir: Path, *, timeout_s: float = _TMUX_READY_TIMEOUT_S) -> None:
"""Cancel the in-flight Kiro turn by sending ``Escape`` to the pane.
The harness ``run_turn`` returns right after the paste, so the runner's
in-process cancel floor can't reach the turn — this is the analog of
:func:`inject_user_message` for the web UI's Stop button. ``Escape`` stops a
running Kiro turn and (verified against kiro-cli 2.10.0) leaves the composer
at an empty prompt, so no draft-clear is needed afterwards: unlike
cursor-native, Kiro does not restore the interrupted prompt. Mirrors
:func:`omnigent.goose_native_bridge.inject_interrupt`.
:raises RuntimeError: If the tmux target is not advertised or send-keys fails.
"""
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
# No ``-l``: tmux must interpret ``Escape`` as a key name.
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Escape")
def kill_session(bridge_dir: Path, *, timeout_s: float = _TMUX_READY_TIMEOUT_S) -> None:
"""Hard-stop the Kiro session by killing its tmux session.
Terminates ``kiro-cli`` and the pane outright — the analog of the user
manually exiting the attached TUI, for the web UI's "Stop session"
affordance. Mirrors :func:`omnigent.goose_native_bridge.kill_session`.
:raises RuntimeError: If the tmux target is not advertised or kill-session fails.
"""
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
def send_kiro_permission_verdict(
bridge_dir: Path,
*,
action: str,
expected_title: str | None = None,
timeout_s: float = _TMUX_READY_TIMEOUT_S,
) -> None:
"""Deliver a one-time Kiro permission verdict to the active TUI prompt."""
if action not in {"accept", "decline", "cancel"}:
raise RuntimeError(f"unsupported Kiro permission action: {action!r}")
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
socket_path = info["socket_path"]
tmux_target = info["tmux_target"]
if not _session_alive(socket_path, tmux_target):
raise RuntimeError(
"kiro terminal is no longer running (the TUI exited); restart the session"
)
_wait_for_kiro_permission_prompt(
socket_path, tmux_target, expected_title=expected_title, timeout_s=timeout_s
)
if action == "accept":
time.sleep(_PERMISSION_ENTER_SETTLE_S)
pane = _capture_pane(socket_path, tmux_target)
if not (
_kiro_permission_prompt_active(pane)
and _kiro_permission_focus_on_one_time_allow(pane)
and _kiro_permission_prompt_matches_title(pane, expected_title)
):
raise RuntimeError("kiro-native allow option was not safely focused before delivery")
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
time.sleep(_PERMISSION_KEY_INTERVAL_S)
return
for key in ("Down", "Down"):
_run_tmux(socket_path, "send-keys", "-t", tmux_target, key)
time.sleep(_PERMISSION_KEY_INTERVAL_S)
pane = _capture_pane(socket_path, tmux_target)
if not (
_kiro_permission_prompt_active(pane)
and _kiro_permission_focus_on_reject(pane)
and _kiro_permission_prompt_matches_title(pane, expected_title)
):
raise RuntimeError("kiro-native reject option was not safely focused before delivery")
time.sleep(_PERMISSION_ENTER_SETTLE_S)
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
time.sleep(_PERMISSION_KEY_INTERVAL_S)
# kiro prints "Model changed to <id> (saved as default)" after a successful
# ``/model`` switch; the injector polls for this to confirm the switch landed.
_MODEL_CHANGED_MARKER = "Model changed to"
# The switch itself takes a couple of seconds (kiro round-trips the change), so
# the confirmation poll uses its own timeout rather than the short pane-readiness
# ``timeout_s`` the runner passes to fail fast when the pane isn't attached.
_MODEL_CONFIRM_TIMEOUT_S = 10.0
def inject_model_command(
bridge_dir: Path,
*,
model: str,
timeout_s: float = _TMUX_READY_TIMEOUT_S,
) -> None:
"""Switch the live kiro model by typing ``/model <id>`` into the TUI.
kiro-cli's ``--model`` is baked in at spawn, so a mid-session web pick can't
be applied by re-reading the persisted ``model_override`` — it has to be
typed into the running pane. kiro's ``/model <id>`` switches directly (no
picker) and prints ``Model changed to <id>``; poll for that so a typo'd or
unavailable id fails loudly rather than silently leaving the model unchanged.
The cursor-native analog is
:func:`omnigent.cursor_native_bridge.inject_model_command`.
Note: kiro persists the switch as its own global default ("saved as
default"), so a live switch also affects the next fresh kiro launch.
:param bridge_dir: The kiro-native bridge dir holding ``tmux.json``.
:param model: kiro model id, e.g. ``"claude-haiku-4.5"`` (a ``--list-models`` id).
:param timeout_s: Per-readiness-gate / confirmation timeout.
:raises RuntimeError: If the tmux target is never advertised, the TUI has
exited, a tmux command fails, or kiro never confirms the switch.
"""
model = model.strip()
if not model:
raise RuntimeError("kiro-native model switch requires a non-empty model id")
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
socket_path = info["socket_path"]
tmux_target = info["tmux_target"]
if not _session_alive(socket_path, tmux_target):
raise RuntimeError(
"kiro terminal is no longer running (the TUI exited); restart the session"
)
_wait_for_kiro_input_ready(socket_path, tmux_target, timeout_s=timeout_s)
# Clear any leftover draft so the slash command isn't appended to it.
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-a")
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-k")
# ``-l`` sends literal characters so ``/`` opens the slash command and the id
# is not parsed as tmux key names.
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "-l", f"/model {model}")
time.sleep(_TYPE_SETTLE_S)
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
# Confirm via kiro's "Model changed to <id>" line so a bad id fails loudly.
deadline = time.monotonic() + _MODEL_CONFIRM_TIMEOUT_S
while time.monotonic() < deadline:
if f"{_MODEL_CHANGED_MARKER} {model}" in _capture_pane(socket_path, tmux_target):
return
time.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"kiro-native did not confirm the model switch to {model!r}")