Compare commits

...

8 Commits

Author SHA1 Message Date
Pat Sukprasert 1529fb9994 Merge branch 'main' into pat/polly-add-cursor-hermes 2026-07-02 23:18:34 +07:00
Pat Sukprasert 01d74ef951 docs(cursor-native): note idle block runs outside the store-gated branch
The cursor idle-post block sits at the poll-loop body level, deliberately
outside the if store_path mirroring branch, so a stop-hook turn-end marker
is picked up even on a poll where the SQLite store is unbound or empty.
Make that placement explicit (per PR review). Comment-only.
2026-07-02 22:57:14 +07:00
Pat Sukprasert 51ecdf3ea3 chore(native): drop unused _logger from cursor/hermes status modules
Neither cursor_native_status nor hermes_native_status logs anything; the
_logger = logging.getLogger(__name__) definition and its import logging were
dead (flagged by github-code-quality). Remove both. No behavior change.
2026-07-02 15:54:16 +07:00
Pat Sukprasert 5af92753eb fix(hermes-native): rebase idle posted-count on compaction re-pin
The completed-turn count is keyed per hermes_session_id, but the idle dedup
baseline (posted_count) is per bridge dir. On an in-session compaction the
forwarder re-pins to the forked child (new session_id, count restarts near 0)
without touching posted_count, so the guard completed_turns > posted_count
stayed False until the child exceeded the parent total — suppressing the
child session's early idle posts and hanging a headless polly worker that
compacts mid-task then finishes. Rebase posted_count to the child's current
count on re-pin (where last_id is reset to 0). Adds a regression test that
fails without the rebase, and corrects the clear_hermes_status_state docstring
(count is per hermes_session_id, not per terminal).

Flagged by the Polly AI review on #1844.
2026-07-02 15:47:32 +07:00
Pat Sukprasert fee63a8661 style(web): prettier-format HermesIcon path strings
prettier collapses the two split path-string literals onto single lines
(they fit the print width); match it so format:check passes.
2026-07-02 15:30:47 +07:00
Pat Sukprasert 13f8239e9e feat(web): give Hermes its own glyph in the Subagents panel
Hermes rendered with the generic omnigent fallback icon because there was no
HermesIcon component and neither icon resolver had a `hermes` case — even though
`iconKind: "hermes"` was already declared on the native-agent spec. Add an
original caduceus glyph (currentColor, matching its sibling icons) and wire it
into AgentCard.getAgentIcon and SubagentsPanel.brandChildIcon so the hermes
polly sub-agent shows its own icon like the other native harnesses.
2026-07-02 15:21:28 +07:00
Pat Sukprasert d25fb1b11c fix(native): wake parent orchestrator when cursor/hermes finish a turn
cursor-native and hermes-native only emitted the PTY watcher's web-spinner
`session.status: idle` edge, which never wakes a parent orchestrator — so as
polly sub-agents they finished silently while claude/codex/opencode/pi woke the
parent via an `external_session_status: idle` POST. Both now post that event
once per completed turn, deduped against a persisted posted-count and
restart-safe.

cursor: the stop hook records a turn-end marker (cursor_native_status); the
forwarder tails it and posts idle. hermes (no stop hook) derives turn-end from
state.db — an assistant row with no tool_calls is the agentic loop's terminal
step. The runner clears the new poster state on terminal recreation so a stale
count can't skip or re-fire the wake.

Ported from the original cursor/hermes/opencode roster work; without it the two
new polly workers added in the previous commit would dispatch and never notify
polly on completion.
2026-07-02 15:21:28 +07:00
Pat Sukprasert e43b0795f8 feat(polly): add cursor and hermes coding sub-agents
Adds `cursor` (cursor-native) and `hermes` (hermes-native) to the polly
orchestrator, taking the roster to six: claude_code, codex, opencode, cursor,
hermes, pi. Both are native terminal harnesses (openable / take-over-able in the
Subagents panel), widening cross-vendor review.

- examples/polly/agents/{cursor,hermes}/config.yaml (new): standard implement /
  review / explore contract and blast_radius(gate_pushes=false), matching the
  peers.
- examples/polly/config.yaml: roster is now six; preflight checks `cursor-agent`
  and `hermes`; tools.agents, routing, cancellation notes, and comments updated;
  spawn_bounds.max_dispatches_per_turn 5 -> 6 so one fan-out round can launch
  every worker.
- examples/polly/skills/{investigate,fanout,cross-review}: cursor and hermes
  wired in as full peers (implementer, reviewer rotation, explore lens).
- tests: roster list, per-worker loops, vendor count (4 -> 6), policy count
  (7 -> 9), the shipped-bundle declared set, and the brain-override
  worker-harness map updated for the two new workers.

