Files
Pat Sukprasert ce225f3117 feat(polly): add cursor and hermes coding sub-agents (#1844)
* 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.
2026-07-02 23:55:04 +07:00

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()