ce225f3117
* 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.
* 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.
* 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.
* 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.
* 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.
* 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.
* 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.
77 lines
3.6 KiB
Python
77 lines
3.6 KiB
Python
"""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()
|