c8a6b3ca949d58955076568aaaec72a7fe92d7ee
1014 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a21747fe4e |
fix(kanban): an anchorless thread subscription warns once instead of vanishing (#110919)
Follow-up on the salvaged #110928 (CLI `--parent-chat-id` / `--guild-id`): - `_claim_for_sub` skipped a thread-shaped row that matched no `profile_routes` entry at DEBUG on every tick. Legacy rows written before the flags existed can never match a channel-level route (no `parent_chat_id`), so the notifier now logs ONE WARNING per row naming the task, the thread and the re-subscribe command. Still fail-closed: the events stay unclaimed. - Docs: the kanban user guide explains the anchors and shows the Discord-thread subscribe command under `profile_routes`. - Test (red on origin/main): two collects → exactly one WARNING, events unseen. |
||
|
|
ee2f5629b8 |
Desktop connect runs on the connection operation: one card, no link to the model, no renderer polling (NS-868) (#110574)
* refactor(connectors): cut comments that restate the code
Connector modules (tools/connectors, tui_gateway connector RPCs, desktop
connector card/store) keep only comments that carry a non-derivable why or
a cross-module contract. No behaviour change.
* feat(connectors): managed connect runs on the connection operation
Managed `connect` / `reconnect` mint one ConnectionOperation for every target and, on a
desktop session, block the tool turn until the operation settles; the result is per-target
outcomes and never carries a connect link. Off the desktop the result carries the links and
returns at once (PR3 delivers them as their own message).
Why: the previous leg handed the model a URL and a `wait` verb, and the renderer ran its own
2s poller on top of the backend's 5s one; both walked the whole gateway catalog at two vendor
calls per page to read one row (~3 Composio calls/s per pending target). A hidden composer
message started the model's `wait` on the user's behalf. None of it was observable from the
operation the MCP leg already used.
What the operation looks like now:
- `contract.py`: TargetState / Actor / SettleReason enums and the `(kind, from) -> {to: actor}`
transition table. `operation.transition()` enforces it; a card cannot claim a managed
target `connected`, only the backend watcher can.
- `live.py`: one open operation per session, found by `op_id`. `connectors.operation.status`
reads it, `connection.respond` drives it, `pending_connection` on resume replays it.
- `run.py`: the one lifecycle for both target kinds (prepare -> card -> wake/observe loop ->
settle -> result). The managed `observe` hook polls the gateway list once per tick for the
whole operation; the exact-status route replaces that call when the gateway ships it.
- `connection.update` is emitted on every transition and on settlement; registered in the
shared event contract with the operation vocabulary typed on the TS side.
- `wait`, `_rendered_links`, `_seen_instructions`, the just-minted bounce and `_clamp_timeout`
are deleted. `force` on `reconnect` always reinitiates; plain `reconnect` repairs only what
the gateway reports disconnected.
- `connections.wait_timeout_seconds` is removed from config defaults, the example and the
docs. The deadline is `OPERATION_DEADLINE_SECONDS = 300` in `operation.py`; the key was
added on this unmerged train so no migration is needed.
- Wire model: `statusReason` parsed on connection results; the seven-state `connectionStatus`
is typed on list items and an unknown value fails validation; `CONNECTION_REQUIRED` carries
`connect_card_available` instead of the link when the session platform is `desktop`.
Session platform, not callback presence, decides whether a card exists: the GUI bridge
attaches callbacks to every backend session, terminal TUI included.
* feat(desktop): connector card subscribes to the connection operation
The card renders from the backend's operation instead of driving its own: `connector-flow.ts`
(the renderer's 2s `connectors.list` poller, its 120s client deadline and `keepWaiting`) is
deleted, and both hidden composer submits in `connector-tool.tsx` go with it. The model is
never nudged into a `wait`; the tool call is blocked on the backend until the operation
settles.
- `connection-request.ts` is the operation store: keyed by `op_id`, one entry per session,
`applyOperationStatus` / `applyConnectionUpdate` as pure reducers, `respond` leaves the
entry in place (the backend answers with `connection.update`), `ConnectionTargetOutcome`
is a discriminated union the backend's transition table accepts.
- `input-requests.ts` applies `connection.update`; `connection.expire` and the resume
snapshot correlate by `op_id` (a snapshot has no `request_id`).
- `ConnectorOffer` renders one `ConnectorCard` per target from a single
`Record<ConnectionTargetState, phase>` table; Connect opens the stored link, Try again on
failed / expired reissues through `connectors.connect` on the open operation, Not now is a
per-target `skipped`, Continue settles. A settled operation renders `ConnectorSummary` rows
with no live control.
- `tool-render-class.ts`: `manage_connections` renders the card regardless of
`HERMES_GUEST_ONBOARDING`; the flag still gates the onboarding flow, not the card. The
backend gate already decided admission; a card only exists because the tool was admitted.
- `mcp-setup-tool.tsx` speaks the same outcome vocabulary (connected / skipped / failed).
- `ConnectorRow.connectionStatus` is the seven-state literal union, not `string | null`.
- The guided-onboarding poller (`first-build-connectors.ts`) keeps its own row/phase types
and compiles unchanged; PR3 moves it onto the operation.
anti-slop: no net-new findings (17 touched files vs 11d1a12472).
* fix(connectors): the card never parks the tool thread; every update carries the snapshot
Found by the pre-PR adversarial review and a real-path E2E test (both left in the tree).
- The desktop `connection_callback` was still `_block("connection.request", ...)`, which parked
the tool thread on a private request-id Event until a `_respond` that no longer exists for
this event. `connection.respond` settled the operation but the tool waited its full deadline
before the watcher loop even started. The callback now only emits the card; the operation's
own wake loop is the wait. The MCP leg's blocking bridge goes with it: the card answers
through `connection.respond` like every other card.
- `connection.request` and every `connection.update` frame carry the full target snapshot
(state, link, detail). The initial mint happened before the card existed, so the renderer
never saw the links and Connect stayed disabled; a Continue settlement stamped
`not_connected` on the backend while the card still showed `initiated`. The store now
overlays the snapshot; no state is reconstructed from deltas.
- The `connection.update` emitter is a class-level `on_change` slot on the operation, set
once by `register()` (a second `register()` no longer stacks wrappers); session lookup takes
`_sessions_lock`; a re-minted link on an `initiated` target goes through `refresh_link()`
and emits, instead of a bare attribute write.
- `session.interrupt` is checked before the first observe, so an interrupted call settles
`interrupt`, not `all_resolved`.
- A gateway list reporting `expired` for an initiated target is recorded with actor `clock`
(the contract's owner of that edge); it raised `IllegalTransition` before.
- Dead `keepWaiting` i18n keys from the deleted renderer poller removed.
tests/tui_gateway/test_connector_operation_e2e.py runs the desktop lifecycle through the real
tool, registry, gateway RPC handlers and callback bridge with only the HTTP client faked.
* docs(connectors): prompts and docs describe the operation, not the deleted wait verb
The onboarding prompts told the model to call action="wait" with timeout_seconds and to
expect a hidden [setup]/[connectors] note; both are gone. tool-search.md and
toolsets-reference.md said the model gets a connect link on the desktop. tui_gateway/AGENTS.md
gains the connection-operation row of the surface table.
* fix(connectors): the panel re-mints only a dead link
Try again on a failed or expired target mints a fresh link on the open operation. A waiting
target keeps the link it was minted with; the card reopens it and connectors.connect refuses
to spend a second mint (LINK_STILL_VALID). The unused refresh_link() goes. The package
docstring names the new siblings; the nine-name public surface is unchanged.
* test(connectors): the local-batch test answers the operation the way the card does
The callback stopped returning an answer in f782b26d98 (the card answers through
connection.respond); this test still returned one and waited out the 300s deadline in CI.
* ci: retrigger
* fix(connectors): the desktop card appears outside guided onboarding
Live on a signed-in macOS desktop, the two-app connect never showed a card. Three
defects, each hidden by a test that bound state the running app never binds.
The backend read the surface from HERMES_SESSION_PLATFORM only. The desktop and TUI
gateway bind it as HERMES_SESSION_SOURCE (_set_session_context), so session_platform()
was "" and managed connects took the off-desktop branch: links in the model's message,
no operation. session_platform() now reads platform, then source. The E2E test binds
through server._set_session_context instead of set_session_vars(platform="desktop").
The renderer routed manage_connections to the card only under isOnboardingEnabled(),
the HERMES_GUEST_ONBOARDING launch flag, in message-parts.tsx and the run splitter in
fallback.tsx. tool-render-class.ts had already dropped that gate in this PR; the two
routers had not. Both now route on the tool name alone.
ConnectorTool resolved the session owner by the runtime id. Owner routes, hints and
session rows are keyed by the stored id, so in registry topology the owner never
resolved and the card rendered null while the tool blocked. It now resolves by the
stored id, matching the PR1.5 card and every other owner lookup.
message-parts-connectors.test.tsx mounts the real Fallback router with the onboarding
flag off and distinct runtime/stored ids; red before each renderer fix, green after.
* style(connectors): shorter comments, no module mock in the card router test
The router test mocked isOnboardingEnabled to false; jsdom has no preload bridge, so the
real function already returns false. Comments that restated the code are cut to one line.
anti-slop: no net-new findings (25 touched files)
* fix(connectors): Connect on a waiting row opens the stored link
ConnectorCard derived the button's loading state from the phase label, so a managed row that
read "Finish connecting in your browser" (every row, since links are minted up front) had a
disabled Connect button. Nothing on the desktop could open the sign-in link; every managed
connect ended skipped, not_connected, or at the deadline.
The card now takes `busy` for "the action itself is running" and keeps `phase` as a label.
The MCP card passes its in-flight flag; the connector card passes the re-mint wait. Red before:
the Connect button on an initiated row rendered disabled and a click opened nothing.
* fix(connectors): a settled card stays dead; the card binds to its tool call only
A second connect for the same apps revived the finished card on the old tool row. The
connection.request payload carried no id, so the renderer fell back to matching rows by
connector names, and any row with those names qualified, settled or not.
The operation now records the model's tool_call_id and sends it in connection.request and in
the resume snapshot. The card binds to the tool row with that id and to nothing else; the
name-match fallback is deleted. A payload without the id is rejected by the store.
`reason` is removed from the tool: it was the only text the card ever showed from the model
and its absence forked a second tool part, since `reason` doubled as the row-correlation key
in tool-parts.ts. The card never needed it.
`connection.expire` is deleted from the contract and from _EXPIRING_REQUESTS: the card is
raised with _emit, not _block, so nothing has emitted it since the operation lifecycle landed.
Sid's rule of record: a resolved card is fully dead; no path brings it back.
* fix(connectors): the watch loop settles once, on time, and never raises into the result
Three findings from the live review, one loop.
Continue racing a finished sign-in: the loop ran the gateway read, then settled. A read that
returned `connected` for an already-settled or failed target raised IllegalTransition out of
the tool and the model got a generic error instead of the per-app outcomes. The read now skips
targets that are not live (pending, initiated) and skips a settled operation; the loop checks
`settled` after every read.
Settle reason as row text: `settle()` wrote `continue`/`deadline` into each unresolved target's
`detail`, and the card printed it in red. The reason stays on the operation only.
Stop and the deadline waited for the next tick: `/stop` sets a per-thread flag with no wake
hook, so the sleep is sliced at 250 ms and the flag and clock are read each slice. The clock is
also checked before each read, not only after.
Tests: a failed mint that later reads connected settles cleanly; Continue during a read keeps
the settled result; no reason in detail; an interrupt settles within the same second.
* fix(connectors): MCP setup off the desktop returns unavailable instead of blocking
run_mcp_operation treated a non-None connection_callback as "a card exists". Every tui_gateway
session has that callback, the Ink TUI included, so an MCP install from the terminal UI blocked
until the 300 s deadline while the docs promised `unavailable` with the terminal commands.
The MCP path now reads the session surface the same way the managed path does; the callback is
never the predicate. Test binds the surface to `tui` with the callback attached.
* fix(connectors): a failed Try again shows the failure, not the old dead link
The panel's re-mint ignored the gateway's per-app status and moved the row to `initiated` with
whatever link came back, `None` included, so a mint that failed again rendered as waiting on the
link that had already died.
One reader of a mint response now serves both the first mint and Try again
(`managed.mint`, with the actor as a parameter). A repeated failure keeps the row `failed`,
drops the link, and carries the vendor's new text through `operation.refresh`, which emits a
frame without a state change so the card redraws.
* fix(connectors): a forced reconnect waits for the new sign-in before it reports connected
`reconnect` with `force: true` is the account switch. The vendor keeps the old account active
while the new link waits, so the first list read after the mint said `connected` and the
operation settled at once: the new link was dropped and the model was told the switch was done.
A forced target is marked awaiting_new_attempt after the mint. The watcher ignores its row until
the list shows the new attempt (`connectionStatus: initiated`) once, then trusts `connected`.
* fix(connectors): the operation registers under the gateway session key
The tool registered the operation under the agent's session_id; every RPC (connection.respond,
connectors.operation.status, the panel's connectors.connect) and the update emitter looked it up
by the gateway's session key. Those agree until compaction rotates the agent id mid-turn; then
the card's clicks find nothing, no update reaches it, and the tool waits out the deadline.
The registration key is now the bound HERMES_SESSION_KEY, with the agent id as the fallback for
callers with no gateway (unit tests, a bare CLI). The E2E passes a rotated agent id and drives
the card by the gateway key.
* fix(connectors): the forced-reconnect gate reads any non-active row; a failed re-mint of an expired row is failed
Three follow-ups from the verification of the fix pass.
The awaiting_new_attempt gate cleared only on the literal `connectionStatus: initiated`. The
field is optional on the wire and `initializing`, `failed`, `expired` are valid values, so a
forced reconnect could wait the full 300 s and swallow a failed new attempt. The gate now holds
only while the row still reads as the old account (`connected` or `active`) and releases on
anything else.
Try again on an `expired` row whose re-mint fails raised IllegalTransition (no expired → failed
edge). The re-mint steps through `initiated` as the user's attempt, then `failed`, then drops the
dead link.
`detail` never carries a state name any more: `failed` as detail rendered as the row label and
made agent/display.py tag the settled result as a tool error. Only vendor text goes there.
`connection.expire` removed from the renderer's unscoped-stream set; nothing emits it.
|
||
|
|
e0ef0eb9c3 |
manage_connections covers local MCP servers; setup_mcp leaves the schema (NS-867, PR1) (#109517)
* feat(connections): manage_connections covers local MCP servers; setup_mcp leaves the schema
One model tool now connects the user to apps of both kinds. A target
`{"name": "linear", "mcp": true}` is a locally configured MCP server;
`install` / `enable` / `authorize` are its verbs. Bare strings and
`{"name": ...}` stay managed connectors and that leg is unchanged.
MCP targets run through one backend-owned connection operation
(tools/connections_tool_operation.py): created with a server-side
deadline from the new config key `connections.wait_timeout_seconds`
(default 120, floor 5, no ceiling), per-target state, and exactly-once
settlement (all resolved / Continue / deadline / interrupt). Unresolved
targets freeze as `not_connected` with the settle reason.
Why the fold works now: the approval card is reached through
`agent.connection_callback` via the agent-level inline executor table,
which is the only path that carries a GUI callback. Registry dispatch
(every non-GUI surface) settles MCP targets as `unavailable` with the
`hermes mcp install / login` hint; managed targets in the same call
are unaffected.
`setup_mcp` is removed from every advertised toolset and from the
deferral list; an inline-table shim keeps calls from conversations
opened before this change dispatching (prompt-cache protection).
`_LEGACY_TOOL_ALIASES` is not the mechanism: inline tools bypass it.
Gateway: `mcp.setup.request/respond` are replaced by
`connection.request/respond/expire` (no wire compat; desktop ships
with this). The bridge waits exactly the operation's deadline. The
`session.resume` snapshot gains `pending_connection` so a reopened
window restores the card with the original deadline.
`manage_connections` joins `_SEQUENTIAL_DEADLINE_EXEMPT_TOOLS`: the
operation owns its wait; the 420s guard must not report `tool_timeout`
while the card is live.
The portal `check_fn` on the tool is dropped in favour of a
handler-level gate on the managed leg, so signed-out sessions can still
approve local MCPs.
* wip(desktop): connection.request store, resume restore, card routing for MCP targets
Renderer half of the setup_mcp fold, first slice: connection-request store
(mirrors clarify), connection.request/expire handling, pending_connection
resume restore, mcpTargets() + isCardTool(name, args) so MCP-target
manage_connections calls classify as cards. Not yet: the card component
rewrite (mcp-setup-tool.tsx), mcp-directory.ts removal, vitest, docs.
Does not typecheck until the card rewrite lands.
* fix(config): hermes update turns on the connections toolset for saved toolset lists
`hermes tools` writes an explicit `platform_toolsets.<platform>` list, and the
resolver reads absence from that list as "unchecked". The `connections`
toolset (#106842) shipped after most users last saved, so `manage_connections`
is stripped from the schema on every install that ever opened the picker.
The Nous entitlement gate never runs; the agent reports the tool as missing.
Migration 44 -> 45 (renumbered when folded into #109517; main was already at 44) appends `connections` to each explicit per-platform list
that lacks it and records the offer in `known_builtin_toolsets` where that
record exists, so a later uncheck reads as a decline. It skips: platforms
whose record already holds `connections` (the user saw the checkbox and left
it off), bare composite lists ([hermes-cli]) that already inherit it, platforms
where the toolset is not allowed, and any config whose `agent.disabled_toolsets`
names `connections` (Blank Slate, `hermes tools --disable`), because the
resolver subtracts that list last and the enable would never take effect.
The explicit-list test is the resolver's own: any configurable or plugin key.
`hermes update` runs migrations post-pull for the active profile and every
sibling, so one update is enough. Fresh installs and composite users were
never affected.
* refactor: anti-slop pass on the desktop slice; shorten added comments
Parse connection.request at the boundary with a typed wire interface instead of
unknown + typeof; mcpTargets reuses connectorText; comments cut to one or two
lines. slop-ratchet: no net-new findings in 13 touched files.
* feat(desktop): the MCP approval card answers manage_connections; MCP Directory removed
The existing card (mcp-setup-tool.tsx) now reads the connection-request store,
renders for manage_connections calls with mcp:true targets, answers through
connection.respond with a per-target outcome, and no longer calls reload.mcp
after Install; the new server's tools arrive on the between-turns refresh.
A settled operation renders the first target's frozen state.
session.resume restores a pending card with its original deadline on both the
activate and cold-resume paths.
lib/mcp-directory.ts is deleted along with its two fallback branches
(suggestion provider, card install). The catalog was already primary in both;
a catalog miss now yields no suggestion / a notInCatalog error. The GitHub
never-suggest test is rewritten on catalog-shaped data.
vitest: connection-request store (6), suggestion provider, clarify restore.
slop-ratchet: no net-new findings in 19 touched files.
* chore: drop __pycache__ files swept in by an over-broad git add
* fix(desktop): correlate the connection.request row with the model's tool call by reason
The synthetic row from connection.request and the tool.start row carried
different ids and no shared match value (op_id is not in the model's args),
so the card mounted twice. reason is the arg both sides carry.
* docs: manage_connections covers local MCP servers; connections.wait_timeout_seconds
* fix(connections): settle reason derives from target state, never from the renderer
A card that answers one of two targets and claims all_resolved must settle as
continue with the other target not_connected; found live with a two-target call.
* fix(desktop): a pending connection card re-arms on resume and activate
The store entry was restored but the transcript row was not, so navigating
away and back (or reloading) lost the card while the backend kept waiting.
restorePendingClarifyToolCall's core is generalized to any blocking tool
name and both resume paths project the connection row through it.
Verified live: card restored after navigate-away and after a full renderer
reload, deadline_at unchanged, approve settles connected.
* style: literal wording in added comments, docstrings and docs
* fix: shared gateway-event contract and config-schema category for the connection events
connection.request/expire replace mcp.setup.* in apps/shared gateway-events
(json list, BACKEND_EVENT_NAMES, GatewayEventMap) so the renderer's event
union includes them and the tui_gateway contract test passes. The new
`connections` config section folds into the agent tab like the other
single-field sections.
* style: import order (perfectionist) in the desktop and shared files this PR touches
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
|
||
|
|
3275ca88ec |
fix(image_gen): Codex-auth images use the native images endpoints, no chat host model
The openai-codex image provider rode a Responses call with a hosted image_generation tool on a pinned chat model (gpt-5.5). Two failure classes came with that shape: when OpenAI withdrew gpt-5.5 from an account cohort every image call 404'd while chat kept working (#105398, #107076), and the host model was free to answer in text instead of calling the tool, so we streamed SSE, kept partial frames and retried on empty streams. Post to chatgpt.com/backend-api/codex/images/generations and images/edits instead - the route the official Codex client uses (codex-rs/ext/image-generation). No host model, no SSE, no partial-frame handling; the response is a plain JSON body with b64_json. Remote source URLs are fetched client-side and inlined as data URLs because the backend's own downloader 400s on ordinary public images. The backend treats model/quality/size as advisory (#107233), so the result now reports reported_quality/reported_size next to the requested values plus the x-codex-imagegen-request-id for support. GPT Image 2.5 is deliberately not added to this catalog: the backend accepts any model id, including nonexistent ones, and generates with its server-managed engine (C2PA reports gpt-image 2.0), so a 2.5 tier here would be a label with no effect (#106708). |
||
|
|
498abb677e |
fix(cron): a successful run resolves the job's open incidents; a repeat re-opens them
The incident ledger only ever grew. A one-off failure (a drift skip after a global model bump, a provider outage) stayed `detected`/`alerted` forever after the job recovered, so `hermes cron incidents` listed 32 "open" incidents on an install where all 32 jobs had since run OK, and the list stopped saying anything about current health. A successful run now marks that job's `detected`/`alerted` incidents `resolved` (new state). `resolved` is distinct from the operator's `closed` ack on purpose: `upsert_incident` re-opens a resolved incident as `detected` when the same error signature recurs, so the operator is alerted again for a job that broke a second time, while `closed` keeps the signature silent as before. Wired from `_compose_run_delivery` next to the failure-side upsert; best-effort, store errors never affect delivery. CLI: `--state resolved` filter and a green `resolved` colour; `closed` is now dim. Docs updated in the same change. |
||
|
|
7d368c7d2c | docs: background_review.reasoning_effort applies on the routed path; same-model warns once | ||
|
|
45ab5e3b5d |
Inspired by ChatGPT Work: event-triggered cron jobs via webhook routes
ChatGPT Work's Aug 25 2026 release lets scheduled tasks fire from app events (new Gmail message, Slack activity, GitHub PR feedback) instead of polling on a cadence. This ports the pattern by composing two existing Hermes subsystems: a webhook route can now set cron_job to fire an existing cron job on each inbound event. - gateway/platforms/webhook.py: cron_job route mode — after the same HMAC auth / rate limit / filters / script / idempotency as agent routes, the rendered prompt becomes transient per-run context and the job fires through execute_job_for_event on a worker thread (202 Accepted immediately). Startup validation rejects cron_job + deliver_only. - tools/cronjob_tools.py: execute_job_for_event() — public wrapper over the shared claimed-run body (_execute_job_now), so event fires share at-most-once claiming, in-flight dedupe, delivery, and [SILENT] handling with scheduler and manual runs. - hermes webhook subscribe --cron-job: creates event-trigger subscriptions; job ref validated (and canonicalized to the job ID) at create time. - Docs: webhooks.md route table + Event-Triggered Cron Jobs section, cron.md capability list, zh-Hans mirrors. - Tests: tests/gateway/test_webhook_cron_trigger.py (adapter + unit), CLI tests in test_webhook_cli.py. |
||
|
|
230ca004a7 |
fix(delegate): forward inline data-URL images to vision children; trim tests to invariants
data:image/... entries were treated as local paths and silently skipped as "unreadable". They now ride as image_url parts only (never pasted into the text hint, never appended to a text-mode goal). Skips and forwarding failures log at warning since the caller explicitly asked for the images; decide_image_input_mode gets the child's requested_provider like the CLI and gateway callers. Tests collapse to five invariants, including one that drives _ChildRun and asserts the multimodal content list reaches run_conversation as the first user turn. Docs mention data: URLs and the read guard. |
||
|
|
f3f5c4f7c7 |
Port from RooCodeInc/Roomote#1796: per-task image forwarding on delegate_task
Subagents can now SEE images. Each delegate_task task accepts an optional images list (max 8; local paths or http(s) URLs). Vision-capable children receive native image_url content parts on their goal turn (local files as data URLs, remote URLs verbatim); non-vision children get [Image attached at: ...] hints plus a vision_analyze pointer. Routing reuses agent.image_routing (decide_image_input_mode / build_native_content_parts), so agent.image_input_mode governs delegation exactly like inbound gateway images. Best-effort by contract: malformed images arrays fail the call loudly before any child spawns; unreadable paths are skipped with a log line; any exception in the forwarding path degrades to the text-only goal. Adapted from RooCodeInc/Roomote#1796 / #1767 (Fast agent forwards bounded current-turn attachments to delegated coding tasks). |
||
|
|
c63de5a231 |
feat(tool_search): long hunts for nonexistent tools now return no results instead of incidental matches
Port from nearai/ironclaw#7965: BM25 admits any document scoring above zero, i.e. sharing ONE term with the query. A long descriptive search for a capability that does not exist therefore returned a plausible- looking ranked list, and the model read 'results exist' as 'it is in here somewhere' and rephrased instead of stopping (IronClaw production trace: 652 tool calls, 216 of them tool_search, hunting a 'data' tool that did not exist). A document must now match at least half the query's ANSWERABLE terms (terms present anywhere in the index) before it is offered. Coverage only engages from four answerable terms up, preserving recall on short queries; exact tool-name matches remain authoritative; the substring fallback is unchanged. Docs: relevance-floor bullet added to tool-search.md implementation details. |
||
|
|
d9e88e19e2 |
feat(mcp): bind stored OAuth refresh tokens to their issuer
Port from openai/codex#39615: the authorization server discovered for an MCP server can change (protected-resource metadata edit, server migration, DNS takeover). Without binding, Hermes would send the stored refresh token to whatever issuer the server now advertises — handing a long-lived credential to a different authorization server. - HermesTokenStorage records hermes_issuer alongside cached tokens (stripped before OAuthToken.model_validate; never sent on the wire). - Both provider classes (tools/mcp_oauth.py legacy path and tools/mcp_oauth_manager.py managed path) stamp the discovered issuer on every token save and enforce the binding on _initialize. - On mismatch: refresh token is stripped from memory and disk; the unexpired access token keeps working; full re-auth happens at expiry. - Legacy token files without an issuer adopt the current one once (no forced re-login for existing installs — deliberate divergence from Codex, which requires reauth). Validated: 12 new tests + 163 existing MCP OAuth tests green; sabotage run confirms the new tests fail without the enforcement; E2E against the real manager provider class with a temp HERMES_HOME confirms mismatch strips and match preserves. |
||
|
|
2710d85914 | fix(kanban): gate gateway notifier polling | ||
|
|
1e76efbe28 |
fix: scan plugin test trees again, cap their criticals at caution
Skipping `tests/`, `spec/`, ... in EXCLUDED_DIRS made those trees invisible to the guard, but `plugins_loader._load_directory_module` sets `submodule_search_locations=[plugin_dir]`, so a plugin `__init__.py` doing `from .tests import evil` imports and runs whatever lives there: a `tests/evil.py` with a destructive root remove scanned `dangerous` on main and `safe` on this branch. `_walk` also matched the names at any depth, so `src/spec/handler.py` — plain runtime code — went unscanned. Keep scanning everything; instead cap a critical finding located under a ROOT-level test dir at `high`, so the verdict is `caution` (confirmation required, `--force` overridable) rather than the un-overridable `dangerous`. Fixture strings still cannot brick an install, which was the reported problem, while a critical in any runtime file (`setup.sh`, `src/spec/...`) still yields `dangerous`. Trade-off stated in the PR body: hostile code deliberately placed under `tests/` is now force-installable rather than blocked outright. Docs no longer claim test code never runs. |
||
|
|
713270d3d0 |
docs(plugins): document skipped test trees and the critical-finding block reason
User-visible scanner behaviour changed in this PR (test trees skipped, the block reason names the critical rule ids), so the plugin docs say so in the same PR. |
||
|
|
e10d30f8a8 |
docs(kanban): review handoffs also stage declared artifacts
`kanban_request_review(artifacts=[...])` now preserves scratch deliverables the same way `kanban_complete` does; say so where the scratch-workspace lifecycle is documented. |
||
|
|
4beb7e29a2 |
fix(mcp): unattended paths never open browser OAuth; gateway follows mcp_servers edits
A gateway with an OAuth MCP server whose refresh token expired opened a new authorize tab every 300s, all night (92 tabs). Four defects stacked: - The parked-server self-probe re-entered the SDK's authorization-code flow with interactive OAuth enabled. The timed wake is unattended by definition: `_wait_for_reconnect_or_shutdown` now distinguishes "self-probe" from an explicit "reconnect", and `_park` flips the task-local `_oauth_interactive_enabled` off before a self-probe revival. - Gateway MCP discovery (startup, `/reload-mcp`, hot-added multiplex profiles) ran interactive, unlike the CLI's background discovery. All three now run under `suppress_interactive_oauth()`; an expired token parks with the `hermes mcp login` hint instead of a browser. - `_is_interactive()` trusted `sys.stdin.isatty()`, which the Windows CRT reports True for a DEVNULL/detached stdin. `_stdin_is_console()` confirms with `GetConsoleMode` on Windows. - Removing an `mcp_servers` entry (or `enabled: false`) never reached a running gateway; the parked server probed forever. New `reconcile_mcp_servers_with_config()` tears down dropped/disabled servers (via `shutdown_mcp_servers(names=...)`) and connects new ones; a housekeeping chore runs it when config.yaml's (mtime, size) changes. `_select_new_servers` also stops nudging disabled parked servers. Fixes #81830. Fixes the browser-storm item of #96320. |
||
|
|
0b40f5a790 |
docs: fold the root docs/ tree into the Docusaurus site and delete it
docs/ was not the documentation site; it was a grab bag of long-form design notes, wire contracts and observability guides that landed with feature PRs because their authors needed somewhere to put them. Root AGENTS.md already says long-form dev docs live in website/docs/developer-guide/; this moves the 14 living documents there (or to the matching user-guide section) so they are published, searchable and linked from the sidebar instead of being found by grep only. Developer guide: micro-compaction, gateway-session-lifecycle (was session-lifecycle), state-db-recovery, multiplexing-gateway, chronos-managed-cron-contract, relay-connector-contract, observer-hooks (was observability/README), gateway-monitoring (observability/monitoring), relay-shared-metrics, middleware, streaming-tts, billing-lifecycle. User guide: egress/network-isolation (was security/network-egress- isolation), features/kanban-multi-gateway (was kanban/multi-gateway). Each page got title/description frontmatter and a sidebar entry; repo- relative links became site links or GitHub blob URLs; two MDX brace hazards escaped. Every in-tree pointer (module docstrings, config comments, the relay conformance test's Path, the monitoring-doc test, gateway-internals, cron-internals, kanban docs, .dockerignore, AGENTS.md) now names the new location. `docusaurus build` passes with no unresolved links on the moved pages. |
||
|
|
0c0875b746 |
chore: delete orphaned bench data, datagen examples and stale one-off docs
Nothing in the tree reads any of these; they landed with feature PRs and were never routed to their proper home. - mcp-research-data/: 224K of July tool-search bench result rows. The harnesses (scripts/tool_search_livetest_ue*.py) write their output to a gitignored dir; the rows were committed by hand once and the headline numbers already live in the bench commit messages. - datagen-config-examples/: Feb 2026 RL datagen configs for a WebResearchEnv that no longer exists; the yaml paths point at a configs/ dir that was never created. - docs/: ADR log with one entry, an implemented cron-doctor spec, an RCA for a resolved bug, two RFCs whose work shipped, an unimplemented profile-builder proposal, the kanban dialog mock HTML and the kanban v1 spec PDF. profile-routing.md duplicated the profile_routes section of website/docs/user-guide/multi-profile-gateways.md. Kanban docs and the `hermes kanban` parser description pointed readers at the PDF; those now point at the user guide (the patterns table it was citing is on that same page). |
||
|
|
3e066dfedd |
fix(computer_use): approval goes through the shared gate; no callback now fails closed
computer_use kept its own approval decision: two module dicts
(_session_auto_approve / _always_allow) mirroring tools.approval's
session store and _persist_choice, a private verdict vocabulary
(approve_once/approve_session/always_approve) that hermes_cli mapped
back to once/session/always, and — the real problem — `if
_approval_callback is None: return None`. Only the interactive CLI ever
installed that callback, so every other host (gateway turns, cron,
api_server, tui_gateway, ACP) ran destructive desktop input with no
approval at all, ignoring cron_mode / unattended_mode / the permanent
allowlist, and "always" grants were invisible to `is_approved`,
`clear_session` and the messaging-platform approval buttons.
_request_approval now calls tools.approval._run_approval_gate with
pattern_key `cua:<action>:<background|foreground>` (the old scope shape,
so a background grant still never covers the visible foreground variant)
and fail_closed_when_no_human=True, the same posture as
request_tool_approval / the SSH-config write gate. The private dicts,
their release/atexit clearing, the verdict mapping in
hermes_cli/cli_modal_mixin.py and the extra callback install in cli.py
are deleted: the CLI's terminal_tool callback answers computer_use
prompts like any other tool. set_approval_callback stays as an optional
explicit-callback hook with the shared callback contract
(cb(command, description, **kw) -> once|session|always|deny|timeout);
no in-tree host uses it.
Behavior change:
- No approval callback and no gateway (cron, api_server/webhook,
headless -q, plain library use): destructive actions are now REFUSED
with a BLOCKED error and never reach the backend. Previously they
silently ran. cron honors approvals.cron_mode, unattended platforms
approvals.unattended_mode, -q approvals.single_query_mode.
- --yolo / gateway /yolo / approvals.mode: off still allow (unchanged).
- Gateway sessions (Telegram/Discord/Slack/...) now get a real pending
approval with once/session/always buttons instead of default-allow.
- session/always grants live in tools.approval's store; "always" is one
command_allowlist entry (`cua:click:background`) and is scoped to that
action+mode — the old blanket "always_approve unlocks everything for
the session" no longer exists.
- Denial wording is the shared gate's ("BLOCKED: User denied ...",
"BLOCKED: Action timed out ..."); the error JSON keeps `action`.
Tests: tests/tools/test_computer_use_approval_isolation.py
::test_no_callback_refuses_unless_yolo (blocked + no backend call, then
yolo executes) and ::test_always_grant_lands_in_the_shared_store
(is_approved sees the cua:<action>:<mode> key; second call served from
the store). Sabotage: restoring the `callback is None -> allow`
short-circuit fails the first; swapping the shared gate for a private
grant set fails the second plus the three delivery-ladder scope tests.
tests/tools/conftest.py gains `grant_computer_use_approvals` for
dispatch tests that only care about routing.
|
||
|
|
d3202bbc8d |
feat(tui_gateway): fire agent_loop_stopped on session.interrupt too
Widens the new hook to the sibling interrupt surface: the TUI/desktop session.interrupt path stops a live turn exactly like the gateway's /stop, so plugins holding per-turn external resources get the same signal there (platform='tui'). Gated on a genuinely running turn; dispatch failures are swallowed so a plugin can never break the interrupt. Docs updated to describe both surfaces. Inspired by ChatGPT Work / Codex CLI 0.150.0 'Interrupt' hooks (hooks that run when an active top-level turn is interrupted). |
||
|
|
f361971eed |
feat(gateway): fire agent_loop_stopped plugin hook on interrupt
Reapplied onto current main. The branch had drifted ~3348 commits and a trial merge produced 48 conflict markers, so this is the same change re-landed rather than a rebase of the old history. _interrupt_and_clear_session interrupts the running agent without signalling plugins, so a plugin holding a per-turn external resource — an outbound RPC waiting on a tool result the loop will never consume — has no way to learn the turn is gone. Dispatch agent_loop_stopped immediately after running_agent.interrupt(), gated on a real running agent: the pending-sentinel /stop path has no in-flight work, so firing there would be noise. Per review on #27208, the current helper's behaviour is preserved untouched — multiplex-aware _adapter_for_source() resolution and cached-agent eviction both still run; the hook is additive and its dispatch failures are swallowed so a misbehaving plugin cannot break an interrupt. Tests fail without the change (hook registration and dispatch) and pass with it. The three failures in tests/hermes_cli/test_plugins.py::TestPluginDiscovery are pre-existing on this checkout and reproduce with the change stashed. |
||
|
|
ec58e08a35 |
feat(cron): automatic bounded re-runs when a fire never reached the model
Inspired by Claude Cowork (desktop changelog v1.46388.1, 2026-09-04), which added "automatic re-runs (after 5, 15, and 30 minutes) for a scheduled task that could not reach the model at all, for example right after the computer wakes behind a VPN." A recurring cron job whose run fails with a transient network/DNS error before ANY model call previously sat out a full period (a daily job fired into a reconnecting VPN silently skipped a day). Now the scheduler pulls next_run_at earlier along a bounded 5/15/30-minute ladder, suppresses the interim failure notice while a re-run is pending, and resets the ladder on any run that reaches the model. Deliberately narrower than a generic retry (cf. PR #16512): zero API calls + transient classification means nothing executed and nothing was spent, so a re-run cannot duplicate side effects. One-shots are excluded (at-most-times dispatch accounting, #38758); retries never fire past the schedule's own next occurrence; `cron.retry_unreachable: false` disables. - cron/unreachable_retry.py: ladder, classification, plan/clear/will_retry - cron/scheduler.py: flag unreachable failures in run_job; suppress interim notice; thread model_unreachable through the fenced bookkeeping write - cron/jobs.py: mark_job_run schedules/clears the ladder under the jobs lock - docs: website/docs/user-guide/features/cron.md |
||
|
|
0c2e66ea8b |
test(auth): OpenRouter PKCE invariants, fake-authority A/B harness, docs, contributor map
Two invariant tests (red on main): the PKCE key lands as an api_key pool row that
resolve_provider("auto") picks up while the bare --api-key path keeps its default, and a forged
callback path is a 404 while the genuine nonce path yields the code. evals/openrouter_pkce_ab
drives the real auth_add_command against a local fake /api/v1/auth/keys (verifier check,
single-use codes) for legit / wrong-state / replayed-code / malformed-response / api-key-path.
|
||
|
|
0e13fa98ec |
fix(web): cap web_extract provider dispatch with a wall-clock timeout (salvage #57180)
A provider whose backend keeps the response open without finishing (hanging HTTP server, stuck SDK call) stalled the web_extract tool call — and with a sync provider, the borrowed thread — indefinitely. The dispatch in tools/web_tools_extract._dispatch_extract now runs under asyncio.wait_for with web.extract_timeout (config.yaml, default 120s; 0 disables). On timeout the tool returns structured per-URL error entries, and the one-shot keyless rescue still gets its chance when eligible. Salvaged from PR #57180 by @liuhao1024 (base predated the web_tools decomposition; re-applied at the _dispatch_extract seam, env-var timeout replaced with the web.* config section per the .env-is-for-secrets rule, and the timeout path made rescue-aware). Inspired by Claude Code 2.1.268: "Fixed WebFetch hanging indefinitely on a server that keeps the response open without finishing; a fetch now fails after 300 seconds." Fixes #57155 Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> |
||
|
|
101b986fdf |
docs(cron): document resnap alongside the drift guard
The drift-guard section only offered pin-or-disable; resnap is the middle path (adopt the new default, stay unpinned). Same PR as the salvaged feature per docs-in-same-PR policy. |
||
|
|
eae250534f |
docs(memory): explain that memory needs session boundaries on gateways
A community write-up showed a real usage pattern: running Hermes on a messaging gateway for weeks without ever issuing /new. The memory docs never said that the recall loop (MEMORY.md/USER.md snapshot + session_search) only fires at session boundaries, and that gateway chats are intentionally one continuous session across restarts. Add a section spelling out why boundaries matter and recommending /new at natural break points, cross-linked to the session-continuity docs. |
||
|
|
819988acb7 | docs(kanban): per-platform opt-in is the documented path (hermes tools enable kanban --platform X) | ||
|
|
3d7f773bb4 | fix(kanban): honor explicit platform tool opt-ins across configuration surfaces | ||
|
|
f003e449be |
refactor(cron): trim the scope-degrade dispatch to its invariants
Follow-up to the cherry-picked #102431 fix, addressing the review findings: - The two real-helper scheduler tests ran the Linux-only helper unmarked and failed on macOS/Windows; the surviving one is now `linux_only`. - `_warn_scope_degraded_once` used a bare `os.getuid()` that tripped scripts/check-windows-footguns.py --all (lint lane red). The remedy text is now built once in the helper and passed into the warning, so the "scope binary vanished" case no longer warns about a missing D-Bus. - Tests trimmed to the invariant bar: degraded != in_process and warns once (helper level); default config still Popens externally and `require_restart_safe_scope: true` raises (scheduler level, real helper). Dropped the stubbed duplicate, the standalone config-raise test and the in_process half already covered by the existing passthrough test. - `GatewayChildDispatch.reason` had no reader outside a test; removed. - Both degrade branches share one local `_degrade(detail)`. - The per-fire config read uses `load_config_readonly()` (no deepcopy) with the same `except Exception` guard as the sibling `failure_nudge_threshold` read, so a config error no longer escapes the launcher. - Kanban's no-run-id guard fails closed for any non-`in_process` mode instead of matching one enum value. - Rationale restated in six places collapsed to the helper docstring; `cron.require_restart_safe_scope` documented in the cron user guide. |
||
|
|
853bfec43a |
docs(browser): separate Chrome 136 flag refusal from the 144+ approval dialog
The salvaged paragraph described one mechanism that is actually two. Per developer.chrome.com/blog/remote-debugging-port, Chrome 136+ simply stops respecting --remote-debugging-port/--remote-debugging-pipe against the default data dir — no dialog, nothing listens. The "Allow remote debugging?" prompt the contributor saw is the separate Chrome 144+ opt-in approval flow (chrome://inspect/#remote-debugging), which asks per incoming connection, not per launch. Say so, cite both sources, and keep the fix (non-default --user-data-dir, or use_real_profile) unchanged. |
||
|
|
d47df6ea00 |
docs(browser): the Chrome 136+ remote-debugging block is a consent dialog, not a silent failure
The `/browser connect` tip said Chrome 136+ "silently refuse[s] to open the remote debugging port" on the default user-data-dir and that "there is no error message". Chrome does surface it: on Chrome 152/macOS, launching the default profile with --remote-debugging-port puts up an "Allow remote debugging?" dialog, and the port opens once you press Allow. Port 9222 refusing connections is the symptom while that consent is pending, not a permanent silent block. Two details this cost time to rediscover, now written down: - the consent is asked per launch, so it reappears on every browser restart - the remote-debugging toggle in chrome://settings does not suppress it; that toggle only makes the feature available Also cross-references browser.use_real_profile for the case the tip leaves unanswered — wanting existing logins in the agent's browser. A dedicated user-data-dir avoids the dialog but starts signed out; the real-profile snapshot gets both, since the snapshot copy is itself a non-default user-data-dir. Verified locally: default profile + --remote-debugging-port=9222 shows the dialog and leaves 9222 closed, while a --user-data-dir launch answers /json/version in under a second with no prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
70d0f556d7 |
fix(branding): use the Caduceus ☤ (U+2624), not the Rod of Asclepius ⚕ (U+2625)
Every inline glyph — CLI banner/status bar/response labels/goodbye, setup and doctor boxes, gateway update prompts, WhatsApp reply prefix, TUI theme, locale strings and the docs — used ⚕, the staff of Asclepius (medicine). Hermes carries the Caduceus ☤. The ASCII-art logo was already correct. Mechanical swap across 60 files (no logic change); both glyphs are East-Asian-width Neutral so no layout shifts. Skins that set their own `response_label` / `goodbye` are unaffected. Direction from PR #7064 (@bixycler), the earliest of #7064 / #9611 / #15574, redone against current main. Fixes #9565 |
||
|
|
44ce128a27 |
fix(personality): honour the top-level personalities: config block on every surface
DEFAULT_CONFIG ships a root-level `personalities: {}` (from #643) and the schema
whitelists it, but the single personality resolver read only
`agent.personalities`. A user who followed the generated config saw
"No personalities configured" from /personality on CLI, gateway and TUI.
`available_personalities()` now merges root `personalities` then
`agent.personalities` (later wins), so all three consumers pick both up.
Earlier attempt: PR #9657 (@flobo3) patched the CLI loader only.
Fixes #9636
|
||
|
|
850c48cd84 |
feat(skills-hub): tap K-Dense and OpenScience scientific skills under one "science" bucket
~480 scientific research skills become searchable/installable through the Skills Hub with nothing vendored: K-Dense-AI/scientific-agent-skills (165, MIT) and synthetic-sciences/openscience (314 across 17 category paths, Apache-2.0). A new optional tap-level `bucket` key stamps extra["category"] on every skill from a tap whose repo ships no skills.sh.json grouping, so several repos surface as one hub category; a sidecar grouping still wins when present. Both repos stay at community trust (not in TRUSTED_REPOS) so the guard scans every install. Re-grafted from #60559 onto the post-split tools/skills_hub_github.py. |
||
|
|
53c57871d6 |
feat(plugin-catalog): add snyk — Snyk MCP server + snyk-security-scan skill
Snyk lands as a standalone Agent Plugins v1 package (NousResearch/hermes-plugin-snyk, pinned 2a41a07f) instead of an optional-mcps entry: the package carries the pinned `npx -y snyk@1.1306.0 mcp` stdio launch with CLI analytics disabled AND the workflow skill that tells the agent when to use which scanner, so a single `hermes plugins install snyk` gives both the tools and the playbook. Third-party product integrations ship outside the core tree per the contribution rubric. Supersedes the optional-mcps manifest from #73860 (same pin, same telemetry posture, tool-pruning rationale moved into the skill). |
||
|
|
be2f7e9c36 |
feat: curator prunes unused skills at 30 days (was 90), stale at 14
A skill nobody has loaded in a month is prompt weight, not knowledge, and archival is recoverable (`hermes curator restore`). Defaults move stale 30→14 / archive 90→30; config v44 rewrites only the OLD defaults so an explicitly customized window is preserved. `hermes curator prune` now defaults --days to curator.archive_after_days instead of a hardcoded 90 so the manual and automatic paths agree. |
||
|
|
f364c19775 |
feat(plugins): touchdesigner ships as a catalog plugin (MCP server + skill), optional skill retired
The twozero TouchDesigner integration now lives in one installable unit: plugin-catalog/touchdesigner.yaml points at NousResearch/hermes-plugin-touchdesigner (portable Agent Plugins v1: mcp.json registers the twozero Streamable HTTP hub, skills/ carries touchdesigner-mcp). `hermes plugins install touchdesigner` + `hermes plugins enable td` replaces the optional skill whose setup.sh hand-wrote an mcp_servers block. The manifest name is `td` because Hermes names portable MCP tools mcp__agent_plugin_<name>_<hash>__<server>__<tool>; twozero's longest tool under a `touchdesigner` namespace is 71 chars, past the 64-char provider function-name cap. optional-skills/creative/touchdesigner-mcp and its generated docs (bundled, optional, zh-Hans) are removed; catalog tables, sidebar and kanban-video-orchestrator references are updated to point at the plugin. Supersedes #68607 (MCP-catalog-only approach). |
||
|
|
b7b35a84b7 | docs: remove the Nous guest free-tier guide (#108991) | ||
|
|
6cd4fbd640 |
docs(cron): state the missed-occurrence contract for restart gaps
cron-internals.md gets a 'Missed-occurrence contract' section (pre-dispatch advance is provisional, restore once, never twice, grace, opt-out, paused never catches up, same on standalone and multiplexed); the user guide describes the catch-up-once behaviour above the cron.catch_up_missed opt-out; cron/AGENTS.md lists it as a hardening invariant. |
||
|
|
df51797e2e | feat(cron): let planned downtime skip missed recurring runs | ||
|
|
f923faa0b8 |
feat(voice): GPT-Live voice chat mode — a full-duplex voice frontend that delegates to Hermes (Desktop)
`voice.voice_chat_mode: gpt-live` swaps the desktop's chained STT → turn → TTS loop for OpenAI's gpt-live-1: one voice model that listens while it speaks and has no tools of its own. Every real request it hears becomes a normal Hermes turn on the open chat — any model/provider the session selected, full toolset, memory, approvals — and the voice paraphrases the reply aloud. Backend - tools/voice_live.py: mode/credential/persona resolution and the one server-side step the API needs — POST /v1/live/sessions exchanging the renderer's SDP offer, pinned to client delegation; the OpenAI key never reaches the renderer. Voice persona follows the vendor prompting guide (role, style, labelled delegation policy describing Hermes as the backend). VOICE_LIVE_TURN_NOTE is the per-turn model-input note (transcript in, speakable prose out). - REST: GET /api/audio/voice-live/status (mode + readiness, non-secret), POST /api/audio/voice-live/session (SDP exchange). The offer is passed byte-exact: a stripped trailing CRLF is a vendor 400 "unmarshal SDP: EOF". - prompt.submit accepts surface=voice-live (+ voice_context) beside hud; the note rides the model input via the existing _prepend_note seam, the persisted user row stays the user's words, the system prompt stays byte-stable. - config_defaults: voice.voice_chat_mode (chained|gpt-live), voice.gpt_live.*. Desktop - lib/voice-live.ts: RTCPeerConnection + oai-events data channel owner, transcript accumulation, session.commentary/thinking/instructions appends (500-token chunking), mute, graceful close waiting for session.closed. - hooks/use-voice-live-conversation.ts: same public shape as useVoiceConversation; delegation → prompt.submit(surface=voice-live); tool activity → quiet thinking appends; reply streamed back per sentence; spoken stop phrase ends the chat; a newer delegation interrupts an in-flight turn. - use-composer-voice mounts both engines and latches one at conversation start from the backend-resolved status; gpt-live without a key falls back to chained with a notice. Settings → Voice gets the mode dropdown, voice picker, persona. Live-verified on the worktree desktop build (headless Electron, CDP, synthetic mic): "what is 17 times 23 and which model are you on" → delegation → Hermes (Claude Sonnet 4.5 via OpenRouter) → spoken "391 … Claude Sonnet 4.5 through OpenRouter"; follow-up "double that" resolved from the spoken context → 782; "run uname -r" ran the terminal tool with "Hermes is working: terminal" fed as quiet context → spoken kernel version; "stop" closed the session (reason=close_requested). Chained mode creates no RTCPeerConnection. |
||
|
|
fafb27ee5c |
feat(plugins): on_room_member_activity hook projects Group Chat member runtime events to plugins
A hosted room member runs on a hidden room_plumbing session with no client transport, so the tool.start/complete, approval.request, message.delta and reasoning.delta frames its turn already emits bottom out at stdio and vanish. Between turn.started and turn.settled in the durable room log a client sees a black box, and community clients (Hermes Crew) cannot render tool cards, approvals or live member status without inferring them from text. One seam in write_json (plus the connector bypass in tool_progress) re-routes those frames, stamped with the session's _hosted_room_task coordinates (room_id, thread_id, member_id, turn_id, task_id, execution_generation), to a new observer hook through the bounded per-consumer queues on_stream_* already use, so plugin code never runs on the token path. Nothing is written to the room log: deltas would exhaust a room's byte budget in minutes and checkpoint replay must stay a pure function of the durable events. The task stamp gains member_id (the driver already knows it; the proof did not carry it). Group Chat keeps execution, scheduling and persistence; plugins own presentation. |
||
|
|
819517fbac |
fix(gateway): routed profiles get their own max_turns, fallback chain, hooks, aux auth and media policy
One multiplexed gateway process serves every profile, but several per-turn reads still went through state frozen from the LAUNCH profile: - `_current_max_iterations` re-bridged `agent.max_turns`/`sessions.*` from the module constant `_hermes_home` into one process-wide HERMES_MAX_ITERATIONS, so every secondary ran with the default profile's turn budget. A routed turn (HERMES_HOME override) now resolves `agent.max_turns` from its own config. - `_refresh_fallback_model` read `_hermes_home/config.yaml` into one runner-wide slot, so secondaries fell back through the default's provider/model with their own keys. It now reads the active gateway home and keeps a last-known-good chain per home. - `_load_prefill_messages` resolved relative paths against the launch home. - `agent/auxiliary_client._AUTH_JSON_PATH` was an import-time constant, so a secondary's compression/title/vision calls authenticated to Nous with the default profile's token when it had no pool entry. Resolved per call via `hermes_cli.auth._auth_file_path()` (patched constant still wins in tests). - `gateway/hooks.HOOKS_DIR` was frozen at import and one `HookRegistry` was loaded outside any profile scope, so secondaries' `hooks/` never ran and the default profile's handlers received every profile's messages, responses and user ids. `HOOKS_DIR` now resolves per call (salvaged from #56508) and the runner holds one registry per served home, picked from the active scope at emit time and front-loaded under each secondary's startup scope. - Shell-hook subprocesses inherited the launch `os.environ` (default HERMES_HOME and the default profile's secrets). They now get the routed HERMES_HOME via `build_subprocess_env`, scrubbed under multiplexing, and the stdin payload carries `profile` so one script can tell which profile fired it. - Media-delivery policy (`gateway.strict`, `media_delivery_allow_dirs`, `trust_recent_files*`) was bridged once into env at startup and read from env per delivery; under a HERMES_HOME override the validator now reads the routed profile's config. Single-profile runs keep the env-bridge contract. Audit: /tmp/mux_audit F3, F4, F6 (auth.json half), F7, F12 (media). Live repro (temp HERMES_HOME A with profiles/B): before, B saw max_iterations 7, fallback A/fallback, TOKEN_A, A's hooks, strict=A; after, all B's values. |
||
|
|
759024bdff |
fix(deepseek): honor Flash 1M window leftovers and native vision
Users on native DeepSeek were told to pin model.context_length and model.supports_vision in config.yaml. That is the wrong layer: the 1M window is already in DEFAULT_CONTEXT_LENGTHS, and a global supports_vision pin would also mark text-only deepseek-v4-pro as multimodal. Two catalog gaps still produced the reported symptoms: - A leftover context_length_cache.yaml entry of 128K (the old ``deepseek`` catch-all) outlived the 1M catalog keys because deepseek-flash was missing from _PRE_CATALOG_STALE_KEYS. - When models.dev is empty/cold, Flash has no capability record, so image routing falls through to lossy text. Vendor docs (2026-09-10) mark deepseek-flash as vision-capable and deepseek-v4-pro as not. Discard those 128K leftovers, fill Flash (and retired Flash aliases) via _BUILTIN_MODEL_METADATA, and leave Pro catalog-only. |
||
|
|
338bf9ea9a |
fix(cli): banner/TUI/dashboard update checks go through the GitHub API, cached 24h
The Python passive check (`banner.check_for_updates`, used by the CLI
banner, `hermes --tui`, every `tui_gateway` spawn and the dashboard's
/api/hermes/update/check) also ran `git fetch origin main` on every cache
miss, and never cached an inconclusive result so a flaky line retried on
every start. Same GitHub complaint, same fix:
- remote tip via GET /repos/{slug}/commits/main (vnd.github.sha), local tip
via rev-parse, exact count + changelog via the compare API when they
differ. HTTPS `ls-remote` remains only as the fallback when the API is
unreachable or the origin isn't on GitHub.
- cache TTL 6h -> 24h, failures cached 1h; the cache is keyed on HEAD so
`hermes update` invalidates it immediately.
- the dashboard's "what's changed" list comes from the memoized compare
payload (`upstream_commits_behind`) instead of `git log HEAD..origin/main`,
which was stale without a fetch.
Tests rewritten to the new contract: passive checks must not run
`git fetch`/`ls-remote` for a GitHub origin; the daily cache invalidates
when HEAD moves and re-asks after the failure window.
|
||
|
|
d9ca9c974d |
feat(vault): two-factor codes — automatic from a saved authenticator key, otherwise asked for in the user's UI
Follow-up to #106480. Sites that ask for a code after the password stopped the agent cold: the login classifier excludes one-time-code fields on purpose (a password must never land in an OTP box) and there was no tool for the second step, so the only move was to ask in chat. browser_vault_enter_code Fills the one-time code the current page asks for. Two sources, same invariant as passwords (the code goes to the page over the supervisor socket and never enters model context): - a TOTP seed on the login: local vault `otp_secret` (RFC 6238, stdlib, verified against the RFC test vectors), 1Password `op item get --otp`, Bitwarden `bw get totp`. Nobody is asked. - no seed: the surface prompts "Verification code for {site}"; the user types what their phone/email/app shows. Enter on empty / Skip declines and the tool returns code_declined ("do not ask again this turn"). no_code_field tells the model the site wants a passkey / hardware key / app approval: hand it to the user's device and wait for navigation. Per-digit OTP boxes (maxlength=1 pattern) get one digit each in DOM order. Surfaces CLI: sudo-style panel, code shown as typed (not a secret worth masking, typos must be visible), Enter submits, ESC/empty skips. Desktop: "Verification code for {site}" card via vault.code.request / vault.code.respond (gateway), owner-routed like the other vault prompts. Settings → Passwords & Logins: optional "Authenticator key" field on the add form (base32 or otpauth:// link); items with one show a "2FA auto" badge. `hermes vault add` asks for the same optional key. browser_vault_fill's result now says what to do next ("if the site asks for a verification code, call browser_vault_enter_code with this handle"). Six locales. Verified live (real model, local 2FA site that checks the TOTP; CLI PTY): A. login saved with authenticator key → signed in through 2FA, zero prompts, code/password absent from the transcript B. login without key → code panel → user types code → signed in C. panel dismissed → agent stops and explains, never asks in chat Unit: RFC 6238 vectors, seed normalisation, mint-without-asking, per-digit spread, decline, no-code-field; Desktop card test (owner routing, trim, Skip). |
||
|
|
438a313500 | docs(honcho): document a2aSessions and per-author writes on the site page | ||
|
|
98d11c95f4 |
feat(vault): zero-setup UX — save a login on the page that needs it, managers auto-detected, one "Passwords & Logins" surface
Nobody should have to learn `hermes vault add` or find a toggle before "log into GitHub" works. - browser_vault_save_login: when the agent reaches a sign-in page with no saved login it asks the user on THEIR surface (CLI two-step panel on the sudo modal: identifier shown, password masked; Desktop card with labelled Email/username + Password fields). The answer goes to the encrypted vault bound to the page origin and is filled at once; the model gets back only the handle and identifier. Declining returns save_declined; headless sessions get prompt_unavailable. Never a password in chat. - Vault tools ride with the browser toolset (check_browser_requirements) instead of appearing only once the vault has items — an empty vault is exactly when save_login is needed. browser_vault_list hints at it when empty. - 1Password / Bitwarden are login sources as soon as their CLI is installed; `vault.<name>.enabled` is opt-OUT only. Settings shows Detected/Locked/Unlocked/Off/Not detected with a switch only for installed managers; `hermes vault sources` reports detection, `--disable`/`--enable` flip the opt-out. - Desktop nav/page renamed "Passwords & Logins"; empty state tells the user they do not need to add anything; all five locales updated. Docs rewritten from "how it works" to "say log into X". - New per-thread SaveLoginPrompt callback (agent/vault_backends/unlock.py) installed beside the unlock prompt on every CLI site and the gateway bridge (vault.save_login.request/respond/expire), propagated to worker threads via tools.thread_context. Live: CLI PTY (real model, packaged Chromium, local login server) — panel shown, identifier + masked password typed, server received the correct password, password absent from terminal transcript and from every file under HERMES_HOME outside vault/. Native Electron (headless, isolated HOME/HERMES_HOME, own Vite + CDP port) — card shown, "Save & sign in", server received the password, Settings lists the saved item, password absent from the rendered UI. |
||
|
|
c8998c3957 |
feat(vault): fill payment cards and addresses at checkout, cards behind a confirm prompt
payment and address items could be stored (CLI wizard, Desktop dialog) but nothing could fill them: a dead surface holding real card numbers. browser_vault_fill now handles all three kinds through the same origin-bound, supervisor-only, redacted path: - classify_checkout_control / select_checkout_fills map WHATWG autocomplete tokens (cc-number, cc-exp[-month|-year], cc-csc, address-line1/2, address-level1/2, postal-code, country-name) with label/name heuristics as backup; a combined "MM/YY" control gets exp_month+exp_year and suppresses the split fills; inspection now covers <select> (country, state, expiry month) and the fill script picks an option by value or text. - Every payment fill goes through request_elicitation_consent (gateway button round-trip or CLI panel) before a byte is written; declined → payment_declined, headless sessions are refused. A prompt injection that reaches a checkout can ask, not spend. Card values join the redaction registry like passwords; the result lists targeted field tokens only. - Origin is now required for every kind (CLI wizard asks; Desktop dialog always shows the field) because a card without a bound origin is unfillable. - The tool descriptions, docs and CLI copy drop "Phase 1 / login only". Live (evals/vault_fill_live_e2e.py, real browser_exec + packaged Chromium): decline writes nothing; accept fills card/expiry/CVC on the /checkout tab, leaves the email box and the country <select> untouched, and neither the card number nor the CVC appears in any result. browser_vault_tool also: focuses the tab on the bound origin holding the right form before the origin pre-check (focus_page from the previous commit); tool descriptions say "the browser's input tool" (rewritten per session by model_tools); _check_vault_available is registered uncached because its answer is per profile (vault dir + config) and the probe is a file stat. |
||
|
|
dedc99ec6c |
fix(vault): ownership and race findings from the second independent review
Manager tokens: lock generation fence (a Lock acknowledged while `bw unlock` / `op signin` is still running discards the late token); tokens record the unlocking gateway session and are released when THAT session ends, not when any sibling session in the profile is torn down. 1Password: OP_CONNECT_HOST/TOKEN come from the profile's scoped secret store like the service token (Connect outranks a service token inside op), never from the launch environment. Vault RPCs bind params.profile (home + secret scope) so a shared remote backend serving several profiles locks/lists/unlocks the requested one; unknown profile → RPC error, not a crash. Fill target: inspection stamps are `<nonce>:<index>`; a fill resolves only its own inspection's stamps, so an interleaved second inspection can no longer redirect A's password into a newly mounted field (real Chrome: 0 filled, both fields empty). Desktop Settings: every RPC goes through the owner profile's socket (requestGatewayForProfile), query keys carry (connection, profile), an owner change closes dialogs and wipes drafts (a master password typed for A is never submitted to B; a late list from A never paints under B), and vault.add secrets travel in a ref consumed by the mutationFn instead of mutation variables. Three owner-routing invariant tests on the real component. Docs/PR body: session-scoped release, lock-race semantics, bw --passwordenv. |