edbdca8c0e
* feat(hermes): add native Hermes TUI harness (hermes-native) Adds `hermes-native`, the native counterpart to the headless `hermes` harness (#1132), following the goose-native pattern: `omnigent hermes` launches the real `hermes` prompt_toolkit TUI in a runner-owned tmux pane, the harness executor injects each web turn via tmux bracketed paste, and a forwarder tails Hermes' SQLite `state.db` to mirror the transcript back into the Omnigent chat view. Unlike goose-native, Hermes auto-generates its session id (no `--name`), so the forwarder discovers the session cursor-native style: newest `sessions` row whose `cwd` matches the workspace and `started_at` is at/after the launch floor, with a claim guard for concurrent same-cwd sessions. Like goose-native it applies no Omnigent policy hooks — the TUI's own approval prompts gate tools, using the user's own `~/.hermes` config. New modules: hermes_native.py (CLI), hermes_native_bridge.py (tmux inject), hermes_native_forwarder.py (state.db mirror), inner/hermes_native_executor.py + hermes_native_harness.py. Wires the harness registry, aliases, native-coding-agent metadata, runner terminal spawn/interrupt/stop, CLI subcommand, resume dispatch, onboarding readiness, and the ap-web frontend entry. Adds unit tests for the executor, CLI/wiring, and forwarder (discovery, claim guard, mirroring). Co-authored-by: Isaac * fix(ap-web): add "hermes" to ConversationIconKind so the web UI builds getConversationIconKind returns a native agent's iconKind (now including "hermes") as a ConversationIconKind; the union was missing "hermes", so `tsc -b` failed (TS2322) and broke `omnigent[all]` install (web UI build). Mirrors how "qwen" — also glyph-less — is listed in both unions. Co-authored-by: Isaac * fix(hermes-native): render as a native terminal + keep the gold TUI colors Two fixes from live testing: - Add `terminal_hermes_main` to ap-web's AGENT_TERMINAL_IDS so isAgentTerminalKey recognizes the hermes pane as the agent terminal; without it isShellView treated it as a plain shell (and it leaked into the Shells inventory) — the same regression pi/cursor/goose/qwen each hit. Adds the matching test. - Drop the NO_COLOR=1 env on the hermes terminal: it disabled Hermes' themed TUI (gold prompt rendered white). The bridge captures the pane with `capture-pane -p` (ANSI stripped) and the forwarder reads SQLite, so color never interferes with scraping. Co-authored-by: Isaac * feat(hermes-native): route tool calls through Omnigent policy (web approval) The native Hermes TUI now gates tools via Omnigent's approval flow, matching claude-/codex-native. The runner builds a per-session HERMES_HOME (the user's full ~/.hermes config copied in, minus state.db, + Omnigent's pre_tool_call shell hook layered on) and launches the TUI with HERMES_HOME=<dir> and HERMES_YOLO_MODE=1. The hook calls the server's policy evaluate endpoint, which parks on an ASK policy until the human responds to the web approval card; YOLO suppresses Hermes' own in-TUI prompt so the web card is the sole gate (the hook fires before, and independent of, Hermes' approval check per model_tools.py). The forwarder tails the per-session HERMES_HOME/state.db. Adds a unit test. Co-authored-by: Isaac * feat(goose-native): route tool calls through Omnigent policy (web approval) The native Goose TUI now gates tools via Omnigent's approval flow. The runner builds a per-session GOOSE_PATH_ROOT holding an Open-Plugins `omnigent-policy` plugin whose PreToolUse hook calls the server's policy evaluate endpoint (which parks on ASK until the human answers the web approval card). Goose's PreToolUse hook fires independent of GOOSE_MODE and denies on `{"decision":"block"}` — the same contract as the hermes hook. GOOSE_PATH_ROOT relocates all of Goose's dirs, so we symlink the real config/data/state back in (preserving the user's auth + the sessions.db the forwarder tails); the plugin lives only under the per-session root, so standalone `goose` never sees it. The hook reads its per-session _OMNIGENT_* values from the terminal env (Goose inherits env into hooks; verified no env_clear), failing open when unset. GOOSE_MODE=auto suppresses Goose's own in-TUI prompt so the web card is the sole gate. Real dirs are resolved by parsing `goose info` (ANSI- and space-tolerant); if they can't be parsed we launch without gating rather than break auth. Adds unit tests for the parser and plugin builder. Co-authored-by: Isaac * feat(policies): ask_on_os_tools recognizes Goose native tools Goose namespaces its built-in developer tools as developer__shell / developer__write / developer__edit / developer__text_editor / etc. Add them to ask_on_os_tools so the standard approval policy gates a native goose session's shell/file tools (web approval card) — without this the policy silently no-ops for goose-native. Adds parametrized coverage mirroring the pi/hermes cases. Co-authored-by: Isaac * fix(native): restore vendors' in-TUI approval (drop YOLO/auto + policy-hook gating) The policy-hook approach suppressed each vendor's own tool-approval prompt (HERMES_YOLO_MODE=1 / GOOSE_MODE=auto) so only a web card gated — which meant approvals showed only in the web chat, never in the TUI, and Hermes ran on YOLO. That's the wrong model for native TUIs. Revert the runner wiring to vendor-native approval: no HERMES_HOME/YOLO (Hermes uses ~/.hermes and its own approval prompt; forwarder tails ~/.hermes/state.db), and GOOSE_MODE=smart_approve so Goose prompts in its TUI. The prompt now appears in the terminal AND the web's embedded terminal pane (answerable from either). This is also step 1 of the chosen cursor-native-style synced mirror; step 2 (a web elicitation card mirrored from the TUI prompt) lands next. The per-session HERMES_HOME / GOOSE_PATH_ROOT policy-hook helpers are left in the tree, unused, pending that follow-up. Co-authored-by: Isaac * feat(native): synced web approval mirror for hermes-native & goose-native Surfaces each vendor's in-TUI approval prompt as a web elicitation card, synced both ways (answer in the terminal OR the web card) — the cursor-native pattern, now for Hermes and Goose. The vendor's own prompt stays the source of truth and the fallback; nothing is suppressed. - Generic POST /sessions/{id}/hooks/native-permission-request route: parks for the web verdict and labels the card per-vendor (agent/policy_name from body). - hermes_native_permissions.py: detects Hermes' `DANGEROUS COMMAND` / `Choice [o/s/a/D]:` block (confirmed against hermes-agent locales/en.yaml by running it from source), sends `o` (approve) / `d` (deny). - goose_native_permissions.py: detects Goose's cliclack `do you allow?` + Allow/Deny radio (from goose-cli prompt_tool_confirmation) and DRIVES the selector — `Enter` for the default Allow, `Down`×N + `Enter` for Deny (N=2 with "Always Allow", else 1). - capture_/send_*_pane helpers on both bridges; both mirrors run alongside the transcript forwarder under one supervised runner task (like cursor). The goose arrow-select driving is position-dependent and the one part worth confirming against a live Goose. Adds parser unit tests for both. Co-authored-by: Isaac * chore(native): drop the reverted policy-hook code, superseded by the mirror The earlier policy-hook elicitation approach (per-session HERMES_HOME and GOOSE_PATH_ROOT plugin) was reverted in favour of the cursor-native-style synced approval mirror, leaving its builders dead. Remove them: delete inner/goose_native_hook.py, drop setup_hermes_native_home / setup_goose_native_plugin_root / real_goose_dirs and their now-unused imports from the bridges (keeping the capture_/send_*_pane helpers the mirror uses), and remove the corresponding tests. Keep ask_on_os_tools' Goose tool-name coverage (useful for any policy that gates goose tools) and the headless harness's hermes_policy_hook.py (still used by `harness: hermes`). Co-authored-by: Isaac * fix(native): correct hermes approval detection + stop goose card pile-up Two live bugs in the approval mirrors: - goose cards piled up and re-appeared at the end: dedup keyed on a hash of the scraped tool context above the cliclack widget, which jitters every poll, so a new card parked each 0.3s and only the latest cleared on a TUI answer. Switch both mirrors to presence-edge: one card per visible-prompt episode (a per- session counter id), cleared on the falling edge. - hermes elicitation never fired: the interactive TUI renders the gate as a prompt_toolkit PANEL titled "⚠️ Dangerous Command" with NUMBERED choices (1. Allow once … 4. Deny), not the legacy `Choice [o/s/a/D]:` input() prompt (fail-closed under prompt_toolkit) that the parser keyed on. Rewrite the parser to detect the panel + read each choice's digit from the panel, and answer with that digit (Hermes' number-key binding selects AND confirms). Robust to the permanent-allowlist option (Deny is 4 with it, 3 without). Confirmed the panel/keys against hermes-agent cli.py by reading it; the goose arrow-select driving and these pane formats still want a live confirm. Tests updated to the real formats. Co-authored-by: Isaac * test(e2e_ui): add native Hermes render-parity suite (satisfies E2E UI gate) Mirrors test_native_goose_render_parity for hermes-native: composer→TUI parity, a TUI-originated turn surfacing in the web UI, and no duplicate rendering, plus a native_hermes_session fixture. Skips when hermes/tmux/config are absent (CI provisions no Hermes account), like the goose/cursor suites. Covers the ap-web Hermes native-agent UI behavior the E2E UI Required gate flagged. Co-authored-by: Isaac * chore(openapi): regenerate openapi.json for native-permission-request route The new POST /sessions/{id}/hooks/native-permission-request route made the checked-in openapi.json stale, failing the Pytest (server-rest) drift test. Regenerated via scripts/dump_openapi.py. Co-authored-by: Isaac * test(native): cover the bridges, approval mirrors, forwarder loop, and CLI helpers The new native modules dropped total coverage below baseline (Coverage gate), and the e2e suites that would exercise them skip in CI (no vendor binaries). Add unit tests: tmux bridge (inject/capture/send/spawn-env, mocked tmux); both approval mirrors (_run_one_approval keystrokes, external_elicitation_resolved, one-card-per-episode supervise); the hermes forwarder loop (discover→mirror) + _post_conversation_item; and hermes_native CLI/daemon helpers (spec, payload decode, tmux-availability, daemon-flow HTTP via a fake client). Lifts the new modules from ~46% to ~70-85%. Co-authored-by: Isaac * test(e2e): exclude hermes-native from the live no-AGENT harness matrix Registering hermes-native broke test_run_harness_live_matrix_covers_registered_ coding_harnesses (it asserts the matrix covers every registered harness). hermes-native is a terminal-first TUI launched via `omni hermes` (tmux pane + bridge), not `omnigent run --harness hermes-native`, and wraps the hermes CLI — so it's excluded like goose-native/qwen-native/antigravity-native. Its coverage is the dedicated hermes-native unit tests. Co-authored-by: Isaac
375 lines
15 KiB
Python
375 lines
15 KiB
Python
"""Filesystem bridge + tmux injection for the hermes-native terminal harness.
|
|
|
|
The runner launches the ``hermes`` TUI in a private tmux pane and records that
|
|
pane's socket + target here via :func:`write_tmux_target`. The harness executor
|
|
then delivers Omnigent web-UI messages into the *same* pane via
|
|
:func:`inject_user_message` (tmux bracketed paste + a single Enter) — the Hermes
|
|
analog of the goose-native tmux bridge. This is what wires the web-UI chat box to
|
|
the running Hermes TUI (and, since the web UI embeds that pane, the message shows
|
|
in both surfaces).
|
|
|
|
The native TUI uses the user's own ``~/.hermes`` (model/provider/tools) and its
|
|
own tool-approval prompt; Omnigent writes no vendor config here. That prompt is
|
|
surfaced to the web UI as a synced approval card by the runner-side mirror
|
|
(:mod:`omnigent.hermes_native_permissions`), which reads the pane via
|
|
:func:`capture_hermes_pane` and answers it via :func:`send_hermes_pane_keys`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
#: Env var carrying the bridge dir into the harness executor process.
|
|
BRIDGE_DIR_ENV_VAR = "HARNESS_HERMES_NATIVE_BRIDGE_DIR"
|
|
|
|
_BRIDGE_ROOT = Path(os.environ.get("TMPDIR", "/tmp")) / f"omnigent-{os.getuid()}" / "hermes-native"
|
|
_TMUX_FILE = "tmux.json"
|
|
_TMUX_READY_TIMEOUT_S = 30.0
|
|
_TMUX_SEND_TIMEOUT_S = 10.0
|
|
_POLL_INTERVAL_S = 0.2
|
|
_PASTE_SETTLE_S = 0.3
|
|
_PASTE_BUFFER = "omnigent-hermes-paste"
|
|
# How long to wait for the pasted text to become visible in the pane before
|
|
# sending Enter — submitting before the TUI commits the paste folds the Enter
|
|
# into the paste as a newline and the message sits unsent.
|
|
_PASTE_COMMIT_TIMEOUT_S = 5.0
|
|
# Hermes' prompt_toolkit TUI emits no fixed ready-prompt sentinel; readiness is
|
|
# detected by the pane settling (no byte changes across consecutive captures).
|
|
# This many stable polls in a row marks the input box ready.
|
|
_SETTLE_STABLE_POLLS = 3
|
|
|
|
|
|
def bridge_dir_for_session_id(session_id: str) -> Path:
|
|
"""Return the per-session bridge dir, e.g. ``/tmp/omnigent-<uid>/hermes-native/<hash>``."""
|
|
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:32]
|
|
return _BRIDGE_ROOT / digest
|
|
|
|
|
|
def bridge_root() -> Path:
|
|
"""Return the configured hermes-native bridge root."""
|
|
return _BRIDGE_ROOT
|
|
|
|
|
|
def _ensure_dir(path: Path) -> None:
|
|
"""Create *path* (and parents) with owner-only permissions."""
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
with contextlib.suppress(OSError):
|
|
os.chmod(path, 0o700)
|
|
|
|
|
|
def build_hermes_native_spawn_env(session_id: str) -> dict[str, str]:
|
|
"""Build the ``HARNESS_HERMES_NATIVE_*`` env the harness executor reads.
|
|
|
|
Publishes the per-session bridge dir so the
|
|
:class:`~omnigent.inner.hermes_native_executor.HermesNativeExecutor` can find
|
|
the tmux target advertised by the runner. Unlike the headless ``hermes``
|
|
harness this sets no model/provider env — the native TUI uses the user's own
|
|
``hermes`` configuration (``hermes model``), left untouched.
|
|
|
|
:param session_id: The Omnigent session id (keys the bridge dir).
|
|
:returns: Env-var overrides for the harness executor spawn.
|
|
"""
|
|
bridge_dir = bridge_dir_for_session_id(session_id)
|
|
_ensure_dir(bridge_dir)
|
|
return {BRIDGE_DIR_ENV_VAR: str(bridge_dir)}
|
|
|
|
|
|
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 Hermes terminal."""
|
|
_ensure_dir(bridge_dir)
|
|
payload: dict[str, Any] = {
|
|
"socket_path": str(socket_path),
|
|
"tmux_target": tmux_target,
|
|
"updated_at": time.time(),
|
|
}
|
|
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 _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]:
|
|
"""Block until ``tmux.json`` is advertised, or raise on timeout."""
|
|
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"hermes-native tmux target was not advertised within {timeout_s:.0f}s")
|
|
|
|
|
|
def _run_tmux(socket_path: str, *args: str) -> None:
|
|
"""Invoke ``tmux -S <socket> <args...>`` and raise on failure."""
|
|
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 _capture_pane(socket_path: str, tmux_target: str) -> str:
|
|
"""Capture the visible pane contents; ``""`` on any failure (treat as not-ready)."""
|
|
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 _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 _session_alive(socket_path: str, tmux_target: str) -> bool:
|
|
"""Return whether the tmux session/pane still exists (the TUI is running)."""
|
|
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 _submit_needle(content: str) -> str:
|
|
"""A stable single-line substring used to confirm the paste rendered in the pane.
|
|
|
|
Anchored to the LAST qualifying line, not the first: the tail of a freshly
|
|
pasted message is far less likely to already be visible in the pane (a prior
|
|
turn's echo, scrollback) than its opening line, so matching it is a tighter
|
|
signal that *this* paste committed before we send Enter.
|
|
"""
|
|
for line in reversed(content.splitlines()):
|
|
stripped = line.strip()
|
|
if len(stripped) >= 4:
|
|
return stripped[:24]
|
|
stripped = content.strip()
|
|
return stripped[:24] if len(stripped) >= 4 else ""
|
|
|
|
|
|
def _settle_pane(socket_path: str, tmux_target: str, *, timeout_s: float) -> None:
|
|
"""Best-effort wait until the Hermes input box is ready to receive a paste.
|
|
|
|
Hermes emits no fixed idle marker, so readiness is detected by the pane
|
|
settling: the captured contents stop changing for :data:`_SETTLE_STABLE_POLLS`
|
|
consecutive polls (no spinner churn, no streaming output). Falls through after
|
|
the timeout (mid-turn steering may never fully settle) rather than raising.
|
|
"""
|
|
deadline = time.monotonic() + timeout_s
|
|
previous = _capture_pane(socket_path, tmux_target)
|
|
stable = 0
|
|
while time.monotonic() < deadline:
|
|
time.sleep(_POLL_INTERVAL_S)
|
|
current = _capture_pane(socket_path, tmux_target)
|
|
if current and current == previous:
|
|
stable += 1
|
|
if stable >= _SETTLE_STABLE_POLLS:
|
|
return
|
|
else:
|
|
stable = 0
|
|
previous = current
|
|
|
|
|
|
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 Hermes TUI via a tmux bracketed paste.
|
|
|
|
Clears any leftover draft, pastes *content* (multi-line safe via
|
|
``load-buffer``/``paste-buffer -p`` so interior newlines stay data, not
|
|
submits), settles, then submits with a *single* Enter. Hermes' prompt_toolkit
|
|
input submits on Enter, so exactly one Enter is sent — a second would submit
|
|
an empty turn.
|
|
|
|
:param bridge_dir: The hermes-native bridge dir holding ``tmux.json``.
|
|
:param content: User text (non-empty).
|
|
:param timeout_s: Per-readiness-gate timeout.
|
|
:raises RuntimeError: If the tmux target is never advertised or a tmux
|
|
command fails.
|
|
"""
|
|
if not content:
|
|
raise RuntimeError("hermes-native injection requires non-empty content")
|
|
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 _settle_pane polls a dead
|
|
# pane for the full timeout and the web message is silently lost.
|
|
if not _session_alive(socket_path, tmux_target):
|
|
raise RuntimeError(
|
|
"hermes terminal is no longer running (the TUI exited); restart the session"
|
|
)
|
|
_settle_pane(socket_path, tmux_target, timeout_s=timeout_s)
|
|
# Clear any leftover draft: 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")
|
|
with tempfile.NamedTemporaryFile(
|
|
dir=bridge_dir, prefix="paste_", suffix=".bin", delete=False
|
|
) as paste_file:
|
|
# Trailing newline absorbs any trailing backslash so it can't escape Enter.
|
|
paste_file.write(_paste_payload_bytes(content + "\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)
|
|
# Wait until the paste is visibly committed before Enter. Submitting mid-paste
|
|
# folds the Enter in as a newline (rapid stdin bursts coalesce), leaving the
|
|
# message unsent. Poll for the text, then submit; blind-submit if no needle.
|
|
needle = _submit_needle(content)
|
|
if needle:
|
|
deadline = time.monotonic() + _PASTE_COMMIT_TIMEOUT_S
|
|
while time.monotonic() < deadline:
|
|
if needle in _capture_pane(socket_path, tmux_target):
|
|
break
|
|
time.sleep(_POLL_INTERVAL_S)
|
|
time.sleep(_PASTE_SETTLE_S)
|
|
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
|
|
|
|
|
|
def inject_interrupt(bridge_dir: Path, *, timeout_s: float = _TMUX_READY_TIMEOUT_S) -> None:
|
|
"""Cancel the in-flight Hermes 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.
|
|
|
|
: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 Hermes session by killing its tmux session.
|
|
|
|
Terminates ``hermes`` and the pane outright — the analog of the user manually
|
|
exiting the attached TUI, for the web UI's "Stop session" affordance.
|
|
|
|
: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 capture_hermes_pane(bridge_dir: Path) -> str | None:
|
|
"""Return the visible Hermes pane text, or ``None`` if the TUI is not running.
|
|
|
|
Used by the runner-side approval mirror
|
|
(:mod:`omnigent.hermes_native_permissions`) to detect Hermes' in-terminal
|
|
"DANGEROUS COMMAND" approval prompt. ``None`` (no advertised tmux target, or a
|
|
dead pane) is distinct from ``""`` (a live but empty capture).
|
|
|
|
:param bridge_dir: The hermes-native bridge dir holding ``tmux.json``.
|
|
:returns: The captured pane text, or ``None`` when no live pane exists.
|
|
"""
|
|
info = read_tmux_info(bridge_dir)
|
|
if info is None:
|
|
return None
|
|
socket_path, tmux_target = info["socket_path"], info["tmux_target"]
|
|
if not _session_alive(socket_path, tmux_target):
|
|
return None
|
|
return _capture_pane(socket_path, tmux_target)
|
|
|
|
|
|
def send_hermes_pane_keys(bridge_dir: Path, *keys: str) -> None:
|
|
"""Send one or more keys to the Hermes pane (tmux ``send-keys``).
|
|
|
|
Used by the approval mirror to answer Hermes' native prompt from a web
|
|
verdict, e.g. ``"o"`` to approve once or ``"d"`` to deny. Each key is a tmux
|
|
key name/argument (not bracketed-paste data), so multi-byte keys like
|
|
``"Enter"`` are interpreted, not typed literally.
|
|
|
|
:param bridge_dir: The hermes-native bridge dir holding ``tmux.json``.
|
|
:param keys: tmux key arguments, e.g. ``"o"`` or ``"Enter"``.
|
|
:raises RuntimeError: If the tmux target is not advertised or send-keys fails.
|
|
"""
|
|
info = read_tmux_info(bridge_dir)
|
|
if info is None:
|
|
raise RuntimeError("hermes-native tmux target not advertised")
|
|
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], *keys)
|