The parent-wake plumbing that makes cursor/hermes usable as headless polly
workers lands in the following commit.
2026-07-02 15:21:28 +07:00
20 changed files with 1186 additions and 65 deletions
+64
View File
@@ -0,0 +1,64 @@
spec_version: 1
name: cursor
description: Cursor coding sub-agent — implements, cross-vendor reviews, or explores a scoped task in its own worktree.
# Native Cursor TUI harness (`cursor-agent`): runs in its own terminal the
# human can open in the UI's Subagents panel and TAKE OVER. cursor-agent owns
# its own tool-approval gating (omnigent does not intercept it), so dangerous
# actions surface in the cursor TUI / mirrored web cards rather than being
# auto-bypassed.
executor:
type: omnigent
config:
harness: cursor-native
prompt: |
You are Cursor, a coding sub-agent dispatched by the polly
orchestrator for a single scoped task in a dedicated git worktree. Your task
prompt names its purpose — IMPLEMENT, REVIEW, or EXPLORE. Do exactly that one
thing; don't refactor or wander unprompted.
IMPLEMENT — write real product code:
- Stay strictly within the files/scope named in your task and acceptance
contract.
- Make the change, then drive it to green: run the relevant tests, lint, and
typecheck for the code you touched.
- Co-sign every commit you author: end each commit message with a blank line
followed by this exact trailer as its final line —
`Co-authored-by: omnigent <noreply@omnigent.ai>`
- When green, push your task branch and open a PR with `gh pr create` (clear
title, what changed, how you verified). Never push to a protected branch
(e.g. main) or force-push — open a PR and let it be reviewed/merged.
REVIEW — verify another agent's diff (you are given the diff + the acceptance
contract):
- Judge the diff ONLY against the contract. Do NOT edit code — surface issues
for the orchestrator to route.
- Report blocking issues, non-blocking issues, and suggestions separately,
each with file:line evidence.
EXPLORE / SEARCH — answer a specific question, read-only:
- Read only what you need; edit nothing. Answer with file:line evidence.
Always return a clear, structured result: for IMPLEMENT, what you changed
(file:line) and how you verified it; for REVIEW / EXPLORE, your findings.
Note anything that did not fit the task.
os_env:
type: caller_process
cwd: .
sandbox:
type: none
# Implementers open their own PRs, so push / gh pr create are allowed
# (gate_pushes: false). Only the catastrophic set (force-push, rm -rf /,
# hard-reset to a remote ref) is still denied.
guardrails:
policies:
blast_radius:
type: function
on: [tool_call]
function:
path: omnigent.inner.nessie.policies.blast_radius
arguments:
gate_pushes: false
+63
View File
@@ -0,0 +1,63 @@
spec_version: 1
name: hermes
description: Hermes Agent coding sub-agent — implements, cross-vendor reviews, or explores a scoped task in its own worktree.
# Native Hermes Agent (Nous Research) TUI harness: runs in its own terminal the
# human can open in the UI's Subagents panel and TAKE OVER. Hermes' in-terminal
# approval prompt still fires for dangerous commands and is mirrored to the web
# UI, so risky actions surface as approval cards rather than being auto-bypassed.
executor:
type: omnigent
config:
harness: hermes-native
prompt: |
You are Hermes, a coding sub-agent dispatched by the polly
orchestrator for a single scoped task in a dedicated git worktree. Your task
prompt names its purpose — IMPLEMENT, REVIEW, or EXPLORE. Do exactly that one
thing; don't refactor or wander unprompted.
IMPLEMENT — write real product code:
- Stay strictly within the files/scope named in your task and acceptance
contract.
- Make the change, then drive it to green: run the relevant tests, lint, and
typecheck for the code you touched.
- Co-sign every commit you author: end each commit message with a blank line
followed by this exact trailer as its final line —
`Co-authored-by: omnigent <noreply@omnigent.ai>`
- When green, push your task branch and open a PR with `gh pr create` (clear
title, what changed, how you verified). Never push to a protected branch
(e.g. main) or force-push — open a PR and let it be reviewed/merged.
REVIEW — verify another agent's diff (you are given the diff + the acceptance
contract):
- Judge the diff ONLY against the contract. Do NOT edit code — surface issues
for the orchestrator to route.
- Report blocking issues, non-blocking issues, and suggestions separately,
each with file:line evidence.
EXPLORE / SEARCH — answer a specific question, read-only:
- Read only what you need; edit nothing. Answer with file:line evidence.
Always return a clear, structured result: for IMPLEMENT, what you changed
(file:line) and how you verified it; for REVIEW / EXPLORE, your findings.
Note anything that did not fit the task.
os_env:
type: caller_process
cwd: .
sandbox:
type: none
# Implementers open their own PRs, so push / gh pr create are allowed
# (gate_pushes: false). Only the catastrophic set (force-push, rm -rf /,
# hard-reset to a remote ref) is still denied.
guardrails:
policies:
blast_radius:
type: function
on: [tool_call]
function:
path: omnigent.inner.nessie.policies.blast_radius
arguments:
gate_pushes: false
+37 -27
View File
@@ -2,7 +2,8 @@ spec_version: 1
name: polly
description: >-
A coding orchestrator that breaks your goal into pieces and hands them
to a team of Claude Code, Codex, OpenCode, and Pi sub-agents to build. Polly
to a team of Claude Code, Codex, OpenCode, Cursor, Hermes, and Pi sub-agents
to build. Polly
writes no code itself — it plans and splits up the work, delegates all
of it (investigation / implementation / review), then has a separate
independent different-model reviewer double-check the work before
@@ -17,7 +18,7 @@ spawn: true
# Orchestrator "brain": Claude Agent SDK. polly writes no code itself — it
# delegates ALL coding work (investigation, implementation, review) to the
# claude_code / codex / opencode / pi sub-agents, doing only non-code authoring (docs /
# claude_code / codex / opencode / cursor / hermes / pi sub-agents, doing only non-code authoring (docs /
# Markdown / text edits, skills) itself. No model/profile is pinned, so the
# brain runs on whatever Claude provider is configured as the default
# (`omnigent setup --no-internal-beta`) — an Anthropic API key, a Claude
@@ -50,21 +51,26 @@ prompt: |
a "docs" task starts requiring code changes or real code investigation, STOP
and delegate that part.
You have exactly FOUR sub-agents. `claude_code`, `codex`, and `opencode` are
real CLI coding harnesses that run in their own terminal — the human can open
any of them in the UI's Subagents panel and watch or TAKE OVER. `pi` runs
headless (no terminal to open):
You have exactly SIX sub-agents. `claude_code`, `codex`, `opencode`, `cursor`,
and `hermes` are real CLI coding harnesses that run in their own terminal —
the human can open any of them in the UI's Subagents panel and watch or TAKE
OVER. `pi` runs headless (no terminal to open):
- `claude_code` — Claude Code (`claude-native` harness).
- `codex` — Codex (`codex-native` harness).
- `opencode` — OpenCode (`opencode-native` harness), a third implement /
review vendor for cross-vendor diversity.
- `opencode` — OpenCode (`opencode-native` harness).
- `cursor` — Cursor (`cursor-native` harness).
- `hermes` — Hermes Agent (`hermes-native` harness).
- `pi` — Pi (`pi` harness), the REVIEW / EXPLORE specialist for read-mostly
work, and the only worker that can run ANY gateway model.
Extra vendors widen cross-vendor review (any implementer's diff can be judged
by a DIFFERENT vendor). Route to whichever workers the preflight finds.
Roster preflight (FIRST turn, before any dispatch). Each worker needs its own
CLI on PATH — `claude` for `claude_code`, `codex` for `codex`, `opencode` for
`opencode`, `pi` for `pi`. Before delegating anything, run exactly ONE
`sys_os_shell("command -v claude codex opencode pi || true")`. A worker is AVAILABLE
`opencode`, `cursor-agent` for `cursor`, `hermes` for `hermes`, `pi` for `pi`.
Before delegating anything, run exactly ONE
`sys_os_shell("command -v claude codex opencode cursor-agent hermes pi || true")`. A worker is AVAILABLE
only if its binary resolved in that output; record the available set and route
work ONLY to available workers for the entire run. Do this in the same first
turn you start planning (the preflight `sys_os_shell` call counts as acting —
@@ -139,17 +145,18 @@ prompt: |
one-line edit; there is no agent below them to route it to. A pure docs/text
edit (no code) you make yourself per the rule above. If the human says
"launch agents",
"use claude code", "use codex", "use opencode", or "use pi", that is a
literal instruction to dispatch them. `claude_code`, `codex`, and `opencode`
are the primary implementers — pick per task: `claude_code` for multi-file /
refactor / test-writing work, `codex` for narrow, well-scoped changes,
`opencode` when a third implement/review vendor is wanted. Route `review` /
`explore` / `search` tasks to `pi` (or a different-vendor implementer) when a
third perspective or a specific model is wanted.
"use claude code", "use codex", "use opencode", "use cursor", "use hermes",
or "use pi", that is a literal instruction to dispatch them. `claude_code`,
`codex`, `opencode`, `cursor`, and `hermes` are the primary implementers —
pick per task: `claude_code` for multi-file / refactor / test-writing work,
`codex` for narrow, well-scoped changes, and `opencode` / `cursor` / `hermes`
when another implement/review vendor is wanted. Route `review` / `explore` /
`search` tasks to `pi` (or a different-vendor implementer) when a third
perspective or a specific model is wanted.
Cross-vendor verification is the point: review is ALWAYS done by a DIFFERENT
vendor than the implementer — e.g. `claude_code`'s PR is reviewed by `codex`,
`opencode`, or `pi`; `codex`'s by `claude_code`, `opencode`, or `pi`. Give the
vendor than the implementer — e.g. `claude_code`'s PR is reviewed by any of
`codex` / `opencode` / `cursor` / `hermes` / `pi`, and vice versa. Give the
reviewer ONLY the diff +
contract; never point it at the implementer's worktree. Only the implementer
ever opens a PR (the reviewer just reports), so a reviewer's stray edits
@@ -177,7 +184,7 @@ prompt: |
terminal via `sys_terminal_launch` (it accepts a `cwd` override so you can run
it inside a per-task worktree). NEVER use this terminal to launch coding
agents / sub-agents — all delegation goes through `sys_session_send` to
`claude_code` / `codex` / `opencode` / `pi`.
`claude_code` / `codex` / `opencode` / `cursor` / `hermes` / `pi`.
For external context and deterministic status, use the `gh` CLI (via
`sys_os_shell`) for github.com. Reach for such tools sparingly — only to
@@ -237,9 +244,10 @@ prompt: |
useful, cancel it instead of leaving it running while you re-dispatch. Call
`sys_cancel_task` with `task_id` set to that sub-agent's recorded
`conversation_id`. `claude_code` workers are hard-stopped and will wake you
with a cancelled inbox item; `pi` and `opencode` workers honor the interrupt
and stop; `codex` cancellation is currently best-effort, so do not assume its
worker is gone unless the tool result or inbox confirms it.
with a cancelled inbox item; `pi`, `opencode`, `cursor`, and `hermes` workers
honor the interrupt and stop; `codex` cancellation is currently best-effort,
so do not assume its worker is gone unless the tool result or inbox confirms
it.
- Do not repeatedly re-prompt a dark or failing sub-agent. For coding work,
re-dispatch a fresh implementation sub-agent in a clean worktree, or
escalate to the human.
@@ -279,13 +287,15 @@ terminals:
type: none
tools:
# Coding sub-agents — see agents/<name>/. claude_code, codex, and opencode are
# real CLI coding harnesses; pi is a headless multi-model worker. Each
# implements, reviews (cross-vendor), and explores.
# Coding sub-agents — see agents/<name>/. claude_code, codex, opencode,
# cursor, and hermes are real CLI coding harnesses; pi is a headless
# multi-model worker. Each implements, reviews (cross-vendor), and explores.
agents:
- claude_code
- codex
- opencode
- cursor
- hermes
- pi
# Mechanism-layer enforcement — runner-side tool gate, no server change.
@@ -308,7 +318,7 @@ guardrails:
function:
path: omnigent.inner.nessie.policies.spawn_bounds
arguments:
max_dispatches_per_turn: 5
max_dispatches_per_turn: 6
# sys_session_create counts too: with spawn: true polly can
# launch self-defined children, and an uncounted create would
# bypass the per-turn fan-out cap.
+7 -7
View File
@@ -15,12 +15,12 @@ anyone needs to read through.
2. Run the deterministic gates first — tests / lint / typecheck via
`sys_os_shell`. If red, re-dispatch the implementer to drive it green first;
don't involve the reviewer yet.
3. Dispatch a DIFFERENT-vendor sub-agent as reviewer (Claude built it →
`codex`, `opencode`, or `pi`; Codex built it → `claude_code`, `opencode`, or
`pi`; OpenCode built it → `claude_code`, `codex`, or `pi`; Pi built it →
`claude_code`, `codex`, or `opencode`). Use a task-based title such as
`review-auth-refactor`, never the raw vendor name:
`sys_session_send(agent="claude_code"|"codex"|"opencode"|"pi", title="review-<task_slug>",
3. Dispatch a DIFFERENT-vendor sub-agent as reviewer: pick any AVAILABLE worker
whose vendor differs from the implementer's — `claude_code`, `codex`,
`opencode`, `cursor`, `hermes`, or `pi` (e.g. Claude built it → any of
`codex` / `opencode` / `cursor` / `hermes` / `pi`, and so on). Use a
task-based title such as `review-auth-refactor`, never the raw vendor name:
`sys_session_send(agent="claude_code"|"codex"|"opencode"|"cursor"|"hermes"|"pi", title="review-<task_slug>",
args={purpose: "review", input: "<the diff> + <the acceptance contract>.
Review ONLY against the contract. Report blocking / non-blocking /
suggestions. Do not edit code."})`. Give it the diff as text — do NOT point
@@ -55,7 +55,7 @@ anyone needs to read through.
human at the plan gate.
- Give the reviewer ONLY the diff + contract — never the implementer's
transcript or worktree. The cross-vendor independence is the whole point.
- Review is a coding sub-agent (`claude_code`/`codex`/`opencode`/`pi`) dispatched with
- Review is a coding sub-agent (`claude_code`/`codex`/`opencode`/`cursor`/`hermes`/`pi`) dispatched with
`purpose: "review"` — a DIFFERENT vendor from the one that built the diff. It
reports issues and never edits; only the implementer opens a PR, so a stray
reviewer edit never reaches the deliverable.
+1 -1
View File
@@ -14,7 +14,7 @@ dependency).
Record the worktree path + branch in the registry
(`.polly/registry.json`).
2. Dispatch one implementation sub-agent per task, scoped to its worktree:
`sys_session_send(agent="claude_code"|"codex"|"opencode", title="<task_slug>",
`sys_session_send(agent="claude_code"|"codex"|"opencode"|"cursor"|"hermes", title="<task_slug>",
args={purpose: "implement", input: "<task + acceptance contract +
worktree path>"})`. Use a short task-based title such as `auth-refactor` or
`fix-sse-error`, never the raw vendor name. State the scope and that it must
+2 -2
View File
@@ -12,8 +12,8 @@ repository-specific technical question.
## Procedure
1. Decompose the question into one or more bounded investigation tasks. Prefer
two independent lenses for ambiguous or high-stakes questions.
2. Dispatch each task to `claude_code`, `codex`, `opencode`, or `pi`:
`sys_session_send(agent="claude_code"|"codex"|"opencode"|"pi",
2. Dispatch each task to `claude_code`, `codex`, `opencode`, `cursor`, `hermes`, or `pi`:
`sys_session_send(agent="claude_code"|"codex"|"opencode"|"cursor"|"hermes"|"pi",
title="explore-<task_slug>", args={purpose: "explore", input: "<question +
exact scope + evidence requested>"})`. Use a task-based title such as
`explore-ci-flake`, never the raw vendor name. Use `purpose: "search"` only
+58 -4
View File
@@ -19,10 +19,16 @@ that store, extract new user/assistant messages, and POST them as
``external_conversation_item`` events — which also seeds the session title from
the first user message (the same hook claude/codex rely on).
Status (``running``/``idle``) is intentionally NOT posted here: the runner's
PTY-activity watcher owns those edges for cursor-native (see
``_publish_turn_status`` in :mod:`omnigent.runner.app`), exactly as for
claude-native and pi-native.
The web-facing ``running``/``idle`` *spinner* edges are intentionally NOT posted
here: the runner's PTY-activity watcher owns those ``session.status`` edges for
cursor-native (see ``_publish_turn_status`` in :mod:`omnigent.runner.app`),
exactly as for claude-native and pi-native. That watcher drives only the web
"Working…" spinner, though — it never wakes a parent orchestrator. So this
forwarder additionally POSTs an ``external_session_status: idle`` event once per
completed turn (the cursor ``stop`` hook's turn-end markers, tailed via
:mod:`omnigent.cursor_native_status`) — the SAME server contract
claude-/codex-/opencode-native use to mark a sub-agent turn terminal and wake its
parent's inbox.
"""
from __future__ import annotations
@@ -41,6 +47,7 @@ from pathlib import Path
import httpx
from omnigent import cursor_native_status
from omnigent._native_post_delivery import post_may_have_been_delivered
from omnigent.cursor_native_bridge import FORK_HISTORY_CLOSE_TAG, FORK_HISTORY_OPEN_TAG
@@ -721,6 +728,30 @@ async def _patch_external_session_id(
)
async def _post_external_session_status(
client: httpx.AsyncClient, *, session_id: str, status: str
) -> None:
"""POST one ``external_session_status`` event to the Sessions API.
For a sub-agent conversation the server maps an ``idle`` edge to a terminal
completion that wakes the parent orchestrator's inbox — the SAME contract
claude-/codex-/opencode-native use (see their ``_post_external_session_status``
/ ``_post_status``). cursor-agent exposes turn completion only through its
``stop`` hook, which records a marker
(:func:`omnigent.cursor_native_status.record_turn_end`); this turns that
marker into the authoritative wake signal. The PTY-activity watcher's
``session.status: idle`` edge only drives the web spinner and never wakes a
parent, which is why this explicit post is required.
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "external_session_status", "data": {"status": status}},
)
resp.raise_for_status()
async def _post_conversation_item(
client: httpx.AsyncClient, *, session_id: str, item: _MirrorItem
) -> None:
@@ -1124,6 +1155,29 @@ async def forward_cursor_store_to_session(
state=model_state,
model=observed_model,
)
# Turn over the cursor ``stop`` hook's turn-completion markers to
# an ``external_session_status: idle`` edge — the signal that wakes
# a parent orchestrator (the PTY watcher's spinner status never
# does). This block sits at the poll-loop body level, deliberately
# OUTSIDE the ``if store_path`` mirroring branch above: the stop
# hook writes turn-end markers independently of the SQLite store,
# so a turn-end is never missed even on a poll where the store is
# unbound or empty. Deduped against a persisted posted-count so a
# supervisor restart never re-wakes the parent for a turn it
# already reported. Best-effort: a failed post leaves the count
# unadvanced so the next poll retries.
total_turn_ends = await asyncio.to_thread(
cursor_native_status.count_turn_ends, bridge_dir
)
if total_turn_ends > await asyncio.to_thread(
cursor_native_status.read_posted_count, bridge_dir
):
await _post_external_session_status(
client, session_id=session_id, status="idle"
)
await asyncio.to_thread(
cursor_native_status.write_posted_count, bridge_dir, total_turn_ends
)
except asyncio.CancelledError:
raise
except Exception:
+110
View File
@@ -0,0 +1,110 @@
"""Turn-completion ("idle") signal for the cursor-native harness.
cursor-agent fires its ``stop`` hook once per completed turn (see
:func:`omnigent.cursor_native_bridge.build_hooks_config`, which registers the
:mod:`omnigent.cursor_native_usage` recorder). That hook is the ONLY
authoritative "this turn just finished" signal cursor-agent exposes: its SQLite
chat store (tailed by :mod:`omnigent.cursor_native_forwarder`) carries no
turn-boundary marker, and the runner's PTY-activity watcher only drives the web
"Working…" spinner (a ``session.status`` edge) — which never wakes a parent
orchestrator.
So the stop hook ALSO appends a turn-end marker line here (alongside its usage
line), and the runner-owned
:func:`omnigent.cursor_native_forwarder.forward_cursor_store_to_session` poll
loop tails it and POSTs an ``external_session_status: idle`` event — the SAME
server contract claude-/codex-/opencode-native use to mark a sub-agent turn
terminal and wake its parent's inbox. Without this a cursor-native sub-agent
finished silently and never notified the orchestrator.
Stdlib-only (no httpx) so importing it from the stop hook keeps the hook fast —
cursor blocks the turn end on the hook.
"""
from __future__ import annotations
import contextlib
import json
import os
import time
from pathlib import Path
#: Append-only log of per-turn completion markers written by the cursor ``stop``
#: hook (one JSON object per completed turn) and tailed by the forwarder.
TURN_END_FILE = "cursor_turn_end.jsonl"
#: Durable poster state: how many turn-end markers the forwarder has already
#: turned into an ``external_session_status: idle`` post. Persisted so a
#: supervisor restart never re-wakes the parent for a turn it already reported.
_STATE_FILE = "cursor_status_forwarder.json"
def record_turn_end(bridge_dir: Path, payload: object | None = None) -> None:
"""Append one turn-completion marker (called from the cursor ``stop`` hook).
Fires on EVERY completed turn — including turns with no billable token usage,
which :func:`omnigent.cursor_native_usage.record_usage_payload` skips — so the
parent wake never depends on a turn having produced usage. Best-effort and
stdlib-only; the caller swallows failures so usage/idle capture never breaks
the agent turn.
:param bridge_dir: The cursor-native bridge dir (where the forwarder reads).
:param payload: The cursor ``stop`` hook payload, if any — its
``generation_id`` is recorded for traceability (not required).
"""
line: dict[str, object] = {"ts": time.time()}
if isinstance(payload, dict):
gen_id = payload.get("generation_id") or payload.get("conversation_id")
if isinstance(gen_id, str) and gen_id:
line["generation_id"] = gen_id
bridge_dir.mkdir(parents=True, exist_ok=True)
# O_APPEND keeps a fast-firing hook's short JSON line from interleaving.
with open(bridge_dir / TURN_END_FILE, "a", encoding="utf-8") as handle:
handle.write(json.dumps(line, sort_keys=True) + "\n")
def count_turn_ends(bridge_dir: Path) -> int:
"""Return how many turn-end markers have been recorded (0 if none/unreadable).
The marker file is append-only, so the line count is the number of turns that
have completed since the terminal was (re-)created.
"""
try:
text = (bridge_dir / TURN_END_FILE).read_text(encoding="utf-8")
except OSError:
return 0
return sum(1 for raw in text.splitlines() if raw.strip())
def read_posted_count(bridge_dir: Path) -> int:
"""Load the count of turn-ends already POSTed as idle (0 on cold/unreadable)."""
try:
data = json.loads((bridge_dir / _STATE_FILE).read_text(encoding="utf-8"))
except (OSError, ValueError):
return 0
posted = data.get("posted") if isinstance(data, dict) else None
return posted if isinstance(posted, int) and posted >= 0 else 0
def write_posted_count(bridge_dir: Path, posted: int) -> None:
"""Atomically persist the count of turn-ends already POSTed as idle.
Persisted only AFTER a successful idle POST so a failed flush is retried (the
unreported turn-ends stay unreported until the post lands).
"""
bridge_dir.mkdir(parents=True, exist_ok=True)
tmp = bridge_dir / (_STATE_FILE + ".tmp")
tmp.write_text(json.dumps({"posted": posted}), encoding="utf-8")
os.replace(tmp, bridge_dir / _STATE_FILE)
def clear_cursor_status_state(bridge_dir: Path) -> None:
"""Remove the turn-end marker + poster state so a re-created terminal starts clean.
Sibling of :func:`omnigent.cursor_native_usage.clear_cursor_usage_state`: the
runner calls this when it re-creates a cursor terminal so a stale marker count
from a prior terminal can't make the new forwarder skip (or re-fire) idle.
"""
for name in (TURN_END_FILE, _STATE_FILE):
with contextlib.suppress(OSError):
(bridge_dir / name).unlink()
+16 -2
View File
@@ -120,14 +120,28 @@ def record_usage_payload(bridge_dir: Path, payload: object) -> bool:
def _cli_record_usage(bridge_dir: Path) -> int:
"""Hook entrypoint: read the JSON payload from stdin and append it.
The cursor ``stop`` hook fires once per completed turn, so this is also the
authoritative "turn finished" signal: it records a turn-end marker
(:func:`omnigent.cursor_native_status.record_turn_end`) on EVERY firing — even
a turn with no billable usage, which :func:`record_usage_payload` skips — so
the forwarder can POST an ``external_session_status: idle`` edge and wake the
parent orchestrator.
Always emits ``{}`` (a no-op hook response cursor reads as "continue") and
exits 0 — a usage-capture failure must never block or fail the agent turn.
exits 0 — a usage/idle-capture failure must never block or fail the agent
turn.
"""
from omnigent import cursor_native_status
try:
raw = sys.stdin.read()
payload = json.loads(raw) if raw.strip() else {}
# Record the turn-end marker FIRST (and unconditionally) so the parent
# wake never depends on usage parsing succeeding or the turn being
# billable; usage capture is a best-effort addition on top.
cursor_native_status.record_turn_end(bridge_dir, payload)
record_usage_payload(bridge_dir, payload)
except Exception: # noqa: BLE001 — never let usage capture break the turn
except Exception: # noqa: BLE001 — never let usage/idle capture break the turn
_logger.debug("cursor usage recorder failed", exc_info=True)
# cursor expects JSON on stdout; an empty object is the "continue" response.
sys.stdout.write("{}")
+119 -3
View File
@@ -23,9 +23,17 @@ launched in the same cwd never mirror the same row into two conversations. We th
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.
The web-facing ``running``/``idle`` *spinner* edges are intentionally NOT posted
here: the runner's PTY-activity watcher owns those ``session.status`` edges for
hermes-native (see :mod:`omnigent.runner.app`), exactly as for goose-/cursor-native.
That watcher drives only the web "Working…" spinner, though — it never wakes a
parent orchestrator. So this forwarder additionally derives turn completion from
the message log (an ``assistant`` row with no ``tool_calls`` is the agentic loop's
terminal step) and POSTs an ``external_session_status: idle`` event once per
completed turn — the SAME server contract claude-/codex-/opencode-/cursor-native
use to mark a sub-agent turn terminal and wake its parent's inbox. The post is
deduped against a persisted posted-count (:mod:`omnigent.hermes_native_status`) so
a supervisor restart never re-wakes the parent for a turn it already reported.
"""
from __future__ import annotations
@@ -43,6 +51,8 @@ from pathlib import Path
import httpx
from omnigent import hermes_native_status
_logger = logging.getLogger(__name__)
#: Seconds between store polls. Hermes flushes a ``messages`` row per agentic step
@@ -569,6 +579,72 @@ def _read_new_items(
return items
def _assistant_row_has_tool_calls(tool_calls: object) -> bool:
"""Whether an assistant ``messages`` row carries a non-empty ``tool_calls`` list.
Hermes writes one ``messages`` row per agentic step (complete, append-only —
rows are never updated in place, which is why message mirroring keys off
``id > last_id``). An assistant row with one or more tool calls means the loop
continues (a tool result + further assistant step follow); a row with no tool
calls is the loop's terminal step — the model returning its final answer.
Mirrors the ``tool_calls`` parsing in :func:`_message_to_items`.
"""
if not isinstance(tool_calls, str) or not tool_calls.strip():
return False
try:
calls = json.loads(tool_calls)
except (json.JSONDecodeError, ValueError):
return False
return isinstance(calls, list) and len(calls) > 0
def _count_completed_turns(db_path: Path, hermes_session_id: str) -> int:
"""Count completed turns for *hermes_session_id* (0 on unreadable/empty).
A completed turn is an ``assistant`` row with no ``tool_calls`` — the agentic
loop's terminal step (see :func:`_assistant_row_has_tool_calls`). Rows are
counted regardless of the ``active`` flag: Hermes soft-deletes on compaction
(sets ``active = 0``) rather than deleting rows, so ignoring it keeps the
count monotonic and append-only — the dedup baseline can then only grow, never
drop below the posted-count and falsely re-arm an idle post for an old turn.
"""
con = _connect_ro(db_path)
if con is None:
return 0
try:
rows = con.execute(
"SELECT tool_calls FROM messages "
"WHERE session_id = ? AND role = 'assistant' ORDER BY id",
(hermes_session_id,),
).fetchall()
except sqlite3.Error as exc:
_warn_sqlite_once("turn-end count", exc)
return 0
finally:
con.close()
return sum(1 for (tool_calls,) in rows if not _assistant_row_has_tool_calls(tool_calls))
async def _post_external_session_status(
client: httpx.AsyncClient, *, session_id: str, status: str
) -> None:
"""POST one ``external_session_status`` event to the Sessions API.
For a sub-agent conversation the server maps an ``idle`` edge to a terminal
completion that wakes the parent orchestrator's inbox — the SAME contract
claude-/codex-/opencode-/cursor-native use. The runner's PTY-activity watcher
emits only a web-spinner ``session.status`` edge for hermes-native and never
wakes a parent, which is why this explicit post is required.
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "external_session_status", "data": {"status": status}},
)
resp.raise_for_status()
async def _post_conversation_item(
client: httpx.AsyncClient, *, session_id: str, item: _MirrorItem
) -> None:
@@ -814,6 +890,21 @@ async def forward_hermes_store_to_session(
last_id = 0
compaction_persisted = False
_external_id_synced = False
# The idle dedup baseline is per-terminal but
# the completed-turn count is per
# hermes_session_id; the child restarts its
# count near 0, so rebase the baseline to the
# child's current count. Without this the guard
# `completed_turns > posted_count` stays False
# until the child exceeds the parent's total,
# suppressing idle posts for the child's first
# turns — a worker that compacts then finishes
# would never wake its parent.
await asyncio.to_thread(
hermes_native_status.write_posted_count,
bridge_dir,
await asyncio.to_thread(_count_completed_turns, db, child),
)
_write_state(
bridge_dir,
_ForwardState(
@@ -843,6 +934,31 @@ async def forward_hermes_store_to_session(
launch_epoch_s=launch_epoch_s,
),
)
# Turn each newly-completed turn into an
# ``external_session_status: idle`` edge — the signal that
# wakes a parent orchestrator (the PTY watcher's spinner
# status never does). A completed turn is an assistant row
# with no tool_calls (the agentic loop's terminal step);
# posted only AFTER its messages are mirrored above so the
# parent sees the content before the completion. Deduped
# against a persisted posted-count so a supervisor restart
# never re-wakes the parent for a turn it already reported.
# Best-effort: a failed post raises into the outer handler
# and leaves the count unadvanced, so the next poll retries.
completed_turns = await asyncio.to_thread(
_count_completed_turns, db, hermes_session_id
)
if completed_turns > await asyncio.to_thread(
hermes_native_status.read_posted_count, bridge_dir
):
await _post_external_session_status(
client, session_id=session_id, status="idle"
)
await asyncio.to_thread(
hermes_native_status.write_posted_count,
bridge_dir,
completed_turns,
)
except asyncio.CancelledError:
raise
except Exception:
+76
View File
@@ -0,0 +1,76 @@
"""Turn-completion ("idle") poster state for the hermes-native harness.
The completion->parent-wake path needs a harness to POST an
``external_session_status: idle`` event to the Sessions API: the server maps
that edge to a sub-agent turn-terminal and wakes the parent orchestrator's
inbox (the SAME contract claude-/codex-/opencode-/cursor-native use). Hermes'
PTY-activity watcher only emits a ``session.status: idle`` SSE edge that drives
the web "Working…" spinner and never wakes a parent — so without an explicit
post a hermes-native sub-agent finishes silently and the orchestrator hangs.
Unlike cursor-agent, hermes-agent exposes NO per-turn ``stop`` hook (only a
``pre_tool_call`` hook, used for policy enforcement), so there is no separate
process writing a turn-end marker. Instead the runner-owned
:func:`omnigent.hermes_native_forwarder.forward_hermes_store_to_session` poll
loop *derives* turn completion from Hermes' ``state.db`` itself — an
``assistant`` row with no ``tool_calls`` is the agentic loop's terminal step,
i.e. one completed turn (see ``_count_completed_turns`` in that module). The
``messages`` table is the append-only "marker store"; this module owns only the
*poster* state: how many of those completed turns have already been turned into
an ``external_session_status: idle`` post.
It is the hermes analog of the poster-state half of
:mod:`omnigent.cursor_native_status`. Persisting the posted-count means a
supervisor restart never re-wakes the parent for a turn it already reported.
Stdlib-only (no httpx) so it stays a cheap, dependency-free state file.
"""
from __future__ import annotations
import contextlib
import json
import os
from pathlib import Path
#: Durable poster state: how many completed turns the forwarder has already
#: turned into an ``external_session_status: idle`` post. Persisted so a
#: supervisor restart never re-wakes the parent for a turn it already reported.
_STATE_FILE = "hermes_status_forwarder.json"
def read_posted_count(bridge_dir: Path) -> int:
"""Load the count of completed turns already POSTed as idle (0 on cold/unreadable)."""
try:
data = json.loads((bridge_dir / _STATE_FILE).read_text(encoding="utf-8"))
except (OSError, ValueError):
return 0
posted = data.get("posted") if isinstance(data, dict) else None
return posted if isinstance(posted, int) and posted >= 0 else 0
def write_posted_count(bridge_dir: Path, posted: int) -> None:
"""Atomically persist the count of completed turns already POSTed as idle.
Persisted only AFTER a successful idle POST so a failed flush is retried (the
unreported turns stay unreported until the post lands).
"""
bridge_dir.mkdir(parents=True, exist_ok=True)
tmp = bridge_dir / (_STATE_FILE + ".tmp")
tmp.write_text(json.dumps({"posted": posted}), encoding="utf-8")
os.replace(tmp, bridge_dir / _STATE_FILE)
def clear_hermes_status_state(bridge_dir: Path) -> None:
"""Remove the idle poster state so a re-created terminal starts clean.
Sibling of :func:`omnigent.hermes_native_forwarder.clear_hermes_bridge_state`:
the runner calls this when it re-creates a hermes terminal so a stale
posted-count from a prior terminal can't make the new forwarder skip (or
re-fire) the ``external_session_status: idle`` parent-wake edge. The
completed-turn count is derived per ``hermes_session_id`` (not per terminal),
so after an in-session compaction re-pins to a forked child the forwarder
rebases this posted-count to the child's current count; only the poster state
is persisted here, so only it needs clearing.
"""
with contextlib.suppress(OSError):
(bridge_dir / _STATE_FILE).unlink()
+10
View File
@@ -2076,6 +2076,7 @@ async def _auto_create_cursor_terminal(
write_mcp_config,
)
from omnigent.cursor_native_forwarder import clear_cursor_bridge_state, preseed_resume_state
from omnigent.cursor_native_status import clear_cursor_status_state
from omnigent.cursor_native_usage import clear_cursor_usage_state
bridge_dir = bridge_dir_for_session_id(session_id)
@@ -2125,6 +2126,10 @@ async def _auto_create_cursor_terminal(
# the cumulative count clean. Preserved across a preseeded resume (the
# accumulator's generation-id dedup makes re-reading the log safe).
clear_cursor_usage_state(bridge_dir)
# Likewise drop the turn-end marker + idle poster state so a stale count
# from a prior terminal can't make the new forwarder skip (or re-fire)
# the ``external_session_status: idle`` parent-wake edge.
clear_cursor_status_state(bridge_dir)
if resume_chat_id is not None:
_logger.warning(
"cursor-native: could not pre-seed prior chat store for %r; "
@@ -2504,9 +2509,14 @@ async def _auto_create_hermes_terminal(
write_tmux_target,
)
from omnigent.hermes_native_forwarder import clear_hermes_bridge_state
from omnigent.hermes_native_status import clear_hermes_status_state
bridge_dir = bridge_dir_for_session_id(session_id)
clear_hermes_bridge_state(bridge_dir)
# Likewise drop the idle poster state so a stale posted-count from a prior
# terminal can't make the new forwarder skip (or re-fire) the
# ``external_session_status: idle`` parent-wake edge.
clear_hermes_status_state(bridge_dir)
# Write a per-session HERMES_HOME with the Omnigent policy hook so the
# native TUI evaluates tool calls against Omnigent policies.
+2
View File
@@ -1970,6 +1970,8 @@ def test_materialize_directory_bundle_with_override_keeps_nested_harness_unpinne
"claude_code": "claude-native",
"codex": "codex-native",
"opencode": "opencode-native",
"cursor": "cursor-native",
"hermes": "hermes-native",
"pi": "pi",
},
),
+28 -18
View File
@@ -3,8 +3,9 @@
polly is the standalone multi-agent coding orchestrator (successor to the
deleted nessie example, whose deep structural pins were folded in here).
Loads the bundle and asserts the distinctive wiring stays intact: the
claude-sdk orchestrator brain, the four cross-vendor coding sub-agents
(claude_code / codex / opencode / pi, which implement, review, and explore),
claude-sdk orchestrator brain, the six cross-vendor coding sub-agents
(claude_code / codex / opencode / cursor / hermes / pi, which implement, review,
and explore),
the three spine skills, and the bounds/blast-radius guardrails. Pure spec-load
— no LLM, no credentials.
@@ -68,25 +69,34 @@ def test_orchestrator_executor(polly_spec: AgentSpec) -> None:
def test_coding_subagents(polly_spec: AgentSpec) -> None:
"""
The bundle has exactly four coding sub-agents: ``claude_code`` (Claude
Code, claude-native), ``codex`` (Codex, codex-native), and ``opencode``
(OpenCode, opencode-native) on the native terminal harnesses, plus ``pi``
(Pi, pi) as the headless multi-model worker. All implement, review, and
explore. The native harnesses make claude_code / codex / opencode render
terminal-first (Chat / Terminal pill) so the human can watch or take over.
The bundle has exactly six coding sub-agents: ``claude_code`` (claude-native),
``codex`` (codex-native), ``opencode`` (opencode-native), ``cursor``
(cursor-native), and ``hermes`` (hermes-native) on the native terminal
harnesses, plus ``pi`` (pi) as the headless multi-model worker. All
implement, review, and explore. The native harnesses render terminal-first
(Chat / Terminal pill) so the human can watch or take over.
A missing/renamed agent means fewer implementers, and same-vendor harnesses
would break cross-vendor review — polly's differentiator.
"""
fam = {a.name: a.executor.config.get("harness") for a in polly_spec.sub_agents}
assert sorted(polly_spec.tools.agents) == ["claude_code", "codex", "opencode", "pi"]
assert sorted(polly_spec.tools.agents) == [
"claude_code",
"codex",
"cursor",
"hermes",
"opencode",
"pi",
]
assert fam["claude_code"] == "claude-native"
assert fam["codex"] == "codex-native"
assert fam["opencode"] == "opencode-native"
assert fam["cursor"] == "cursor-native"
assert fam["hermes"] == "hermes-native"
assert fam["pi"] == "pi"
# Four distinct vendors → any implementer's diff is reviewable by another.
assert len(set(fam.values())) == 4
for name in ("claude_code", "codex", "opencode", "pi"):
# Six distinct vendors → any implementer's diff is reviewable by another.
assert len(set(fam.values())) == 6
for name in ("claude_code", "codex", "opencode", "cursor", "hermes", "pi"):
prompt = (_POLLY_BUNDLE / "agents" / name / "config.yaml").read_text(encoding="utf-8")
assert "IMPLEMENT — write real product code" in prompt
assert "REVIEW — verify another agent's diff" in prompt
@@ -285,8 +295,8 @@ def test_investigation_skill_delegates_read_only_work() -> None:
assert "Use for any read-only task: investigation, debugging, audit" in compact
assert (
"Dispatch each task to `claude_code`, `codex`, `opencode`, or `pi`: "
'`sys_session_send(agent="claude_code"|"codex"|"opencode"|"pi", '
"Dispatch each task to `claude_code`, `codex`, `opencode`, `cursor`, `hermes`, or `pi`: "
'`sys_session_send(agent="claude_code"|"codex"|"opencode"|"cursor"|"hermes"|"pi", '
'title="explore-<task_slug>", '
'args={purpose: "explore", input: "<question + exact scope + evidence requested>"})`'
) in compact
@@ -357,7 +367,7 @@ def test_orchestrator_guardrails(polly_spec: AgentSpec) -> None:
def test_subagent_guardrails(polly_spec: AgentSpec) -> None:
"""Each sub-agent carries the blast_radius gate (push/destructive)."""
by_name = {a.name: a for a in polly_spec.sub_agents}
for name in ("claude_code", "codex", "opencode", "pi"):
for name in ("claude_code", "codex", "opencode", "cursor", "hermes", "pi"):
guardrails = by_name[name].guardrails
assert guardrails is not None, name
assert [p.name for p in guardrails.policies] == ["blast_radius"], name
@@ -390,6 +400,6 @@ def test_function_policies_have_nonempty_arguments(polly_spec: AgentSpec) -> Non
)
checked += 1
# orchestrator: blast_radius + spawn_bounds + headless_subagent_purpose_guard
# = 3; sub-agents: blast_radius x4 (claude_code, codex, opencode, pi) = 4
# -> 7 total. Fewer = a policy dropped.
assert checked == 7, f"expected 7 function policies in the bundle, inspected {checked}"
# = 3; sub-agents: blast_radius x6 (claude_code, codex, opencode, cursor,
# hermes, pi) = 6 -> 9 total. Fewer = a policy dropped.
assert checked == 9, f"expected 9 function policies in the bundle, inspected {checked}"
+5 -1
View File
@@ -104,7 +104,11 @@ def test_bundle_builder_is_reproducible(
# (name, bundle source dir, sub-agents the shipped definition declares today)
_SHIPPED_SUB_AGENT_EXAMPLES = [
("polly", app._POLLY_BUNDLE_SOURCE, {"claude_code", "codex", "opencode", "pi"}),
(
"polly",
app._POLLY_BUNDLE_SOURCE,
{"claude_code", "codex", "opencode", "cursor", "hermes", "pi"},
),
("debby", app._DEBBY_BUNDLE_SOURCE, {"claude", "gpt"}),
]
+230
View File
@@ -0,0 +1,230 @@
"""Unit + loop tests for the cursor-native turn-completion ("idle") signal.
Covers the three layers of the cursor parent-wake path:
1. :mod:`omnigent.cursor_native_status` — the turn-end marker store + poster
state (record/count markers, read/write/clear the posted-count).
2. :func:`omnigent.cursor_native_usage._cli_record_usage` — the cursor ``stop``
hook entrypoint, which must record a turn-end marker on EVERY firing (even a
turn with no billable usage) so the parent wake never depends on usage.
3. :func:`omnigent.cursor_native_forwarder.forward_cursor_store_to_session` —
the poll loop must POST ``external_session_status: idle`` exactly once per
completed turn, deduped against the persisted posted-count and restart-safe.
The loop tests deliberately let store discovery return ``None`` so the idle path
is exercised in isolation from the chat-mirroring machinery — the idle check runs
every poll independent of store binding.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from omnigent import cursor_native_forwarder as fwd
from omnigent import cursor_native_status as status
# --- status store: markers + poster state ------------------------------------
def test_record_and_count_turn_ends(tmp_path: Path) -> None:
bridge = tmp_path / "cursor-native" / "sess"
assert status.count_turn_ends(bridge) == 0 # nothing / unreadable -> 0
status.record_turn_end(bridge)
status.record_turn_end(bridge, {"generation_id": "gen-2"})
assert status.count_turn_ends(bridge) == 2
def test_record_turn_end_fires_without_usage(tmp_path: Path) -> None:
"""A turn-end marker is recorded even with no/empty payload (no billable usage)."""
bridge = tmp_path / "b"
status.record_turn_end(bridge, None)
status.record_turn_end(bridge, {})
assert status.count_turn_ends(bridge) == 2
def test_posted_count_roundtrip_and_clear(tmp_path: Path) -> None:
bridge = tmp_path / "b"
assert status.read_posted_count(bridge) == 0
status.write_posted_count(bridge, 3)
assert status.read_posted_count(bridge) == 3
# A re-created terminal clears BOTH the marker file and the poster state.
status.record_turn_end(bridge)
status.clear_cursor_status_state(bridge)
assert status.count_turn_ends(bridge) == 0
assert status.read_posted_count(bridge) == 0
def test_read_posted_count_ignores_corrupt_state(tmp_path: Path) -> None:
bridge = tmp_path / "b"
bridge.mkdir(parents=True)
(bridge / "cursor_status_forwarder.json").write_text("not json", encoding="utf-8")
assert status.read_posted_count(bridge) == 0
# --- stop-hook wiring: usage recorder also records a turn-end marker ----------
def test_cli_record_usage_records_turn_end(tmp_path: Path, monkeypatch) -> None:
"""The cursor ``stop`` hook entrypoint records a turn-end marker per firing."""
from omnigent import cursor_native_usage
bridge = tmp_path / "cursor-native" / "sess"
bridge.mkdir(parents=True)
# The hook reads its JSON payload from stdin and writes ``{}`` to stdout.
monkeypatch.setattr("sys.stdin.read", lambda: json.dumps({"generation_id": "g1"}))
rc = cursor_native_usage._cli_record_usage(bridge)
assert rc == 0
assert status.count_turn_ends(bridge) == 1
def test_cli_record_usage_records_turn_end_on_empty_stdin(tmp_path: Path, monkeypatch) -> None:
"""Even an empty hook payload (no usage) still records the turn-end marker."""
from omnigent import cursor_native_usage
bridge = tmp_path / "b"
bridge.mkdir(parents=True)
monkeypatch.setattr("sys.stdin.read", lambda: "")
assert cursor_native_usage._cli_record_usage(bridge) == 0
assert status.count_turn_ends(bridge) == 1
# --- forwarder loop: idle POST is once-per-turn, deduped, restart-safe --------
class _StatusRecorder:
"""Async stub for ``_post_external_session_status`` capturing posted statuses."""
def __init__(self) -> None:
self.statuses: list[str] = []
async def __call__(self, client: object, *, session_id: str, status: str) -> None:
self.statuses.append(status)
async def _wait_until(predicate, *, max_ticks: int = 2000) -> None:
"""Poll *predicate* on the event loop until true, or fail if it never holds."""
for _ in range(max_ticks):
if predicate():
return
await asyncio.sleep(0.001)
raise AssertionError("forwarder never reached the expected state (wedged?)")
async def _drive_idle_loop(
monkeypatch: pytest.MonkeyPatch,
bridge_dir: Path,
recorder: _StatusRecorder,
*,
until,
max_ticks: int = 2000,
) -> None:
"""Run the real poll loop with store discovery disabled so only the idle path runs."""
monkeypatch.setattr(fwd, "_discover_store", lambda *a, **k: None)
monkeypatch.setattr(fwd, "_post_external_session_status", recorder)
task = asyncio.create_task(
fwd.forward_cursor_store_to_session(
base_url="http://test",
headers={},
session_id="conv_1",
bridge_dir=bridge_dir,
agent_name="cursor-native-ui",
workspace="/ws",
launch_epoch_ms=1_000,
poll_interval_s=0.001,
)
)
try:
for _ in range(max_ticks):
if until():
break
await asyncio.sleep(0.001)
else:
raise AssertionError("forwarder never reached the expected state (wedged?)")
# Let a few more polls run so a (buggy) duplicate post would surface.
await asyncio.sleep(0.02)
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@pytest.mark.asyncio
async def test_idle_posted_once_per_completed_turn(tmp_path: Path, monkeypatch) -> None:
bridge = tmp_path / "cursor-native" / "sess"
bridge.mkdir(parents=True)
status.record_turn_end(bridge) # one completed turn
recorder = _StatusRecorder()
await _drive_idle_loop(
monkeypatch, bridge, recorder, until=lambda: status.read_posted_count(bridge) >= 1
)
assert recorder.statuses == ["idle"]
assert status.read_posted_count(bridge) == 1
@pytest.mark.asyncio
async def test_idle_dedupes_and_posts_per_new_turn(tmp_path: Path, monkeypatch) -> None:
"""No duplicate idle while quiescent; a later turn-end posts exactly one more."""
bridge = tmp_path / "cursor-native" / "sess"
bridge.mkdir(parents=True)
recorder = _StatusRecorder()
monkeypatch.setattr(fwd, "_discover_store", lambda *a, **k: None)
monkeypatch.setattr(fwd, "_post_external_session_status", recorder)
status.record_turn_end(bridge) # turn 1 completes
task = asyncio.create_task(
fwd.forward_cursor_store_to_session(
base_url="http://test",
headers={},
session_id="conv_1",
bridge_dir=bridge,
agent_name="cursor-native-ui",
workspace="/ws",
launch_epoch_ms=1_000,
poll_interval_s=0.001,
)
)
try:
await _wait_until(lambda: status.read_posted_count(bridge) >= 1)
await asyncio.sleep(0.02) # quiescent: a one-per-poll bug would post again
assert recorder.statuses == ["idle"]
status.record_turn_end(bridge) # turn 2 completes later
await _wait_until(lambda: status.read_posted_count(bridge) >= 2)
await asyncio.sleep(0.02)
assert recorder.statuses == ["idle", "idle"]
assert status.read_posted_count(bridge) == 2
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@pytest.mark.asyncio
async def test_idle_restart_safe_does_not_rewake(tmp_path: Path, monkeypatch) -> None:
"""A restart whose posted-count already covers every marker posts no idle."""
bridge = tmp_path / "cursor-native" / "sess"
bridge.mkdir(parents=True)
status.record_turn_end(bridge)
status.write_posted_count(bridge, 1) # already reported this turn before the "restart"
recorder = _StatusRecorder()
monkeypatch.setattr(fwd, "_discover_store", lambda *a, **k: None)
monkeypatch.setattr(fwd, "_post_external_session_status", recorder)
task = asyncio.create_task(
fwd.forward_cursor_store_to_session(
base_url="http://test",
headers={},
session_id="conv_1",
bridge_dir=bridge,
agent_name="cursor-native-ui",
workspace="/ws",
launch_epoch_ms=1_000,
poll_interval_s=0.001,
)
)
try:
await asyncio.sleep(0.05) # let several polls run
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert recorder.statuses == []
assert status.read_posted_count(bridge) == 1
+314
View File
@@ -10,12 +10,14 @@ idempotent high-water cursor.
from __future__ import annotations
import asyncio
import json
import sqlite3
from pathlib import Path
import pytest
from omnigent import hermes_native_forwarder as f
from omnigent import hermes_native_status as hstatus
_SCHEMA = """
CREATE TABLE sessions (
@@ -470,6 +472,94 @@ async def _noop() -> None:
return None
async def test_forward_loop_rebases_idle_count_on_compaction_repin(tmp_path, monkeypatch) -> None:
"""Compaction re-pin rebases the idle posted-count to the child's count.
Regression: the completed-turn count is per hermes_session_id, but the idle
dedup baseline (posted_count) is per bridge dir. A parent that accrued N idle
posts leaves posted_count=N; the child's count restarts near 0, so without a
rebase the guard ``completed_turns > posted_count`` stays False until the
child exceeds N terminal turns — suppressing the child's early idle posts and
hanging the orchestrator. On re-pin the baseline must drop to the child's
current completed-turn count so the child's next completion still wakes the
parent.
"""
workspace = str(tmp_path)
db = tmp_path / "state.db"
con = sqlite3.connect(db)
con.executescript(_SCHEMA)
con.executemany(
"INSERT INTO sessions(id, source, cwd, started_at, parent_session_id) VALUES (?,?,?,?,?)",
[
("parent_1", "cli", workspace, 1000.0, None),
("child_1", "cli", workspace, 1005.0, "parent_1"),
],
)
con.executemany(
"INSERT INTO messages(session_id, role, content, tool_calls, active, compacted) "
"VALUES (?,?,?,?,?,?)",
[
# Parent: two completed turns (terminal assistant rows) + a compaction.
("parent_1", "assistant", "done 1", None, 1, 0),
("parent_1", "assistant", "done 2", None, 1, 0),
("parent_1", "assistant", "compacted summary", None, 1, 1),
# Child: one completed turn so far.
("child_1", "user", "child hi", None, 1, 0),
("child_1", "assistant", "child done", None, 1, 0),
],
)
con.commit()
con.close()
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
# Pin the parent; posted_count reflects the parent's two completed turns.
f._write_state(bridge_dir, f._ForwardState(hermes_session_id="parent_1", last_id=3))
hstatus.write_posted_count(bridge_dir, 2)
async def _fake_post(_client, *, session_id, item):
return None
monkeypatch.setattr(f, "_post_conversation_item", _fake_post)
monkeypatch.setattr(f, "_persist_hermes_compaction_item", lambda *a, **k: _noop())
idle_posts: list[str] = []
async def _fake_idle(_client, *, session_id, status):
idle_posts.append(status)
monkeypatch.setattr(f, "_post_external_session_status", _fake_idle)
iteration = {"n": 0}
async def _sleep(_s):
iteration["n"] += 1
if iteration["n"] >= 2:
raise asyncio.CancelledError
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_child",
bridge_dir=bridge_dir,
agent_name="hermes-native-ui",
workspace=workspace,
launch_epoch_s=1000.0,
db_path=db,
)
# Re-pinned to the child, and the baseline dropped from the parent's 2 to the
# child's 1 completed turn — so the child's already-present completion is not
# suppressed. Without the rebase, posted_count would stay 2 and the guard
# (1 > 2) would suppress the child's idle.
assert f._read_state(bridge_dir).hermes_session_id == "child_1"
assert hstatus.read_posted_count(bridge_dir) == 1
assert idle_posts == [] # child's single completed turn == baseline, no double-post
# --- Usage tracker tests ---------------------------------------------------
@@ -664,3 +754,227 @@ async def test_persist_hermes_compaction_item_empty_db(tmp_path: Path) -> None:
assert body["type"] == "compaction"
assert body["data"]["last_item_id"].startswith("compact_boundary_")
assert "compacted_messages" not in body["data"]
# --- turn-completion ("idle") parent-wake path --------------------------------
#
# Hermes has no per-turn stop hook (only a ``pre_tool_call`` policy hook), so the
# forwarder derives turn completion from the message log itself: an ``assistant``
# row with no ``tool_calls`` is the agentic loop's terminal step. It POSTs
# ``external_session_status: idle`` once per completed turn — the edge that wakes
# the parent orchestrator — deduped against a persisted posted-count.
def _seed_turns(path: Path, *, cwd: str, started_at: float, session_id: str, n_turns: int) -> None:
"""Seed *n_turns* completed turns: each is user + assistant(final, no tool_calls)."""
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 = []
for i in range(n_turns):
rows.append((session_id, "user", f"ask {i}", None, None, None, 1))
rows.append((session_id, "assistant", f"answer {i}", None, None, None, 1))
con.executemany(
"INSERT INTO messages"
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
" VALUES (?,?,?,?,?,?,?)",
rows,
)
con.commit()
con.close()
def test_assistant_row_has_tool_calls() -> None:
assert f._assistant_row_has_tool_calls(None) is False
assert f._assistant_row_has_tool_calls("") is False
assert f._assistant_row_has_tool_calls("[]") is False
assert f._assistant_row_has_tool_calls("not json") is False
assert f._assistant_row_has_tool_calls(json.dumps([{"id": "c1"}])) is True
def test_count_completed_turns_counts_no_tool_call_assistant_rows(tmp_path: Path) -> None:
"""A turn ends on an assistant row with no tool_calls; tool-call steps don't count."""
db = tmp_path / "state.db"
con = sqlite3.connect(db)
con.executescript(_SCHEMA)
con.execute(
"INSERT INTO sessions(id, source, cwd, started_at) VALUES (?,?,?,?)",
("s1", "cli", str(tmp_path), 1000.0),
)
tc = json.dumps([{"id": "c1", "call_id": "c1", "function": {"name": "f", "arguments": "{}"}}])
rows = [
("s1", "user", "go", None, None, None, 1),
("s1", "assistant", "", None, tc, None, 1), # tool-call step -> not terminal
("s1", "tool", "result", "c1", None, "f", 1),
("s1", "assistant", "final answer", None, None, None, 1), # terminal -> +1
]
con.executemany(
"INSERT INTO messages"
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
" VALUES (?,?,?,?,?,?,?)",
rows,
)
con.commit()
con.close()
assert f._count_completed_turns(db, "s1") == 1
def test_count_completed_turns_counts_regardless_of_active(tmp_path: Path) -> None:
"""Soft-deleted (compacted, active=0) terminal rows still count, keeping it monotonic."""
db = tmp_path / "state.db"
_seed_turns(db, cwd=str(tmp_path), started_at=1000.0, session_id="s1", n_turns=2)
con = sqlite3.connect(db)
con.execute("UPDATE messages SET active = 0 WHERE role = 'assistant'")
con.commit()
con.close()
assert f._count_completed_turns(db, "s1") == 2
def test_hermes_status_posted_count_roundtrip_and_clear(tmp_path: Path) -> None:
bridge = tmp_path / "b"
assert hstatus.read_posted_count(bridge) == 0
hstatus.write_posted_count(bridge, 4)
assert hstatus.read_posted_count(bridge) == 4
hstatus.clear_hermes_status_state(bridge)
assert hstatus.read_posted_count(bridge) == 0
async def _run_hermes_loop(
monkeypatch,
*,
db: Path,
bridge_dir: Path,
workspace: str,
statuses: list[str],
stop_after_iterations: int,
) -> None:
"""Drive the real hermes forward loop with HTTP stubbed; record idle statuses."""
async def _noop_item(_client, *, session_id, item):
pass
async def _record_status(_client, *, session_id, status):
statuses.append(status)
monkeypatch.setattr(f, "_post_conversation_item", _noop_item)
monkeypatch.setattr(f, "_post_external_session_status", _record_status)
iteration = {"n": 0}
async def _sleep(_s):
iteration["n"] += 1
if iteration["n"] >= stop_after_iterations:
raise asyncio.CancelledError
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_idle",
bridge_dir=bridge_dir,
agent_name="hermes-native-ui",
workspace=workspace,
launch_epoch_s=1000.0,
db_path=db,
)
async def test_forward_loop_posts_idle_once_per_turn(tmp_path, monkeypatch) -> None:
workspace = str(tmp_path)
db = tmp_path / "state.db"
_seed_turns(db, cwd=workspace, started_at=1000.0, session_id="s1", n_turns=1)
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
statuses: list[str] = []
await _run_hermes_loop(
monkeypatch,
db=db,
bridge_dir=bridge_dir,
workspace=workspace,
statuses=statuses,
stop_after_iterations=2, # discover+mirror+idle in iter 1, then stop
)
assert statuses == ["idle"]
assert hstatus.read_posted_count(bridge_dir) == 1
async def test_forward_loop_idle_restart_safe(tmp_path, monkeypatch) -> None:
"""A restart whose posted-count already covers the completed turn posts no idle."""
workspace = str(tmp_path)
db = tmp_path / "state.db"
_seed_turns(db, cwd=workspace, started_at=1000.0, session_id="s1", n_turns=1)
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
hstatus.write_posted_count(bridge_dir, 1) # already reported before the "restart"
statuses: list[str] = []
await _run_hermes_loop(
monkeypatch,
db=db,
bridge_dir=bridge_dir,
workspace=workspace,
statuses=statuses,
stop_after_iterations=3,
)
assert statuses == []
assert hstatus.read_posted_count(bridge_dir) == 1
async def test_forward_loop_idle_dedupes_and_posts_per_new_turn(tmp_path, monkeypatch) -> None:
"""No duplicate idle while quiescent; a turn that lands later posts exactly one more."""
workspace = str(tmp_path)
db = tmp_path / "state.db"
_seed_turns(db, cwd=workspace, started_at=1000.0, session_id="s1", n_turns=1)
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
statuses: list[str] = []
async def _noop_item(_client, *, session_id, item):
pass
async def _record_status(_client, *, session_id, status):
statuses.append(status)
monkeypatch.setattr(f, "_post_conversation_item", _noop_item)
monkeypatch.setattr(f, "_post_external_session_status", _record_status)
iteration = {"n": 0}
async def _sleep(_s):
iteration["n"] += 1
# After the first turn has been reported, append a second completed turn
# so the next poll observes a strictly higher count and posts once more.
if iteration["n"] == 3:
con = sqlite3.connect(db)
con.execute(
"INSERT INTO messages"
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
" VALUES (?,?,?,?,?,?,?)",
("s1", "assistant", "answer 2", None, None, None, 1),
)
con.commit()
con.close()
if iteration["n"] >= 6:
raise asyncio.CancelledError
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_idle",
bridge_dir=bridge_dir,
agent_name="hermes-native-ui",
workspace=workspace,
launch_epoch_s=1000.0,
db_path=db,
)
# One idle for the seeded turn, one for the turn appended mid-run — never
# one-per-poll across the six iterations.
assert statuses == ["idle", "idle"]
assert hstatus.read_posted_count(bridge_dir) == 2
+3
View File
@@ -4,6 +4,7 @@ import { ClaudeIcon } from "@/components/icons/ClaudeIcon";
import { CodexIcon } from "@/components/icons/CodexIcon";
import { CursorIcon } from "@/components/icons/CursorIcon";
import { GooseIcon } from "@/components/icons/GooseIcon";
import { HermesIcon } from "@/components/icons/HermesIcon";
import { KimiIcon } from "@/components/icons/KimiIcon";
import { KiroIcon } from "@/components/icons/KiroIcon";
import { NessieIcon } from "@/components/icons/NessieIcon";
@@ -39,11 +40,13 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
if (nativeAgent?.iconKind === "goose") return GooseIcon;
if (nativeAgent?.iconKind === "kimi") return KimiIcon;
if (nativeAgent?.iconKind === "antigravity") return AntigravityIcon;
if (nativeAgent?.iconKind === "hermes") return HermesIcon;
// A null harness (spec couldn't load) flows through to the bot fallback.
if (agent.harness?.includes("codex")) return CodexIcon;
if (agent.harness?.includes("claude")) return ClaudeIcon;
// Both the SDK "cursor" harness and "cursor-native" get the Cursor glyph.
if (agent.harness?.includes("cursor")) return CursorIcon;
if (agent.harness?.includes("hermes")) return HermesIcon;
if (agent.harness?.includes("kiro")) return KiroIcon;
if (agent.harness?.includes("goose")) return GooseIcon;
// Both the SDK "kimi"/"kimi-code" harness and "kimi-native" get the Kimi glyph.
+39
View File
@@ -0,0 +1,39 @@
import type { SVGProps } from "react";
// Hermes Agent (Nous Research) glyph — an original caduceus: the winged staff
// with two entwined serpents, the classic emblem of Hermes the messenger.
// Drawn in currentColor so it follows the app theme like its sibling icons;
// authored here (not a third-party brand asset). The left wing + serpent are
// drawn once and the whole set is mirrored around the x=12 center for exact
// symmetry, so the two snakes open the caduceus' twin lens loops.
export function HermesIcon(props: SVGProps<SVGSVGElement>) {
// One wing (two feathers) spreading from the top of the staff.
const wing = "M11.5 5.9 C 9 4.7 6.4 4.7 4.8 5.7 " + "M11.5 7.3 C 9.4 6.6 7.2 6.8 5.8 7.9";
// One serpent weaving down the staff: it bows out, crosses the centre, bows
// back the other way, and converges at the foot. Mirrored, the two snakes
// weave the entwined caduceus.
const snake = "M12 8.2 C 8.9 9 8.6 11 12 12 C 15.4 13 15.1 15 12 16 C 9.9 16.7 9.6 17.6 12 18.2";
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{/* staff */}
<path d="M12 5 V 20" />
{/* left wing + serpent */}
<path d={wing} />
<path d={snake} />
{/* right wing + serpent — the left set mirrored around the x=12 center */}
<g transform="matrix(-1 0 0 1 24 0)">
<path d={wing} />
<path d={snake} />
</g>
</svg>
);
}
+2
View File
@@ -35,6 +35,7 @@ import { ClaudeIcon } from "@/components/icons/ClaudeIcon";
import { CodexIcon } from "@/components/icons/CodexIcon";
import { CursorIcon } from "@/components/icons/CursorIcon";
import { GooseIcon } from "@/components/icons/GooseIcon";
import { HermesIcon } from "@/components/icons/HermesIcon";
import { KimiIcon } from "@/components/icons/KimiIcon";
import { KiroIcon } from "@/components/icons/KiroIcon";
import { NessieIcon } from "@/components/icons/NessieIcon";
@@ -317,6 +318,7 @@ function brandChildIcon(child: ChildSessionInfo): AgentRowIcon | null {
if (nativeAgent?.iconKind === "antigravity") return AntigravityIcon;
if (nativeAgent?.iconKind === "goose") return GooseIcon;
if (nativeAgent?.iconKind === "kimi") return KimiIcon;
if (nativeAgent?.iconKind === "hermes") return HermesIcon;
// Exact match — substring checks would false-match names like "pipeline".
if (child.tool === PI_AGENT_NAME) return PiIcon;
return null;