OpenRouter's /v1/models entries advertise reasoning capability
(supported_parameters + reasoning.mandatory/supported_efforts). Use that
metadata as the primary gate in _supports_reasoning_extra_body instead of
the hand-maintained vendor-prefix allowlist, which went stale one vendor at
a time (nvidia/ missing -> #75386). Also clamp the requested effort to the
nearest LOWER catalog-supported level in the OpenRouter profile so ultra/max
against a high-capped route no longer 4xxes.
Cache-only on the hot path: capabilities parse for free out of the existing
fetch_openrouter_models() payload, a background warmer covers cold starts,
and unknown models/offline catalogs fall back to the static prefix list
unchanged.
omo's ultimate-browsing engine added a 'surrogate retrieval tier' (PR #6662):
when a page fetch is blocked by a WAF/paywall/rate-limit, it falls back to
third-party copies (Wayback, archive.today, Jina Reader) with strict
provenance labeling and validators that reject fake successes (dead Google
Cache interstitials, AMP redirect stubs, rate-limit bodies).
Hermes adaptation: a bundled research skill + stdlib-only script instead of
a Python sub-engine — zero core-tool footprint, per the footprint ladder.
Clean-room implementation (their repo is Sustainable Use License; nothing
copied), keeping the good ideas: provenance contract (snapshot vs live),
body validation over status codes, domain rotation for archive.today,
API-first pivot guidance, and explicit skip of proxy relays (MITM).
E2E tested: recovered a real 486KB Wayback snapshot with timestamp;
validators reject redirect stubs, interstitial titles, and sub-floor bodies.
Port from cline/cline#12983 (the 'invalidate tokens when OAuth client
changes' invariant): tokens are minted for a specific client_id, so after
a user edits oauth.client_id / oauth.client_secret in config.yaml the old
tokens can only fail with invalid_client. Pre-registered clients are
deliberately exempt from the invalid_client auto-poison path, so the stale
tokens wedged every request until ~/.hermes/mcp-tokens/<server>.* was
wiped by hand.
_maybe_preregister_client() now compares the on-disk client.json identity
against the incoming config identity before overwriting it and discards
tokens.json + meta.json on a mismatch (with a log line pointing at
hermes mcp login). Unchanged identity is a strict no-op.
Proven live on main with an isolated-HERMES_HOME E2E probe; regression
tests sabotage-verified (fail without the wiring line).
Adds a 'council' synthesis style to MoA (per preset via synthesis_style,
one-shot via the new /council command on CLI + gateway). Reference models
answer independently; the aggregator chairs the deliberation and produces
a user-facing report of consensus, per-model disagreements (with the
differing assumptions behind them), unique contributions, and a
recommendation with an explicit confidence level.
Inspired by Perplexity's Model Council rollout to Perplexity Computer
(changelog 08/04/26): pick a board of 2-8 models, run them independently,
synthesize where they agree/disagree and what each uniquely surfaces.
_spawn() now uses subprocess.Popen + communicate() instead of
subprocess.run(); the windows_only creationflags assertion mocks Popen
accordingly and additionally pins that the POSIX-only process_group
kwarg never reaches a Windows spawn.
Port from openai/codex#37527: Terminate timed-out hook process trees.
A shell hook that forked helpers (scanners, watchers, "cmd &") and then hit
its timeout left those descendants running forever — subprocess.run() only
kills the direct child. Worse, descendants holding the inherited pipe write
ends could stall run()'s post-kill communicate() drain.
- agent/shell_hooks.py _spawn(): spawn hooks in their own process group on
POSIX (process_group=0, Python >=3.11); on timeout/error, reap the whole
tree via the shared kill_process_tree() helper, then drain bounded (1s).
Hooks that complete in time keep their descendants, so intentionally
detached helpers survive successful runs (mirrors codex semantics).
- hermes_cli/_subprocess_compat.py: rename _kill_git_process_tree ->
kill_process_tree (it was never git-specific; taskkill /T /F on Windows,
ownership-gated os.killpg on POSIX). Backward-compat alias retained.
- tests/agent/test_shell_hooks_tree_kill.py: real-subprocess regression
tests (descendant killed on timeout, preserved on success, own-group
spawn, fast-path contract, fail-open). Sabotage-verified: reverting the
process_group spawn fails exactly the two new behavior tests.
Gap proven live on main first: a forking hook timed out at 2s and its
descendant survived; same probe against this branch shows it reaped.
Ubuntu CI (and other distro builds) ship a sqlite3 shell compiled without
the sqlite_dbpage virtual table that .recover requires, so PATH presence
alone let the lane attempt and fail with 'no such table: sqlite_dbpage'.
find_sqlite3_cli() now probes .recover on a scratch DB once; the test skip
gate uses the same probe, and the no-CLI guidance names the capability
requirement.
Fixes#80205: when one ordered rowid-edge probe failed,
_salvage_rowid_bounds() substituted the whole SQLite rowid domain and
_copy_table_salvage() burned the 10,000-query budget bisecting a
synthetic tail that could not contain rows, silently omitting readable
boundary rows (field case: message 76882 of 76882). Two-part fix:
* _probe_populated_edge(): gallop outward from the surviving edge with
doubling offsets; a clean 'no rows beyond X' probe caps the domain in
O(log range) queries instead of exhausting the budget on it.
* exact-key singleton salvage: a one-row range scan must advance the
cursor past the hit into the damaged sibling page to prove exhaustion,
which discards the already-produced row; 'WHERE rowid = ?' stops at
the hit, recovering the boundary row exactly like sqlite3 .recover.
* the strict-path refusal now points users at --allow-partial.
New last-resort lane for --allow-partial when the sessions/messages
table schemas themselves are unreadable (previously a hard refusal even
though page-level salvage recovers the rows fine). If a sqlite3 CLI is
on PATH, shell out to '.recover --ignore-freelist' into a scratch
lost_and_found DB, then heuristically map rows back into a fresh
SessionDB-schema database (hermes_cli/session_lost_and_found.py):
classification keyed on nfield counts + sentinel columns (session ids
matching ^\d{8}_\d{6}_, roles in user/assistant/tool/system, known
source strings), covering the current 54-col sessions layout, the
52-col historical layout, a 14-col legacy identity-only salvage,
rowid-alias messages rows and 18-col session_model_usage rows. Missing
parent sessions are stubbed (children are never deleted for FK
cleanup), FTS is rebuilt at the end, and output is labeled BEST-EFFORT
everywhere. Without the CLI the error names the sqlite3 requirement
with actionable guidance. Mirrors a successful manual recovery of a
real corrupt state.db (2026-08-12), and this lane was validated against
that preserved file: 32 sessions / 7 messages / 4 usage rows mapped,
integrity_check ok, opens via SessionDB.
Also fixes#72291: the source-fingerprint 'bundle changed while it was
being copied' error now enumerates that the parent interactive CLI
session itself counts as a Hermes process and suggests a fresh shell or
an immutable snapshot.
Tests use real physical page corruption (flipped b-tree/schema header
bytes), skip the CLI-dependent path cleanly when sqlite3 is absent, and
keep the mapper unit tests binary-independent via a synthetic
lost_and_found DB. Sabotage-verified: reverting the fixes makes the
regression tests fail with the exact field failure shape.
Salvaged remainder of PR #82280 (state.db hardening rollup):
- Runtime connection corruption: a sibling process replacing/truncating
the backing file breaks the live write connection — every subsequent
write raises 'file is not a database' and the gateway wedges
permanently (messages pile up in memory). Add a bounded one-shot
reconnect on the write path: close the broken connection, reopen the
DB file (re-running WAL activation + schema reconciliation), retry
the failed write once.
- _on_disk_journal_mode: retry transient 'disk i/o error' (virtualized
block devices) a few times before returning None, so a one-shot EIO
doesn't push callers onto the fail-closed unknown-mode branch.
The rollup's write-lock machinery, checkpoint-strategy changes, and
repair serialization are intentionally NOT included — superseded by
PRs #84277 and #69609, or wrong-direction per the POSIX
lock-cancellation findings (#71724 lineage).
`sessions optimize` could consume several GB of disk instead of freeing
any, filling the host to 100% on exactly the large databases it exists to
shrink.
Two causes, both in the WAL lifecycle:
1. No `journal_size_limit`. SQLite defaults to -1 (unlimited), so after a
checkpoint the WAL is reused in place and never truncated —
`state.db-wal` permanently keeps the high-water mark of the largest
transaction ever run. `hermes_cli/kanban_db.py` already bounds its WAL
with `wal_autocheckpoint=100`; the session store, by far the larger
database, had no equivalent.
2. `vacuum()` checkpoints BEFORE `VACUUM` but not after. VACUUM rewrites
every page through the WAL, so the pre-checkpoint does nothing about
the slack VACUUM itself creates.
Measured on a 3.0 GB state.db: `hermes sessions optimize` reported
"3143.9 MB -> 3155.1 MB (reclaimed -11.2 MB)" while leaving a 3.07 GB
state.db-wal behind. Free space fell from 6.9 GB to 772 MB (100% full)
and stayed there. A manual `PRAGMA wal_checkpoint(TRUNCATE)` recovered
the full 3.07 GB, confirming it was slack, not data.
Fix: set `journal_size_limit` (64 MiB) when enabling WAL, and truncate
the WAL again after VACUUM. Both are best-effort and never raise — a
failure costs disk slack and must not stop the DB from opening.
Tests assert the contract (limit is a finite positive bound; VACUUM does
not leave an oversized WAL) rather than pinning the byte count, which is
a tunable. They skip where WAL is unavailable — including hosts where
Hermes falls back to journal_mode=DELETE due to the SQLite 3.50.4
WAL-reset bug.
Verified: 462 passed / 3 skipped in tests/test_hermes_state.py, and
_apply_wal_size_limit flips a real WAL database from -1 to 67108864.
Tested on Linux (aarch64, Python 3.11).
The Aug 2026 incident in #69603 documented a fail-open: when the
pre-repair backup was refused (another same-process handle open),
repair_state_db_schema() recorded backup_path=None and proceeded —
leaving the writable_schema surgery, FTS-schema deletion, REINDEX and
VACUUM strategies reachable against the only remaining copy of the
damaged DB.
_backup_db_file() now returns (path, reason) and the repair path treats
any refused/failed backup as an unconditional hard stop: abort before
the first mutating strategy and surface the reason in report['error'].
Explicit backup=False (CLI --no-backup) is unchanged — that is the
operator opting out, not a silent failure.
Three new tests: refusal hard-stops with source bytes untouched,
OS-level copy failure hard-stops with the reason surfaced, and
backup=False still repairs.
`repair_state_db_schema()` performs `PRAGMA writable_schema=ON` +
`sqlite_master` surgery + `VACUUM` on a private connection. The only guard
around it is `_repair_attempt_lock`, a `threading.Lock`, whose docstring
claims it "serialises concurrent web_server / gateway opens" — but a
threading lock covers threads inside one interpreter, not processes.
A normal host runs four independent processes against the same state.db:
the gateway service, the Desktop app's own `hermes serve` backend (it
spawns one per launch, not a thin client), interactive CLI sessions, and
the TUI slash worker. When two of them hit a malformed DB, both entered
the critical section and each ran the full surgery while the other was
mid-rewrite. Observed as a repair/re-corrupt cascade: the DB is repaired,
then re-corrupts minutes later, repeatedly.
Two fixes:
1. Wrap the surgery in a bounded `flock` on `<db>.repair.lock`. `flock` is
the right primitive — the kernel drops it when the holder dies, so a
crashed repairer cannot wedge future repairs the way a pidfile would.
The acquire is bounded (#36644's failure shape) and, unlike the kanban
init lock, a caller that times out must NOT proceed: here "proceed
anyway" is exactly the unsafe interleaving. It re-probes instead, and
reports success if the holder already healed the file.
Under the lock, the existing `_db_opens_cleanly()` check becomes a
double-check: a queued process finds the DB healthy and returns
`already_healthy` rather than re-running surgery on a repaired DB.
2. Bump the schema cookie after direct `sqlite_master` edits. Ordinary DDL
bumps it for free and every other connection compares it before running
a prepared statement — that is how they learn to drop a cached schema.
Editing `sqlite_master` under `writable_schema=ON` does not, so live
connections in other processes kept writing `messages` rows through
triggers into `messages_fts*` shadow tables the surgery had just
deleted. SQLite's writable_schema docs call out incrementing
`schema_version` as the required companion to such an edit.
Tests: four new cases in tests/test_state_db_malformed_repair.py, all
using real child processes and a real flock. All four fail on main and
pass with this change; the concurrency case asserts exactly one
`malformed-backup-*` file is produced by two simultaneous repairers
(two on main). Full state suite: 558 passed.
Complements #43742, which makes the *in-process* claim loser retry rather
than raise; it explicitly leaves `repair_state_db_schema()` unchanged and
does nothing cross-process. The two are independent and compose.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the state.db PASSIVE checkpoint salvage (PR #84277,
#45383/#80255/#44795): the kanban dispatcher's periodic explicit
checkpoint still used TRUNCATE on the shared kanban.db. The dispatch
flock only serializes dispatchers — CLI kanban commands in other
processes write to the same board without it, so the TRUNCATE races
live writers exactly like the state.db close() path did.
Switch it to PASSIVE and bound the -wal file with
journal_size_limit=8MiB set at connection init (SQLite trims the file
on the writer's natural post-checkpoint reset), since PASSIVE never
truncates.
tests/hermes_cli/test_kanban_db_repair.py updated to assert PASSIVE
and reject TRUNCATE. Remaining TRUNCATE call sites are test fixtures
operating on private temp DBs (sole opener), which is the legitimate
use.
SessionDB.close() ran `PRAGMA wal_checkpoint(TRUNCATE)`. Every cron
run_agent opens and closes its own transient SessionDB, so on a busy
fleet this fired a full WAL reset many times an hour, racing the
gateway's long-lived writer on a large WAL database and tearing hot
B-tree pages -- structurally the same corruption this module's own
periodic checkpoint was already switched to PASSIVE to avoid (#45383).
Only close() and two manual-maintenance paths still used TRUNCATE.
Route every checkpoint on the shared state.db through PASSIVE:
- close() (hermes_state.py)
- pre-VACUUM in vacuum() (hermes_state.py)
- post-optimize-storage (hermes_state_search.py)
PASSIVE never resets/truncates the WAL and never takes the exclusive
checkpoint lock, so it cannot lose a transient closer's race with the
live writer. The WAL is instead bounded by `journal_size_limit` and the
writer's natural post-checkpoint reset. TRUNCATE belongs only on a
sole-opener/quiescent connection (e.g. offline maintenance); this change
does not try to detect that -- PASSIVE is the safe default.
Diagnosed as the root cause of three state.db B-tree corruptions in
2026-08: damage localized to the hottest-written pages (gateway_routing
and the sessions indexes), with whole zero-filled pages still live and
off the freelist -- the checkpoint/reset-race signature, not disk or
application SQL.
Tests: tests/test_wal_checkpoint_strategy.py now asserts PASSIVE at
close(), before vacuum(), and after optimize_fts_storage() VACUUM;
tests/test_hermes_state.py asserts close() likewise. Focused run:
226 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hook queries now lazy-discover plugins (delivery parity, #64178). The
relay direct-runtime tests build a bare PluginManager to prove zero
plugins are involved; mark it discovered so the parity path doesn't
populate it from the real plugin tree mid-test.
Salvaged from PR #64188 (@Bartok9), re-reviewed against the #64229
ownership ledger (landed in #84923).
Delivery parity (survived):
- Module-level invoke_hook/invoke_middleware/has_hook/has_middleware
lazily run plugin discovery via _delivery_manager(), so surfaces that
never import model_tools (dashboards, TUI slash workers, query mode,
cron, gateway platform events) deliver plugin callbacks instead of
silently dropping them (#50776, #67597, #67890, #50937).
- _delivery_manager() joins any in-flight background discovery first and
tolerates test doubles that monkeypatch get_plugin_manager().
Symmetric force-reload (survived):
- agent/shell_hooks.py gains re_register_config_hooks(); the force branch
of discover_and_load() calls it after a successful sweep, restoring
config.yaml shell hooks that the ledger-driven unload wiped but cannot
restore (they are config-owned, not plugin-owned) (#60036).
- unload(plugin=None) now sweeps pre-ledger _plugin_tool_names entries
out of the process-global tools.registry, mirroring the platform-name
sweep that already existed, so zombie tools cannot survive a force
reload in long-lived pre-ledger processes (#60050).
Superseded by the ownership ledger (dropped from #64188):
- _unload_global_plugin_registrations() bulk tool/platform teardown —
the ledger's reverse-order handle disposal with previous-entry
restoration covers it more precisely.
- tools.registry/platform_registry displaced-entry LIFO restore stacks —
the ledger's restore_registration() identity-checked previous-entry
restoration made them redundant.
- Discovery serialization lock + double-checked singleton — main already
has _discovery_lock on every discover/unload path and a keyed,
lock-guarded per-home manager cache (#24714 concern is covered).
Fixes tracked under #64178 (#50776, #60036, #60050, #24714, #67798,
#50937, #67597, #67890, #31480 — #31480 already handled on main by
_parse_hooks_block warn+suggest).
Scope events export when their OWNING scope closes. Turn scopes close
every turn; session scopes close only at session end. Marks were attached
to the session handle, so a long-lived conversation — a Slack thread open
all day, the normal enterprise case — emitted no approval or turn marks
for hours, and none at all if the process died first. Audit dashboards
showed an empty approval table while approvals were demonstrably firing;
the operator had to end the session to see anything.
Attach marks to the live turn handle when one exists for the mark's
session (active_turn already validates live/same-profile/same-session/
unreleased), falling back to the session handle otherwise — correct for
session-level events like session.end and for marks emitted outside a
turn. Parentage semantics are unchanged: the turn is a child of the
session, so the session tree is identical, only export cadence changes
from per-session to per-turn.
Approval marks were emitted under a synthetic 'default' relay session:
the hook payload carried only turn_id/tool_call_id, so the observability
plugin's _session_id() fell back to 'default', parenting approval marks
to a session scope that never closes — and close-time exporters never
shipped them. The audit board's approval tables stayed empty while
approvals were demonstrably firing (staging 2026-08-10).
Bind session_id in set_current_observability_context at both dispatch
sites (model_tools tool dispatch, plugins pre-tool-call approval gate)
and forward it on every approval hook. Explicit session_id in a hook
payload still wins; unbound contexts omit it (legacy behavior).
CI caught the file hanging AFTER '6 passed in 4.32s' until the runner's
300s SIGKILL. Two defects, same class the PR fixes:
1. The executor-refused (interpreter shutdown) fallback ran the native
call UNBOUNDED on the calling thread — a wedged pipeline would block
process exit forever. Now runs on a bounded daemon exit-thread with
the same timeout/abandon semantics as the executor lane.
2. The wedge tests left daemon workers parked on Event.wait() and live
sessions registered on the atexit shutdown hook; exit re-ran the
wedged pops (bounded, 10s each) and the per-file runner timed out.
Autouse teardown now releases every wedge and drains each runtime.
Canonical runner: 4.4s (was 300s file-timeout kill). Bare pytest was a
false green for this class — it exits before atexit replay cost shows.
The NeMo Relay native binding's scope.pop/push are synchronous and
unbounded ('returns after the scope is closed successfully'). When the
native pipeline cannot make progress, the session coordinator's turn and
session finalization block forever inside run_conversation: delegated
children finish their turns but never return, and delegation batches die
on the stall watchdog. Proven live 2026-08-10 on the staging fleet — a
falsification probe (plugin disabled, identical config) completed the
same delegation batch that wedged with the plugin active.
Bound every scope lifecycle operation that gates turn/session completion
(session push, turn push, turn pop, logical-LLM pops, session pop,
subscriber flush) by running the native call on a shared
DaemonThreadPoolExecutor and honoring a 10s result timeout. On breach a
TimeoutError propagates into each call site's existing exception
handling — warn, retain the unclosed-prefix diagnostics, continue — so
the worst case is one lost span, never a blocked agent. timeout=None
preserves byte-identical synchronous behavior for all other callers, and
interpreter-shutdown paths fall back to the synchronous call so the
atexit flush still exports.
Observability must never block the product.
Part A — pre_command observer hook (observer-first per #64182 ground rule 3):
- New VALID_HOOKS event `pre_command`: fires when a recognized slash command
is about to be dispatched, BEFORE the handler runs, on both surfaces:
- CLI: cli.py process_command (right after alias resolution)
- Gateway: gateway/run.py _handle_message cold-path canonical dispatch
- Payload: surface ('cli'|'gateway'), command (canonical), alias_used,
args_raw, session_key, platform. Return values IGNORED in v1; a plugin
returning a directive-shaped dict gets a debug log so future
block/rewrite adopters are discoverable (#64231 taxonomy).
- Deliberately NOT fired on the gateway running-agent intercept path
(/stop, /approve, busy_policy dispatch during an active run): those are
control-plane escape hatches on an in-flight run and must stay outside
plugin observation/veto reach.
- fire_pre_command_hook() helper never raises, so broken plugin infra can
never break command dispatch.
Part B — ctx.call_mcp (capability-gated, default-off, ground rule 4):
- PluginContext.call_mcp(server, tool, arguments, timeout=30): synchronous,
callable from plugin hooks/tools, routes through the EXISTING native MCP
client machinery (tools.mcp_tool._make_tool_handler: background loop,
trust-tier gates, circuit breaker, reconnect) — never a parallel client.
- Gate: plugins.entries.<id>.mcp_allowlist (list of server names).
Absent key / unreadable config / non-list value => default-deny.
Unlisted server raises PermissionError naming the exact config key.
TODO seam left for the #64228 declared-capability model.
- Bounded: timeout clamped to 1-600s and forwarded to the MCP loop call;
results capped at 64KB with truncation marker; stable
{ok, result|error, structuredContent?, truncated?} envelope.
Tests (transport mocked, no live MCP servers):
- tests/hermes_cli/test_pre_command_hook.py: both surfaces fire, canonical
alias reporting (/exit->quit, /q->queue), hook-before-handler ordering,
control-plane exclusion, hook failure non-fatal, observer-only directive
handling.
- tests/hermes_cli/test_plugin_call_mcp.py: default-deny (absent entry,
unreadable config, non-list, '*'), allowlist enforced per-server,
denied calls never touch transport, timeout forwarding/clamping,
result truncation, error/structuredContent envelopes.
Docs: hooks.md shipped-catalog row for pre_command; plugins.md
"Calling MCP servers from plugins" section with the security note.
Closes#64204
The streaming-hook dispatcher runs one worker per callback; delivery
order is FIFO per hook, never across hooks. Two tests pinned a global
start->delta->delta->end interleaving that three concurrent workers
don't guarantee, flaking CI twice within an hour of #84924 landing.
Also wait for the full event count before shutdown so late deltas
aren't dropped mid-assert.
Rebase over the capability-model merge dropped two behaviors the tests
pin: (1) unload_all must still unregister every _plugin_platform_names
entry from the global platform registry (pre-ledger state has no
handles); (2) list_plugin_sources() must see profile-scoped
registrations — scoped entries are plugin-registered by definition.
Extends the salvaged #64229 ledger (PR #76490) to cover the registries
added on main since the PR was cut, and lands the remaining Phase 0
lifecycle pieces:
- register_system_prompt_section and register_approval_transport now
record ownership handles, so unload/force-reload removes plugin
system prompt sections and approval transports too
- ctx.on_unload(callback): plugin cleanup callbacks run through the
reverse-order ledger walk, exception-isolated
- ctx.spawn_task(coro): supervised background asyncio tasks tracked in
the ledger and cancelled on unload
- document the #65593 multi-profile constraint on the ledger (keyed per
manager/(hermes_home, plugin_id); identity-conditional restores) with
a TODO(#64178) for full profile keying of remaining global slots
Part of #64229; prerequisite for #64178.
The pre_transcription hook threads (path, language, source) through
gateway voice transcription; three sibling telegram-voice tests pinned
the old single-arg call shape.
Adds a pre_transcription transform hook (prompt/language/model mutable,
file_path read-only, last-writer-wins per the transform_* convention)
fired before any STT backend, threads prompt to faster-whisper
(initial_prompt) and OpenAI/Groq/Mistral/DeepInfra (prompt), adds an
optional stt.prompt config key on the same plumbing, and keeps the
no-hook dispatch path byte-identical. Fixes#64168.
Documents the new surface for users: a "Transcription prompt
(vocabulary hints)" subsection in the configuration guide (composition
order, per-provider support matrix, length contract, privacy warning),
a pre_transcription entry in the hooks reference, and the mirrored row
in the plugins hook table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6LyYMvHC2o6HbVUozmVR
Todo, subagent, background, and preview titles in the composer status
stack were capped at an arbitrary max-w-[18rem], ellipsizing long items
way before the row ran out of space. The spans already live in the
shared StatusRow's min-w-0 flex-1 content slot, so plain truncate gives
correct overflow at the actual row edge — drop the cap.
Give plugins a first-class, namespaced pub/sub event bus so plugin↔plugin
interaction is a declared, testable contract instead of ad-hoc imports.
Closes#64164 (sub-issue 03/14 of the plugin-interface expansion epic #64182).
Additive-only: when no plugin calls emit/subscribe, behavior is unchanged.
Interface (on PluginContext):
- `ctx.emit(event, payload=None) -> int` publishes to subscribers and returns
the count invoked. The namespace is FORCED to the plugin's own registry key
(`manifest.key or manifest.name`): pass only the bare event name, delivered
as `<key>:<event>`. Fail-closed — any name containing `:` (a `hermes:`
reserved-core prefix, a foreign `other:` namespace, or an own-colon'd name)
is rejected with a ValueError + logged warning naming the plugin.
- `ctx.subscribe(full_event, callback)` registers an ordered listener for a
fully-qualified `<plugin>:<event>`. Subscribing is unrestricted (any plugin
may listen to any published event); only emitting is namespace-gated.
Delivery mirrors invoke_hook: registration-order iteration, per-callback
try/except isolation (one raising subscriber never breaks delivery to the
rest), payload passed as `cb(**payload)`. A per-thread depth counter caps
re-entrant emits at 8 — mutually-emitting plugins terminate cleanly with one
logged warning, never an infinite loop or RecursionError.
Discoverability: optional advisory `emits:`/`listens:` manifest fields (no
manifest-v2 dependency; not enforced) are parsed and surfaced by a new
`hermes plugins show <name>` (alias `info`) command. `get_plugin_subscriptions()`
module accessor mirrors `get_plugin_auxiliary_tasks()`.
Tests (tests/hermes_cli/test_plugin_event_bus.py, 22): two-plugin delivery +
listener count; forced namespace (delivered as `b:ping`); spoof rejection
(parametrized `hermes:x` / foreign / own-colon'd / `:x` / `x:` / empty) with
no delivery; name-fallback when key empty; per-callback isolation; recursion
cap termination + warning; manifest emits/listens parsed (present/absent/from
yaml); module accessor; `plugins show` output. `pytest test_plugin_event_bus.py
test_plugin_auxiliary_tasks.py` → 37 passed. I independently re-verified the
namespace rejection and recursion-cap termination outside the test suite.
Note: the reserved-name gate rejects any `:`-containing input rather than a
bare-name denylist — a bare `core_event` is allowed and delivered under the
plugin's own namespace. Say the word if a reserved bare-name list is wanted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013b1XyXitAxV7phGmKWigJX
Two review items raised on #65449 (thanks @hansai-art):
1. Explicit test that post-module-load registration REBUILDS the
_PREFIX_SUBSTRINGS pre-screen tuple — plugin patterns flow through
the same fast path as built-ins, never around it. This was covered
implicitly by the masking tests; now it is asserted directly.
2. Plugin patterns are now stored keyed by registration source, giving
the #64229 lifecycle/ownership-ledger work a clean seam to drop one
plugin's patterns on unload. No public removal API is added —
additive-only stands; unload remains a host-owned lifecycle concern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnMCvi2vXqfs996AjVeF2F
Nested unbounded quantifiers ((a+)+, (?:x*)*, (a{2,})+) backtrack
catastrophically, and registered patterns run against every log line
and tool output, so a pathological pattern from a buggy plugin would
stall the host process. Registration now rejects the structural
nesting shape with a logged warning, same fail-soft contract as the
other validators.
Detection is a hand-rolled scanner matching the top-level-alternation
check's idiom: escapes and character classes skipped, group stack
tracks whether each group body contains an unbounded repeat, reject
when such a group closes into an unbounded quantifier. Overlapping
alternation ambiguity ((a|aa)+) is documented as out of scope.
Also refreshes the test module docstring left stale by the demo-plugin
unbundling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnMCvi2vXqfs996AjVeF2F
'ab|.*' compiled and carried the accepted 'ab' literal prefix while its
'.*' branch stayed unprefixed, escaping the no-redact-everything
guarantee (_extract_literal_prefix stops at '|'). Registration now
rejects top-level alternation with a regression test for exactly that
shape; grouped alternation after the prefix, escaped pipes, and
character-class pipes remain accepted.
The bundled nvapi-redaction reference plugin is removed per repo policy
(vendor integrations ship as standalone plugin repos); the end-to-end
register() coverage now uses a synthetic plugin written at test time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnMCvi2vXqfs996AjVeF2F
Every new vendor token format has required a core PR appending to
_PREFIX_PATTERNS in agent/redact.py (fw_, retaindb_, hsk-, mem0_, brv_
all landed that way; #58466/#58501 are the latest of the class). This
adds an additive-only registry so provider plugins own their format:
- agent/redact.py: register_redaction_patterns(patterns, source) —
validates each pattern (must compile; must start with >=2 literal
characters so the pre-screen substring gate keeps working and
redact-everything patterns like `.*` are structurally impossible),
dedupes against built-ins and prior registrations, then atomically
rebuilds _PREFIX_RE and _PREFIX_SUBSTRINGS. Registered patterns get
identical treatment to built-ins everywhere: same head/tail masking,
same non-reusable «redacted:label…» sentinel on file_read, same
security.redact_secrets operator opt-out. Additive-only by design —
a plugin can extend masking, never weaken it. Includes a
test/teardown reset helper.
- hermes_cli/plugins.py: PluginContext.register_redaction_patterns()
delegating with per-plugin attribution; warns and returns 0 on any
failure so a broken plugin can never break startup.
- Bundled reference plugin `nvapi-redaction` (opt-in): masks NVIDIA
API keys (nvapi-, used by NIM / build.nvidia.com) — a real format
missing from core, shipped as the one-liner plugin that previously
would have been a one-line core PR.
13 new tests: baseline gap, masking + built-ins unaffected, invalid
regex / no-literal-prefix / dedupe / non-string rejection, file_read
sentinel labeling, reset semantics, PluginContext wiring incl.
exception isolation, and a no-mocks end-to-end through the demo
plugin. Existing redaction suites (tests/agent/test_redact.py,
tests/tools/test_kanban_redaction.py) pass untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWMcB7RPSYUpsXDfBgwjzM
Static machine-readable community plugin index with fuzzy search and
index-resolved installs, mirroring the Skills Hub catalog pattern
(fetch → HERMES_HOME/cache with 24h TTL → bundled seed fallback).
- hermes_cli/plugin_index.py: index fetch/cache/seed chain, fuzzy
search (name/description/tags/author + typo tolerance), capability
filter, bare-name resolution. Canonical URL overridable via
plugins.index_url config key.
- hermes_cli/data/plugin_index.json: bundled seed (offline fallback +
format reference) with 5 real ecosystem plugins, each pinned to an
exact commit SHA.
- hermes plugins search [term] [--json] [--capability] [--refresh]:
Rich table or JSON output, offline-safe, with an explicit
'indexed ≠ audited' footer.
- hermes plugins install <name>: bare names (no slash, no URL scheme)
resolve through the index to owner/repo[/subdir] @ pinned ref and
hand off to the existing install path (ref wired through the #82029
exact-ref support). Ambiguous names list candidates and exit;
explicit owner/repo and Git URL installs are untouched, and an
explicit --ref always beats the index pin.
- Docs: discovery section in user-guide plugins.md (format, submission
workflow via PR to hermes-plugin-index, security framing) and
reference/cli-commands.md rows.
- Tests: tests/hermes_cli/test_plugin_index_search.py (38 tests, no
live network) covering parsing, search, remote→cache→seed fallback,
TTL, install resolution/ambiguity/passthrough, and --json output.
Closes#26193
Adds ContextReferenceProvider ABC so plugins can register custom
@-prefixes (e.g. @issue:ENG-123) with autocomplete and expansion.
Plugin output flows through existing token-limit guards. Zero
breaking changes.
Additive plugin.yaml v2 fields (all optional; v1 manifests unchanged forever):
- manifest_version: manifest FILE-FORMAT version (absent = 1). Deliberately
split from api_version per the round-2 design correction. Newer-than-
supported versions load with a warning, unknown fields ignored.
- api_version: runtime plugin API generation the plugin targets (integer).
- requires_plugins: advisory inter-plugin deps ({id, version_range?}).
Missing dep = warn + still load (ctx.has_plugin() runtime probe added).
Load ORDER is dependency-respecting: graphlib topological sort, stable
alphabetical tiebreak; cycles warn and fall back to alphabetical.
- python_dependencies: declared pip requirements — VALIDATED AND SURFACED
ONLY (loader warning + install-time printout + doctor checks with a pip
install hint). Never auto-installed: the isolation design for the install
seam (#15220) is an explicitly deferred follow-up per the round-2 review.
- config_schema: JSON-schema-ish description of plugins.entries.<id>.settings
keys; validated at load, mismatches are actionable warnings naming the key
and expected type — never load failures.
- Formalized metadata: license, homepage, tags.
- Unknown manifest fields warn-don't-fail (debug-level for v1 manifests).
- hermes plugins doctor gains v2 checks: future manifest_version, invalid
api_version, dep declarations, unpinned/missing python_dependencies,
unknown config_schema types.
- Docs: manifest v2 reference table in the developer-guide plugins index,
including the explicit pip-seam isolation deferral and the note that
#64166 packs build on these fields.
- Tests: tests/hermes_cli/test_plugin_manifest_v2.py (19 tests) covering v1
regression, v2 parse, unknown-field warn, dep order, cycle fallback,
config_schema warnings, and the surfaced-not-installed pip seam.