e87f673faaf1dbaaaa04f37992887fe29b0b9893
164 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
55c6fd01ac |
fix(tui_gateway): declare response_transformed on the message.complete contract (#96465)
MessageCompletePayload is extra="forbid", so the forwarded flag raised ContractViolation under test isolation and logged a wire-contract violation on every transformed turn. Regenerate the shared TS/OpenRPC contract and type the desktop field from the generated MessageCompletePayload. |
||
|
|
ecacf3d0c9 |
fix(desktop): declare the branch methods in the typed gateway contract
Rebased onto main, where params are validated against tui_gateway/contracts and unknown keys are rejected: the copy_parent_history / omit_messages flags on session.create and session.branch now answered 4000. Keep both methods' wire shape unchanged and give the two new methods their own contracts (session.branch_stored, session.branch_whole) with messages_omitted results; the handlers take the behaviour as keyword arguments, not wire flags. |
||
|
|
716db852fe |
test: restore replay-barrier fallback and background-sync coverage
Sabotage showed the barrier's bounds were unguarded: making the timeout/unsupported-method fallback never settle, or lifting the 10s replay deadline, left every suite green, as did deleting the pre-read barrier await in reconcileActiveTranscript. Restore Bear's original timeout/unsupported it.each from 8ee10f65931 (both must resolve true and release parked frames within the bound), and add one reconcileActiveTranscript case proving a background refresh does not read REST until the reconnect replay has dispatched. |
||
|
|
bb7788c626 |
fix(desktop): let history reads proceed when the replay epoch changes
A backend restart announces a new replay_epoch over the still-open socket (gateway.ready or the replay response). adoptReplayEpoch revoked the pending replay holds by resolving their barriers false, which every caller treats as "socket lost, abandon the read". Nothing re-arms that read: the socket never dropped, so no new open follows. The reconnect backstop read (#94779) was silently discarded and turns the old backend committed while Desktop was disconnected never reached the transcript. On an epoch change no replay can cover the old numbering, so REST is the only recovery left: resolve the waiting barriers true. Their continuations still run after the parked live frames dispatch. Socket invalidation keeps resolving false, since the next open re-reads. |
||
|
|
9e664181dd |
test: trim salvage tests to invariants
Keep the two files that pin the ordering contract: the shared client's replay barrier (installed before open listeners run, resolves only after replayed and parked frames dispatch, false when its socket is lost) and the warm-resume ordering. Drop the renderer hydration suite and the formatting-only churn in the replay test; the barrier contract they exercised is covered at the client boundary. |
||
|
|
37e0ac0079 |
fix(desktop): hold history reads behind reconnect replay
After a reconnect, REST history could paint a completed turn before the socket dispatched that turn's ordered replay. The replay then appended the same progress and final rows again, and the unchanged-history signature shortcut left the duplicates in place. Sequence watermarks order replayed frames against live frames, but not REST publication against replay. JsonRpcGatewayClient now installs a per-session replay barrier before synchronous socket-open listeners run. Each session settles on its own once its replay and parked live frames have dispatched. Timeout and unsupported-method fallbacks resolve true. Socket invalidation and epoch revocation resolve false, so pending reads are abandoned. Connect still does not await replay, and the existing replay deadline still applies. pendingSessionReplay() inspects existing sockets without dialing. The active, tile, fallback and warm-resume hydration paths wait on it before activating or reading history, check again when the read returns, and recheck selection and ownership after waiting. Carved out of #119186; its fold/dedup half is superseded by #120809. |
||
|
|
6828ffa70f | fix(desktop): persist edit previews before tool-result flush | ||
|
|
7ed6534c7e | Merge origin/main: browser fence composed with the dispatch/retry split (#115184); server registration, i18n, contracts | ||
|
|
38137d2b6e |
test: purge low-value tests, lane js06 (375 removed)
Change-detectors, tautologies, source-reading tests, redundant duplicates, mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane ledger (category + reason for every removal). |
||
|
|
71b61884a4 |
fix(desktop): deliver a gateway event once even when two sockets to one backend carry it
The backend stamps `seq` once per event before its transport fan-out, so the same frame arriving on two of the renderer's sockets to one process carries the same (session_id, seq); `JsonRpcGateway` is per socket and cannot see the other copy. `JsonRpcGateway.dispatchEvent` now tags each event with the `replay_epoch` its socket adopted from `gateway.ready` (per process, not per socket), and both fan-ins in `use-gateway-boot.ts` (primary + registry secondaries) pass through one `createGatewayEventDedupe()` gate keyed by (epoch, session_id, seq) before any store runs. A duplicate is the same key within 30 s. Not "seq not above the highest seen": the backend restarts a session's counter at 1 when the session leaves its 64-session replay ring, and a high-water mark would swallow the whole restart of any short session. Seq-less / session-less events always pass. Bounded: 2048 seqs per session, 256 sessions LRU. Live, with the router fix reverted so two sockets still join the chat: the DOM never doubled a word (0/38 samples vs 4/40 on main) and the doubled "reply was cut short" card is back to one. Closes #120007. Part of #120005. |
||
|
|
6ec517c475 |
feat: onboarding offers catalog plugins beside connectors and installs the picked ones before handoff
NS-960. One card, one group ("connectors"): the curated catalog plugins
this OS runs lead the hosted connectors (D1, D4). A plugin whose app is
absent is greyed with the reason and stays pickable (D5). Picks land in
answers.plugins and ride the existing [setup] note to the guide.
The guide's runbook gains the install beat as the last thing before the
handoff card (D2, D3): narrow the picks to what the chosen task needs, then
ONE manage_catalog install call. A new suggested first task sits beside the
existing ones and installs its plugins through that same beat: "Set up my
games and streaming" (NVIDIA App + Broadcast) on a Windows PC with an NVIDIA
GPU, "Help me make something in Blender" everywhere else.
When the guide's install card settles, each plugin row's outcome
(installed / failed / skipped, whatever the user did) is written into the
answers (D6). The build session's runbook names what is ready, what was
offered and not installed, and what was picked but not offered, and tells
the build agent never to install. profiles.remember_onboarding records the
picked plugin names in the default profile's memory, next to the connectors.
|
||
|
|
5c215ffa21 |
feat: catalog marks onboarding plugins; catalog rows carry the app's presence
The onboarding card needs to list catalog plugins beside the hosted connectors (NS-960 D1, D4) and grey a plugin whose app is absent (D5). The catalog had no curated flag, and the manage_catalog row left app_state empty. - `onboarding: true` and `title` on catalog entries (loader, validator, docs); set on blender, nvidia-app and nvidia-broadcast. - hermes_cli/plugin_catalog_presence.py reads the plugin.json at the catalog's pinned commit once per pin and judges its app declaration with the hermes_platform resolver the installer and the Plugins-tab pill use. No declaration or an unreadable one is `unknown`, never `present`. - `plugins.manage action=onboarding` lists the curated entries this OS runs (platform mismatch is the only exclusion) with app_state and the sentence the card greys the row with. - manage_catalog plugin rows now carry app_state and the catalog title. - A live entry that differs from the in-tree entry at the same pin (new metadata) now follows the same newer-catalog rule as a new pin, so a checkout that adds `onboarding` is not masked by a published doc that predates it. |
||
|
|
9039b690fc |
feat: installed plugins' MCP tools and skills are live in every open chat, no Connect-now (#119644)
Installing a plugin from any surface now makes its MCP servers and skills
usable in every open chat of that profile on the chat's next turn. There is
no Connect-now button, no /reload-mcp, no relaunch.
- hermes_cli/plugins_activation.py::load_and_go_live: after the forced plugin
rescan, connect the plugin's portable MCP servers one by one
(register_mcp_servers, the connector flow's call), then refresh that
profile's open chats and queue a turn note listing the servers, their
tools and the plugin's skills. activation gains live_now; deferred keeps
only Python tools and prompt sections.
- MCP tools are deferred behind tool_search / tool_call, so appending them
(preserve_prefix) leaves the model-facing tool array and the cached prompt
prefix unchanged; the note rides the existing one-shot turn-note channel,
so the system prompt stays byte-stable. /reload-mcp keeps its consent gate:
it is a full rebuild.
- tui_gateway/methods_tools.py: one session walk (_refresh_live_sessions)
shared by reload.mcp and plugin activation, filtered to the plugin's
profile home.
- hermes plugins install/enable (another process) asks the running Desktop /
dashboard backend to do the in-process half through the new
POST /api/dashboard/agent-plugins/activate, found via the host rendezvous
record and its session token.
- Desktop: the Connect-now toast and its strings are removed in all six
locales; the install toast says what went live ("3 tools connected ·
skill X ready") and warns per server that did not connect.
- Contract: PluginActivation.live_now; regenerated TS/OpenRPC.
- register_skill docstring: plugin skills are listed by skills_list.
|
||
|
|
8365cae594 |
Catalog install card: plugin and skill rows with an Advanced install modal (#119633)
* feat(desktop): catalog install card rows for plugin and skill targets manage_catalog opens the same connection operation manage_connections does, with rows of kind plugin/skill. The renderer collapsed every non-connector kind to 'mcp', so those rows would have drawn as MCP servers with no name, purpose or provenance. - Contract: ConnectionTargetKind gains plugin/skill; ConnectionOperationTarget types the catalog fields agreed in CATALOG-ROW-CONTRACT.md (display, description, tier, platforms, repo, sha, subdir, scan, requirements, has_desktop_half, target_profile, app_state, skill). - Store: kinds map through a lookup; catalog rows carry a typed CatalogEntry. - CatalogRow: kind glyph, name, kind/tier/platform pills, one-line purpose, then Install / Advanced / Skip. Installing shows the backend's per-row detail; installed shows "N tools · show names"; failed shows the reason and Try again (approved again for that row only); skipped stays quiet. - Advanced: a centred dialog with the Plugins-tab controls (halves, target profile, enable, force, pin) plus read-only source and scan. Its Install sends the values in the answer env; the row's Install sends env null. - A missed tool.start restores the card on a manage_catalog row. * fix(desktop): catalog row uses the app's sliding progress and one pill style The installing row drew a full-width pulsing line, which reads as a finished rule, not work in progress; it now uses the Progress primitive's sliding indeterminate block (reduced motion honoured there). The kind pill was all-caps monospace next to two sentence-case pills; all three now share one shape, matching the V1 offer artboard, and the platform pill stays the one coloured note. The Advanced modal's Source and Security tables share a fixed label column, and requirements render as prose, not code. * fix(desktop): a skill's Advanced shows only the controls a skill has A skill has no agent/desktop halves, no enable step and no commit pin, so its Advanced dialog offered three controls that do nothing. It now shows the target profile, force reinstall, source, security and credentials, and sends only target_profile, force and the credential values. The preparing line takes the card's width instead of the whole chat column. |
||
|
|
36b4f7cc2d | fix(gateway): acknowledge persisted prompt and reply identities | ||
|
|
3c6c132366 |
Setup profile is minted by the backend and found by role, not by name (#119456)
* feat: setup profile is minted by the backend and found by role, not by name The guided onboarding runs in a profile the desktop used to create itself (profiles.create with a soul, "already exists" treated as success) and recognise by the literal "hermes-setup". The upcoming setup toolset grants catalog installs to that profile, so the marker that grants it must be written only by the backend. - profile.yaml carries `role: setup`; read_profile_meta / write_profile_meta / ProfileInfo know it; profiles.list and GET /api/profiles report it. - hermes_cli/setup_profile.py: ensure (find by role, adopt a pre-role hermes-setup dir, else clone default + soul + role) and reset (soul, memories, skills back to the created state, in place). The soul text moves here from the renderer. - tui_gateway/methods_onboarding.py: onboarding.ensure_setup_profile and onboarding.reset_setup_profile. Neither takes a name; profiles.create and profiles.configure already reject `role` (unknown key, 4000). - Copies never inherit the role: --clone-all, profile import, and a distribution that ships profile.yaml drop it. - setup.status for a named profile reports `ready` once the boot bootstrap settled. Since one host backend serves every profile (#118246) the desktop's setup-profile probe lands on this branch, which never set `ready`, and the kickoff waited forever. - Desktop: SETUP_PROFILE, ensureSetupProfile(profiles.create) and composeSetupSoul are gone. store/setup-profile.ts holds the name the backend returned (or the roster's role row after a relaunch); kickoff, handoff and the build card use it. The dev reset calls the reset RPC. * fix: write the setup soul as bytes so Windows keeps \n line endings * refactor(desktop): drop the renderer's setup-profile store; the backend is the only owner Kickoff reads the name straight from onboarding.ensure_setup_profile and records it on $setupSession, which every later step already carries. The handoff recovery check reads the roster row's role. No renderer module holds a setup-profile name or a fallback lookup. |
||
|
|
cc02316660 | fix: portable MCP server names in the activation summary are the mcp.json names (post-#119263) | ||
|
|
21d0b12958 |
feat(plugins): late-loaded plugins wire their platform handlers live (#87770)
A plugin that finished loading after an adapter connected never got its platform
handlers (slash commands, button callbacks, inbound transforms) registered until a
gateway restart, silently. Three pieces, one seam shared by every surface:
1. Discovery listener: PluginManager.on_plugin_loaded(cb) fires from INSIDE
discover_and_load for the plugins a sweep newly loaded (diff of the loaded set),
with a per-plugin activation summary (hermes_cli/plugins_activation.py):
activated_now {gateway_commands, gateway_transforms, hooks, callbacks} vs
deferred {tools, prompt, mcp_servers}. Every mid-run load path now performs a real
discover_plugins(force=True): CLI install/enable (via the gateway), Desktop/TUI
plugins.manage install/toggle/update, dashboard REST install, tool-triggered
force re-discovery, the new `reload-plugins` control-socket verb. A non-forced
discover_plugins() short-circuits on _discovered, which is why reload.mcp after
a mid-run install used to reload the OLD server set.
2. Idempotent re-wire: BasePlatformAdapter.rewire_plugin_handlers() runs only
factories not yet wired on the live native client (keyed (plugin, qualname);
a force reload hands back new function objects). Telegram hoists late handlers
ahead of core's catch-all filters.COMMAND / CallbackQueryHandler (PTB dispatches
the first match per group) and re-wires on the transient-init rebuild; Slack
dedupes register_slack_action_handler per AsyncApp. The gateway runner
subscribes per served profile and re-wires on the loop.
3. Scope limit + honest messaging: handlers only. Tools/prompt stay deferred to
the next session (prompt-cache invariant), MCP servers to mcp.reload; the CLI
hint and plugins.manage results (activation, gateway_reloaded,
restart_required only when no gateway answered) say exactly that.
|
||
|
|
d826001ae5 |
fix(desktop): plugin install no longer fails its own wire contract (#119254)
`dashboard_install_plugin` has returned `python_dependencies` in its ok payload since |
||
|
|
571ceec006 |
Desktop plugin card shows each declared server's state and the sentence that explains it (NS-941) (#119051)
* feat: expose plugin server states * feat: reduce plugin server snapshots * feat: render plugin server health |
||
|
|
dc50403a81 |
feat(desktop): the Connectors page replaces the MCP tab (#119074)
* feat(connectors): the backend serves a connector's tool list, cached for 24 hours
The Connectors page opens one app and shows every tool it has. The backend
had no way to read that list.
- `tools/connectors/portal/`: a client for the portal's tool-list route and a
JSON cache under the Hermes home, one file per portal origin and connector.
An entry is fresh for 24 hours. After that the read revalidates with the
stored ETag: 304 keeps the list, 404 deletes the entry, an upstream failure
serves the stored list marked stale, and a 401 never serves the cache.
- `connectors.tools {slug, refresh}`: account-level, routed by `profile`, no
chat session. Errors carry a fixed `reason` from one closed set on the rail.
- Every connector model that is not operation state moves into
`tui_gateway/contracts/connectors.py`. Handlers that no chat session owns
live in `tui_gateway/methods_connectors_account.py`.
The wire model is tolerant: an unknown facet reads as unclassified and one odd
tool never blanks a connector.
* feat(connectors): catalog, accounts and member tool rules by RPC
The Connectors page needs the app catalog, the connected account of one app,
a way to disconnect it, and the member's own on/off rules. None had an RPC.
- `connectors.catalog`: name, description, category and logo of each app.
- `connectors.accounts`, `connectors.accounts.remove`: read the accounts at
the tool gateway and remove one by id.
- `connectors.policy.get`: the rule layers that apply to the member, widest
first. The body is a union on `mode`, so a reader can name who turned a
tool off.
- `connectors.policy.set`: one change, a union on `type` (the tools of one
connector, or one connector on or off), with the revision the user saw. A
stale revision answers `POLICY_CONFLICT`. The backend composes the upstream
write in one pure function, so no renderer learns the upstream rules.
- Bundled MCP manifests can name their hosted twin with `connector:`, so the
page can show one card per app.
* feat(connectors): connect an app without a chat session
Every connector RPC took a `session_id`, and a connect that did not come from
the model's tool call minted a link with no watcher. The Connectors page has
no chat session, and its card must flip to connected by itself.
- `connectors.list`, `connectors.connect`, `connectors.operation.status`,
`connectors.operation.wake` and `connection.respond` take `owner`, a union
on `type`: `session` (today's behaviour and authorization) or `account`
(routed by `profile`, authorized by the live transport like `mcp.*`).
`session_id` is gone from these params; every desktop caller sends `owner`.
- An account connect runs the same operation lifecycle on a background
thread, under the profile's scope, so the watcher reads the account and
settles the operation. A second connect for an app that is already
connecting returns the open operation and mints nothing.
- `connection.update` carries `owner`. An account operation has no session to
address, so its updates go out on the session-less broadcast path.
* feat(mcp-catalog): eighteen more bundled entries name their hosted connector
A bundled MCP entry and a hosted connector for the same app are one card
on the Connectors page only when the manifest names its hosted twin.
Linear and Notion had the field. These entries get it too: airtable,
asana, attio, calendly, dropbox, figma, railway, supabase, todoist,
betterstack, canva, cloudflare, datadog, intercom, neon, sentry, stripe
and vercel. Atlassian maps to two hosted connectors and Prisma Postgres
is not clearly the same app, so both stay without one.
* refactor(connectors): the account handlers share one gate, one params model and one write table
The six account-level handlers each repeated the availability gate, the
auth catch and the catch-all reply. One decorator now owns that, and each
handler validates its params with its contract model instead of a ladder
of isinstance checks. The five connection RPCs share one guard for the
unexpected-failure reply.
The four write composers for the member rules were the same function
with a different list key and polarity. They are one table now.
The owner union lives in contracts/common.py, so the params side and the
event side stop declaring it twice and the import cycle is gone.
An account operation start carries one event and a flag, so the wait for
the sign-in link blocks instead of polling every 50 ms. run_operation
loses its two account-only parameters; drive_operation is the second
entry point.
Tests: four deleted (they exercised pydantic or the mock), three merged
into tables, two added (a client that still sends the old top-level
session_id is refused; all six account RPCs run off the server loop).
The shared reply helper and the HTTP and managed-client fakes move to
one place each. Comments are one line or gone.
* fix(connectors): a missing tool-list route reads as "unavailable", not "connector gone"
The tool-list read treated every 404 as the portal's "this connector is
not in the catalog" answer. It deleted the cache entry and answered
CONNECTOR_NOT_FOUND, so a page would offer to remove an app that is
connected and works. A portal that does not serve the route yet answers
a bare 404 for every app.
Only the portal's own {"error": "connector_not_found"} means the
connector is gone. Any other 404 is now a tool-list outage: the cached
list is served as stale, or the RPC answers TOOLS_UNAVAILABLE.
* fix(connectors): a connect from the page returns to the app after sign-in
The sign-in link carries a return target only when the session's surface
is the desktop. A chat session binds that surface. An account-owned call
has no chat session, so nothing bound it: the link was minted without a
return target and the browser ended on the portal's done page instead of
coming back to Hermes.
Every account-owned call now runs with the process's own surface bound,
next to its profile scope. The operation thread copies that context, so
the first link and every reissued link carry the return target and the
operation id.
* test(connectors): defer the new connector RPC coverage
The tests for the new account RPCs, the portal client, the tool-list cache
and the rule composer leave this PR and come back in one later change, after
the API is settled. The same was done for #111008.
Kept: the edits that existing tests need because the five connection RPCs
now take `owner` instead of `session_id`, and the rename of the managed
client seam.
Removed: six new test files, their two fakes and the gateway conftest, and
the new cases in test_mcp_catalog.py, test_connectors_gateway_client.py,
gateway-rpc.test.ts and notifications.test.ts. Reverting this commit restores
all of them.
* fix(cli): the connection panel hands the tool thread back at once
The classic CLI's connection callback waited on a queue for the user's first
decision. The operation's watcher starts only after the callback returns, and
the watcher is what polls a hosted account, runs the 300-second deadline and
sees Ctrl+C.
For a hosted connector the panel opens on the sign-in link, where the only
key that filled the queue was Cancel. The account was never polled: the user
signed in, the panel never changed, and Esc reported the app as skipped.
Ctrl+C set the interrupt flag but left the thread parked on the queue, so the
turn never ended.
The callback now opens the panel and returns, as the gateway's callback does
for the desktop and the Ink TUI. The panel's actions already reach the
operation through apply_answer on the UI thread, so the queue is removed. An
install with a form still waits for Connect, because the backend starts no
work for a pending row. Ctrl+C now settles the operation as `interrupt`, and
open rows become `not_connected`.
Checked on the e2e rig with the fake tool gateway: hosted connect completes on
the third status read; Ctrl+C ends the turn and the polling stops; an MCP
install with a plain and a secret field still saves config and both values.
* fix(connectors): "run it again" lives in the library, so the classic CLI can use it
Making a new sign-in link for a failed or expired hosted connector was
implemented only in the JSON-RPC layer (`_reissue`). The classic CLI does not
go through JSON-RPC: its Connect button on a failed row called apply_answer,
which does nothing for a hosted operation because it has no MCP runner. The
panel showed "Waiting…" until the deadline.
`tools.connectors.run.reissue(operation, names)` now holds the checks and the
per-kind action, and returns a refusal reason or None. The gateway maps each
reason to the same JSON-RPC error as before. The CLI calls it for a hosted
row; a refusal is shown on the row. MCP rows keep their path, because Connect
on a failed MCP row re-sends the form values.
Checked on the e2e rig: a scripted failed sign-in, then Connect: a second mint
with `reinitiate: true`, a new link with a new connection id, then connected.
* feat(connectors): the account list and disconnect go through the portal
`connectors.accounts` and `connectors.accounts.remove` called the tool
gateway. They now call the portal's account-management routes
(`GET /api/v1/connectors/accounts`, `DELETE /api/v1/connectors/accounts/{id}`),
which apply the organisation membership checks and write the disconnect audit
row. There is no fallback to the gateway when the portal is unavailable, and a
removal is never retried.
The read of ONE account stays on the gateway (`GET v1/connectors/accounts/{id}`):
the portal has no such route, and the operation watcher polls it once per second.
`ConnectorClient.list_accounts` and `delete_account` are removed. The removed
account's reply model carries `connector`, which both services send.
* fix(connectors): the account RPCs answer what the portal really sends
Checked against the portal source and against the staging and production
services.
- Errors are read from the upstream error code, not the HTTP status. A rule
write answered 409 for a stale revision and for a user with no organisation;
both read as "the policy changed". `org_required` is now `ORG_REQUIRED` and
403 `no_access` is `ORG_ACCESS_DENIED` on every account RPC; only a rejected
sign-in is `NEEDS_NOUS_AUTH`. `connectors.list` and `connectors.connect` with
the account owner map these too.
- `connectors.policy.get` and `connectors.policy.set` carry `effective`: the
portal's own result for this user, with its stamp and without provider or
subject ids. Nothing is recomputed locally.
- A rule write needs the revision the user saw: `expected_revision` is required
and must be a revision string; a bad one is refused before any HTTP call.
- A tool row carries `no_auth`; a list without the upstream flag is an invalid
answer, not `false`.
- `connectors.accounts.remove` returns the app of the removed account. An
invalid id is `INVALID_PARAMS`.
- The tool-list cache is per signed-in member (a hash of the token's `sub`),
so two Nous accounts on one profile do not share entries.
- A malformed slug is a local error, not a 404 from a server nobody called.
Live, staging: no revision and a malformed revision refused locally; a good
revision wrote one disabled Gmail tool and returned it in `effective`; the
same revision again answered `POLICY_CONFLICT`; the list row showed the tool;
the restore brought the member rules back to the start. Live, staging and
production, read-only: all 60 tool lists (5483 tools) parse.
* fix(connectors): the operation RPCs match their contract; a settled card cannot start a new link
Found by two adversarial reviews of the RPC layer and its types.
- `connectors.connect` from a chat session with no open operation is refused
(`UNKNOWN_OPERATION`). It used to call `manage_connections` through the tool
registry with no card: it made a link nobody watched, returned a reply
without the required `settled` field, and named an operation that was never
registered. There is one way into an operation: the agent's call, or the
account owner's `connectors.connect`. "Run it again" inside an open
operation is unchanged.
- `connection.update` for a session is routed by session key AND profile; two
profiles with the same key no longer cross-deliver a sign-in link. The event
payload gets the same redaction as the RPC replies.
- `connection.respond` runs on the long-handler pool: an approval can start MCP
OAuth discovery, which blocked every RPC of the gateway while it ran.
- `connectors.list` rows are a closed snake_case model: `connector`, `enabled`,
`connected`, `connection_status`, `status_reason`, `gateway_disabled_tools`.
The last one is display data: the gateway enforces the rules, the backend
only passes the list on. The phantom `name` and `description` are gone, and
the desktop uses the generated types instead of hand-written copies.
- `tools_listing` (model-only data) no longer rides on `connectors.operation.status`.
- `unavailable` is removed from the target states and settle reasons: nothing
produces it. The contract generator now fails when a contract enum and its
domain enum differ.
- `ConnectorErrorReason` is part of the generated TypeScript and OpenRPC.
- The desktop sends `connection.respond` on the socket that holds the session,
as wake and reissue already did.
- Contract violations are logged every time, at error level.
- An account connect whose prepare step is slow returns the live operation
instead of an error while the operation keeps running.
- The MCP-manifest `connector` field leaves this PR (it moves to a later one
on top of the catalog-reader change). `hermes_cli/mcp_catalog.py` and
`optional-mcps/` are untouched by this PR again.
anti-slop: no net-new findings (15 touched files).
* fix(connectors): the model gets no sign-in link wherever a card exists; side agents cannot connect
The flag that tells the model "a connection card exists" was the session
platform (`== "desktop"`). The Ink TUI and the classic CLI also draw a card,
so there a connector call on an unconnected app handed the model the raw
`connect_url` and told it to pass the link to the user.
- The agent turn now declares how a link can reach the user
(`tools/connectors/turn.py`): CARD when the agent was built with a
connection callback, SIDE for a subagent or a background turn, LINK for a
headless run (`-q`, cron, ACP, api_server, messaging). It is set once per
tool batch in the agent loop and read by the connector dispatch path, which
never sees the agent. The session platform decides return-to-app only.
- CARD: the result carries `connect_card_available` and our hint, never the
link and never the gateway's own hint.
- SIDE: subagents (`delegate_tool`), gateway background turns and the classic
CLI `/bg` are built with `side_agent=True`. They hold no `manage_connections`
tool on any path that derives the tool list, and a connector call on an
unconnected app gets no link, only "report this to the main agent".
- LINK is unchanged.
- The hosted path with no card builds a detached operation, as the MCP path
does, so no `connection.update` is emitted for an operation no client asked
for. Names and docstrings that said "off desktop" now say "no card".
- A settled card is dead on the desktop: `reissueConnectionTarget` and
`respondToConnectionRequest` share one guard and send nothing for a settled
or unknown operation.
- The model-facing settled result no longer carries `connection_id`; the model
repeated it to the user.
Shown on the real clients with a real model (rig, fake tool gateway): Ink TUI
and classic CLI get `connect_card_available` and no link, the model opens the
card, the account connects, the retried call succeeds; `-q` still gets the
link; a subagent and a background turn have no `manage_connections` and get
the no-link hint; on the desktop a card settled with Continue has no enabled
control and sends no RPC.
* feat(tools): every call made through tool_search + tool_call shows a real label on all three clients
A bridged call showed as a generic `tool_call` row in the Ink TUI and as
`⚡ tool_call` in the classic CLI, because the display looked the name up in
the tool registry and bridged names are made at run time. The desktop labelled
only batches that were all hosted connector calls, by parsing names itself.
- `tools/tool_labels.py` is the one place that turns a bridged call into a
label: kind, app, action, emoji and text. Hosted: `connectors__gmail__GMAIL_SEND_EMAIL`
→ "Gmail · send email". MCP: "Linear · list issues". A local deferred tool
keeps its own emoji, verb and primary-argument preview. A batch gets exactly
one label per entry, always; an entry with no name gets a generic label.
- Classic CLI: one row per inner call; the duration on the last row; the
failure text on the row of the call that failed. With friendly labels off
it prints what it printed before.
- Gateway: tool start, progress and complete events and stored transcript rows
carry a typed `labels` field. It does not depend on the classic CLI's
display setting. Clients no longer parse tool names.
- Ink TUI: rows from the labels; the verbose trail keeps Args and Result.
- Desktop: `ConnectorExecution` renders hosted, MCP and mixed turns from the
labels, one row per call. The labels reach the row under a key no tool
argument can use. The connect card it drew under a failed tool result is
gone: after `CONNECTION_REQUIRED` the one way in is the agent's own
`manage_connections` call.
- `tool_search` and `tool_describe` rows read "Searching tools · <query>" and
"Reading tool details · N tools".
Shown on the real desktop (video and screenshots), the Ink TUI and the classic
CLI with the rig: hosted rows, MCP rows, a two-entry batch, a failed entry, a
`CONNECTION_REQUIRED` row with no card under it, labels after a reload, and the
desktop rows with the classic CLI setting off.
* fix(connectors): the model can tell "hosted tools unavailable" from "no such tool"; manage_connections routes MCP names correctly
- A failed hosted search or describe used to return nothing, by design, so the
model saw only local tools and told the user that a connected app was
missing. The local results are unchanged; when the hosted leg failed, the
`tool_search` and `tool_describe` results carry
`connectors: {status: "unavailable", reason: "unreachable" | "sign_in_expired"}`
and one hint line. A rejected token is `sign_in_expired`; an entitlement
refusal or a shut gate adds nothing. `tool_describe` no longer lists those
names under `not_found` next to "search again".
- NS-932. The description now says which side a name belongs to: a bare name
is a hosted connector account; `mcp: true` only when the user asks for an MCP
server, a local server or an install, or when the name exists only in the
catalog; connect and reconnect are hosted verbs, install, enable and
authorize are MCP verbs. It names the three clients that draw a card.
- A misrouted target is refused with the call that works. Only when the
gateway does not know the connector (confirmed on that failure path) and the
name is a catalog entry does the target fail with "X is a local MCP server.
Call manage_connections with action install ...". It is a per-target
outcome: other targets of the same call keep their links and their card. A
vendor failure on a name both sides know stays an ordinary failed row. The
MCP side mirrors it, and never for an entry that is only not installed.
- "Do not re-ask after a skip or a timeout" no longer stops the model when the
USER asks for that app again; the description and the settled-result notes
say so. A builder saw the model refuse a direct user request.
Shown on the Ink TUI and the classic CLI with a real model: a dead gateway and
a 401; "connect fxmail" goes hosted; "install the fx-noauth MCP server" goes
MCP; "connect fx-noauth" reaches the MCP install card in one corrective round
with no hosted mint; a two-target call where one is misrouted still connects
the other with exactly one mint.
* fix(tui): the connection card answers every key, shows what is happening, and is dead once settled
Reproduced on the real Ink TUI with the rig, then fixed:
- The keyboard was dead during the sign-in wait: the card kept a `submitting`
flag that the normal OAuth path never cleared, and Esc went through the same
guard. The in-flight state now belongs to the answered row and clears when
that row moves, when any later frame of the operation arrives, or after
five seconds. Esc skips the row in every phase; Ctrl+C interrupts the turn
(the input handler had no branch for this overlay); Shift+arrows scroll the
transcript and the card ignores them; arrow keys no longer move the text
cursor and the field focus at once.
- The card was lost at turn idle: the overlay flag was cleared while the
operation stayed in the store, and a resume dropped the pending card. The
flag survives idle, a resume shows the pending card again, a session switch
clears it.
- States with no branch: `not_connected` and a row with no link fell into the
credential form; `expired` vanished with no note. The title and the row text
now name the action (connect, reconnect, install, enable, authorize); a
failed or expired row with no fields offers Try again / Skip; a failed row
WITH fields reopens the form over the typed draft, with the failure above it.
- A settled card is dead: at settle the overlay closes and one transcript line
per app states the outcome. A settled or dismissed operation id is
remembered, so no replay or resume can reopen its card. Esc in the last
"Finishing…" moment hides the card and still writes the outcome lines.
- A failed `connection.respond` and a browser that did not open are shown on
the card in one sentence.
Also: `tui_gateway/connector_payload.py` redacted the BOOLEAN `secret` flag of
a credential field to the string "[REDACTED]". On the desktop every credential
field therefore rendered as a password and lost its prefilled default. A
boolean is no longer redacted.
* chore(connectors): remove the comments and docstrings this branch added
Deletions only. Kept: tool directives (`# noqa`, `// eslint-disable`, ...),
`// SAFETY:` lines, and the docstrings of the contract models under
`tui_gateway/contracts/`, which become the descriptions in the generated
OpenRPC and TypeScript.
Checked that no code changed: every Python file has the same AST as before
once docstrings and `pass` are ignored (62 files), and every TypeScript file
prints the same with comments stripped by the TypeScript printer (32 files).
The generated contract files are unchanged.
* fix(connectors): a card restored after a reload answers again; every account RPC names auth and org failures
Found by the end-to-end runs on the pushed head.
- Desktop: after a window reload, Continue on the restored card sent nothing.
The answer looked up the backend that holds the session with the runtime
session id, the lookup wants the stored id, and a failed lookup returned
silently. When the lookup gives no owner the answer now goes out on the
window's active socket, which is what main does.
- `connectors.policy.get` answered `POLICY_UNAVAILABLE` for a rejected sign-in,
a refused scope, a non-member and a missing organisation alike: the handler
runs with the gateway's globals and did not import the reason enum, so its
own error mapping raised. `connectors.accounts.remove` caught auth failures
in its generic branch. `org_required` was mapped on `policy.set` only. All
six account RPCs now answer `NEEDS_NOUS_AUTH`, `FORBIDDEN_SCOPE`,
`ORG_ACCESS_DENIED` and `ORG_REQUIRED` for those four upstream answers.
* wip(desktop): port the Connectors tab files and wiring onto the #115191 head
* wip(desktop): Connectors tab on the #115191 contract, catalog arm removed, audit defects fixed
* wip(desktop): Connectors tab passes the anti-slop ratchet; dormant two-ways code and the Available collapse removed
* wip(mcp): every server row says whether config or a plugin provides it; writes refuse plugin rows
* wip(desktop): Connectors tab, the owner's first live round (custom MCP form, kind words, compact dialog)
* wip(desktop): the connector dialog fits its content
* wip(desktop): catalog MCPs show on the Connectors tab until the catalog dies; connector_slug pairs a manifest with its managed app; the closed-gate state
* wip(desktop): connectors cache v3, the seed shape gained connector_slug
* wip(desktop): the owner's answers on the connectors page
A plugin-provided server now shows its tool list: the dialog probes it
through the existing read-only test endpoint, shows the tools without
switches (the plugin owns them), and shows the probe's error with a
Retry when the server cannot start. Its card is named after the server
key in the plugin's mcp.json, not the namespaced runtime key.
The paste box no longer parses `--header` on a `hermes mcp add` line;
the CLI has no such flag.
The rule write sends the member layer's revision only. The portal
always returns a member layer (baseline revision when no row exists)
and compares the write against that row, so the effective revision was
never the right guess. Verified live on staging: two writes in a row,
both accepted, policy restored.
The page cache keeps every read for signed-in accounts too and only
clears itself when the account is signed out. The storage version moves
to v4 so old blobs are ignored.
* chore(desktop): strip the prose comments the connectors page branch added
Comments and docstrings this branch added relative to main are gone;
tool directives, SAFETY lines and the contract docstrings that feed the
generated OpenRPC stay. Guards: Python AST and TypeScript printer output
are identical before and after; ruff, tsc, eslint, the ratchet and the
generated contracts are unchanged.
|
||
|
|
5c0e73eff1 |
feat(desktop): render plugin-declared settings in the Plugins tab (#46600, #87934)
A plugin manifest's `config_schema` now reaches the Desktop: `plugins.manage list` returns each plugin's schema with the current `plugins.entries.<id>.settings` values (`settings_schema`), and a new `settings` action writes edits through `hermes_cli.plugins_state.save_plugin_setting` — the writer extracted from `PluginContext.set_config`, so the plugin, the CLI and the Desktop share one config path, one lock and the same managed-install / managed-key refusals. The Plugins tab grows a gear per plugin with a schema; the inline form is table-driven (`FIELD_CONTROLS` / `INITIAL_TEXT` / `COERCE` keyed on the wire field type) for string / number / boolean / enum / json / secret. Secrets are declared with `type: secret`: the row carries only the `.env` name and a presence flag, the client writes the value through the existing `PUT /api/env` credential route, and the RPC refuses secret keys so nothing lands in config.yaml. Contracts regenerated; docs gain a "Settings form in the Desktop" section. |
||
|
|
70f5dc5f46 |
feat(connectors): the backend API for the desktop Connectors page; connect an app without a chat session (#115191)
* feat(connectors): the backend serves a connector's tool list, cached for 24 hours
The Connectors page opens one app and shows every tool it has. The backend
had no way to read that list.
- `tools/connectors/portal/`: a client for the portal's tool-list route and a
JSON cache under the Hermes home, one file per portal origin and connector.
An entry is fresh for 24 hours. After that the read revalidates with the
stored ETag: 304 keeps the list, 404 deletes the entry, an upstream failure
serves the stored list marked stale, and a 401 never serves the cache.
- `connectors.tools {slug, refresh}`: account-level, routed by `profile`, no
chat session. Errors carry a fixed `reason` from one closed set on the rail.
- Every connector model that is not operation state moves into
`tui_gateway/contracts/connectors.py`. Handlers that no chat session owns
live in `tui_gateway/methods_connectors_account.py`.
The wire model is tolerant: an unknown facet reads as unclassified and one odd
tool never blanks a connector.
* feat(connectors): catalog, accounts and member tool rules by RPC
The Connectors page needs the app catalog, the connected account of one app,
a way to disconnect it, and the member's own on/off rules. None had an RPC.
- `connectors.catalog`: name, description, category and logo of each app.
- `connectors.accounts`, `connectors.accounts.remove`: read the accounts at
the tool gateway and remove one by id.
- `connectors.policy.get`: the rule layers that apply to the member, widest
first. The body is a union on `mode`, so a reader can name who turned a
tool off.
- `connectors.policy.set`: one change, a union on `type` (the tools of one
connector, or one connector on or off), with the revision the user saw. A
stale revision answers `POLICY_CONFLICT`. The backend composes the upstream
write in one pure function, so no renderer learns the upstream rules.
- Bundled MCP manifests can name their hosted twin with `connector:`, so the
page can show one card per app.
* feat(connectors): connect an app without a chat session
Every connector RPC took a `session_id`, and a connect that did not come from
the model's tool call minted a link with no watcher. The Connectors page has
no chat session, and its card must flip to connected by itself.
- `connectors.list`, `connectors.connect`, `connectors.operation.status`,
`connectors.operation.wake` and `connection.respond` take `owner`, a union
on `type`: `session` (today's behaviour and authorization) or `account`
(routed by `profile`, authorized by the live transport like `mcp.*`).
`session_id` is gone from these params; every desktop caller sends `owner`.
- An account connect runs the same operation lifecycle on a background
thread, under the profile's scope, so the watcher reads the account and
settles the operation. A second connect for an app that is already
connecting returns the open operation and mints nothing.
- `connection.update` carries `owner`. An account operation has no session to
address, so its updates go out on the session-less broadcast path.
* feat(mcp-catalog): eighteen more bundled entries name their hosted connector
A bundled MCP entry and a hosted connector for the same app are one card
on the Connectors page only when the manifest names its hosted twin.
Linear and Notion had the field. These entries get it too: airtable,
asana, attio, calendly, dropbox, figma, railway, supabase, todoist,
betterstack, canva, cloudflare, datadog, intercom, neon, sentry, stripe
and vercel. Atlassian maps to two hosted connectors and Prisma Postgres
is not clearly the same app, so both stay without one.
* refactor(connectors): the account handlers share one gate, one params model and one write table
The six account-level handlers each repeated the availability gate, the
auth catch and the catch-all reply. One decorator now owns that, and each
handler validates its params with its contract model instead of a ladder
of isinstance checks. The five connection RPCs share one guard for the
unexpected-failure reply.
The four write composers for the member rules were the same function
with a different list key and polarity. They are one table now.
The owner union lives in contracts/common.py, so the params side and the
event side stop declaring it twice and the import cycle is gone.
An account operation start carries one event and a flag, so the wait for
the sign-in link blocks instead of polling every 50 ms. run_operation
loses its two account-only parameters; drive_operation is the second
entry point.
Tests: four deleted (they exercised pydantic or the mock), three merged
into tables, two added (a client that still sends the old top-level
session_id is refused; all six account RPCs run off the server loop).
The shared reply helper and the HTTP and managed-client fakes move to
one place each. Comments are one line or gone.
* fix(connectors): a missing tool-list route reads as "unavailable", not "connector gone"
The tool-list read treated every 404 as the portal's "this connector is
not in the catalog" answer. It deleted the cache entry and answered
CONNECTOR_NOT_FOUND, so a page would offer to remove an app that is
connected and works. A portal that does not serve the route yet answers
a bare 404 for every app.
Only the portal's own {"error": "connector_not_found"} means the
connector is gone. Any other 404 is now a tool-list outage: the cached
list is served as stale, or the RPC answers TOOLS_UNAVAILABLE.
* fix(connectors): a connect from the page returns to the app after sign-in
The sign-in link carries a return target only when the session's surface
is the desktop. A chat session binds that surface. An account-owned call
has no chat session, so nothing bound it: the link was minted without a
return target and the browser ended on the portal's done page instead of
coming back to Hermes.
Every account-owned call now runs with the process's own surface bound,
next to its profile scope. The operation thread copies that context, so
the first link and every reissued link carry the return target and the
operation id.
* test(connectors): defer the new connector RPC coverage
The tests for the new account RPCs, the portal client, the tool-list cache
and the rule composer leave this PR and come back in one later change, after
the API is settled. The same was done for #111008.
Kept: the edits that existing tests need because the five connection RPCs
now take `owner` instead of `session_id`, and the rename of the managed
client seam.
Removed: six new test files, their two fakes and the gateway conftest, and
the new cases in test_mcp_catalog.py, test_connectors_gateway_client.py,
gateway-rpc.test.ts and notifications.test.ts. Reverting this commit restores
all of them.
* fix(cli): the connection panel hands the tool thread back at once
The classic CLI's connection callback waited on a queue for the user's first
decision. The operation's watcher starts only after the callback returns, and
the watcher is what polls a hosted account, runs the 300-second deadline and
sees Ctrl+C.
For a hosted connector the panel opens on the sign-in link, where the only
key that filled the queue was Cancel. The account was never polled: the user
signed in, the panel never changed, and Esc reported the app as skipped.
Ctrl+C set the interrupt flag but left the thread parked on the queue, so the
turn never ended.
The callback now opens the panel and returns, as the gateway's callback does
for the desktop and the Ink TUI. The panel's actions already reach the
operation through apply_answer on the UI thread, so the queue is removed. An
install with a form still waits for Connect, because the backend starts no
work for a pending row. Ctrl+C now settles the operation as `interrupt`, and
open rows become `not_connected`.
Checked on the e2e rig with the fake tool gateway: hosted connect completes on
the third status read; Ctrl+C ends the turn and the polling stops; an MCP
install with a plain and a secret field still saves config and both values.
* fix(connectors): "run it again" lives in the library, so the classic CLI can use it
Making a new sign-in link for a failed or expired hosted connector was
implemented only in the JSON-RPC layer (`_reissue`). The classic CLI does not
go through JSON-RPC: its Connect button on a failed row called apply_answer,
which does nothing for a hosted operation because it has no MCP runner. The
panel showed "Waiting…" until the deadline.
`tools.connectors.run.reissue(operation, names)` now holds the checks and the
per-kind action, and returns a refusal reason or None. The gateway maps each
reason to the same JSON-RPC error as before. The CLI calls it for a hosted
row; a refusal is shown on the row. MCP rows keep their path, because Connect
on a failed MCP row re-sends the form values.
Checked on the e2e rig: a scripted failed sign-in, then Connect: a second mint
with `reinitiate: true`, a new link with a new connection id, then connected.
* feat(connectors): the account list and disconnect go through the portal
`connectors.accounts` and `connectors.accounts.remove` called the tool
gateway. They now call the portal's account-management routes
(`GET /api/v1/connectors/accounts`, `DELETE /api/v1/connectors/accounts/{id}`),
which apply the organisation membership checks and write the disconnect audit
row. There is no fallback to the gateway when the portal is unavailable, and a
removal is never retried.
The read of ONE account stays on the gateway (`GET v1/connectors/accounts/{id}`):
the portal has no such route, and the operation watcher polls it once per second.
`ConnectorClient.list_accounts` and `delete_account` are removed. The removed
account's reply model carries `connector`, which both services send.
* fix(connectors): the account RPCs answer what the portal really sends
Checked against the portal source and against the staging and production
services.
- Errors are read from the upstream error code, not the HTTP status. A rule
write answered 409 for a stale revision and for a user with no organisation;
both read as "the policy changed". `org_required` is now `ORG_REQUIRED` and
403 `no_access` is `ORG_ACCESS_DENIED` on every account RPC; only a rejected
sign-in is `NEEDS_NOUS_AUTH`. `connectors.list` and `connectors.connect` with
the account owner map these too.
- `connectors.policy.get` and `connectors.policy.set` carry `effective`: the
portal's own result for this user, with its stamp and without provider or
subject ids. Nothing is recomputed locally.
- A rule write needs the revision the user saw: `expected_revision` is required
and must be a revision string; a bad one is refused before any HTTP call.
- A tool row carries `no_auth`; a list without the upstream flag is an invalid
answer, not `false`.
- `connectors.accounts.remove` returns the app of the removed account. An
invalid id is `INVALID_PARAMS`.
- The tool-list cache is per signed-in member (a hash of the token's `sub`),
so two Nous accounts on one profile do not share entries.
- A malformed slug is a local error, not a 404 from a server nobody called.
Live, staging: no revision and a malformed revision refused locally; a good
revision wrote one disabled Gmail tool and returned it in `effective`; the
same revision again answered `POLICY_CONFLICT`; the list row showed the tool;
the restore brought the member rules back to the start. Live, staging and
production, read-only: all 60 tool lists (5483 tools) parse.
* fix(connectors): the operation RPCs match their contract; a settled card cannot start a new link
Found by two adversarial reviews of the RPC layer and its types.
- `connectors.connect` from a chat session with no open operation is refused
(`UNKNOWN_OPERATION`). It used to call `manage_connections` through the tool
registry with no card: it made a link nobody watched, returned a reply
without the required `settled` field, and named an operation that was never
registered. There is one way into an operation: the agent's call, or the
account owner's `connectors.connect`. "Run it again" inside an open
operation is unchanged.
- `connection.update` for a session is routed by session key AND profile; two
profiles with the same key no longer cross-deliver a sign-in link. The event
payload gets the same redaction as the RPC replies.
- `connection.respond` runs on the long-handler pool: an approval can start MCP
OAuth discovery, which blocked every RPC of the gateway while it ran.
- `connectors.list` rows are a closed snake_case model: `connector`, `enabled`,
`connected`, `connection_status`, `status_reason`, `gateway_disabled_tools`.
The last one is display data: the gateway enforces the rules, the backend
only passes the list on. The phantom `name` and `description` are gone, and
the desktop uses the generated types instead of hand-written copies.
- `tools_listing` (model-only data) no longer rides on `connectors.operation.status`.
- `unavailable` is removed from the target states and settle reasons: nothing
produces it. The contract generator now fails when a contract enum and its
domain enum differ.
- `ConnectorErrorReason` is part of the generated TypeScript and OpenRPC.
- The desktop sends `connection.respond` on the socket that holds the session,
as wake and reissue already did.
- Contract violations are logged every time, at error level.
- An account connect whose prepare step is slow returns the live operation
instead of an error while the operation keeps running.
- The MCP-manifest `connector` field leaves this PR (it moves to a later one
on top of the catalog-reader change). `hermes_cli/mcp_catalog.py` and
`optional-mcps/` are untouched by this PR again.
anti-slop: no net-new findings (15 touched files).
* fix(connectors): the model gets no sign-in link wherever a card exists; side agents cannot connect
The flag that tells the model "a connection card exists" was the session
platform (`== "desktop"`). The Ink TUI and the classic CLI also draw a card,
so there a connector call on an unconnected app handed the model the raw
`connect_url` and told it to pass the link to the user.
- The agent turn now declares how a link can reach the user
(`tools/connectors/turn.py`): CARD when the agent was built with a
connection callback, SIDE for a subagent or a background turn, LINK for a
headless run (`-q`, cron, ACP, api_server, messaging). It is set once per
tool batch in the agent loop and read by the connector dispatch path, which
never sees the agent. The session platform decides return-to-app only.
- CARD: the result carries `connect_card_available` and our hint, never the
link and never the gateway's own hint.
- SIDE: subagents (`delegate_tool`), gateway background turns and the classic
CLI `/bg` are built with `side_agent=True`. They hold no `manage_connections`
tool on any path that derives the tool list, and a connector call on an
unconnected app gets no link, only "report this to the main agent".
- LINK is unchanged.
- The hosted path with no card builds a detached operation, as the MCP path
does, so no `connection.update` is emitted for an operation no client asked
for. Names and docstrings that said "off desktop" now say "no card".
- A settled card is dead on the desktop: `reissueConnectionTarget` and
`respondToConnectionRequest` share one guard and send nothing for a settled
or unknown operation.
- The model-facing settled result no longer carries `connection_id`; the model
repeated it to the user.
Shown on the real clients with a real model (rig, fake tool gateway): Ink TUI
and classic CLI get `connect_card_available` and no link, the model opens the
card, the account connects, the retried call succeeds; `-q` still gets the
link; a subagent and a background turn have no `manage_connections` and get
the no-link hint; on the desktop a card settled with Continue has no enabled
control and sends no RPC.
* feat(tools): every call made through tool_search + tool_call shows a real label on all three clients
A bridged call showed as a generic `tool_call` row in the Ink TUI and as
`⚡ tool_call` in the classic CLI, because the display looked the name up in
the tool registry and bridged names are made at run time. The desktop labelled
only batches that were all hosted connector calls, by parsing names itself.
- `tools/tool_labels.py` is the one place that turns a bridged call into a
label: kind, app, action, emoji and text. Hosted: `connectors__gmail__GMAIL_SEND_EMAIL`
→ "Gmail · send email". MCP: "Linear · list issues". A local deferred tool
keeps its own emoji, verb and primary-argument preview. A batch gets exactly
one label per entry, always; an entry with no name gets a generic label.
- Classic CLI: one row per inner call; the duration on the last row; the
failure text on the row of the call that failed. With friendly labels off
it prints what it printed before.
- Gateway: tool start, progress and complete events and stored transcript rows
carry a typed `labels` field. It does not depend on the classic CLI's
display setting. Clients no longer parse tool names.
- Ink TUI: rows from the labels; the verbose trail keeps Args and Result.
- Desktop: `ConnectorExecution` renders hosted, MCP and mixed turns from the
labels, one row per call. The labels reach the row under a key no tool
argument can use. The connect card it drew under a failed tool result is
gone: after `CONNECTION_REQUIRED` the one way in is the agent's own
`manage_connections` call.
- `tool_search` and `tool_describe` rows read "Searching tools · <query>" and
"Reading tool details · N tools".
Shown on the real desktop (video and screenshots), the Ink TUI and the classic
CLI with the rig: hosted rows, MCP rows, a two-entry batch, a failed entry, a
`CONNECTION_REQUIRED` row with no card under it, labels after a reload, and the
desktop rows with the classic CLI setting off.
* fix(connectors): the model can tell "hosted tools unavailable" from "no such tool"; manage_connections routes MCP names correctly
- A failed hosted search or describe used to return nothing, by design, so the
model saw only local tools and told the user that a connected app was
missing. The local results are unchanged; when the hosted leg failed, the
`tool_search` and `tool_describe` results carry
`connectors: {status: "unavailable", reason: "unreachable" | "sign_in_expired"}`
and one hint line. A rejected token is `sign_in_expired`; an entitlement
refusal or a shut gate adds nothing. `tool_describe` no longer lists those
names under `not_found` next to "search again".
- NS-932. The description now says which side a name belongs to: a bare name
is a hosted connector account; `mcp: true` only when the user asks for an MCP
server, a local server or an install, or when the name exists only in the
catalog; connect and reconnect are hosted verbs, install, enable and
authorize are MCP verbs. It names the three clients that draw a card.
- A misrouted target is refused with the call that works. Only when the
gateway does not know the connector (confirmed on that failure path) and the
name is a catalog entry does the target fail with "X is a local MCP server.
Call manage_connections with action install ...". It is a per-target
outcome: other targets of the same call keep their links and their card. A
vendor failure on a name both sides know stays an ordinary failed row. The
MCP side mirrors it, and never for an entry that is only not installed.
- "Do not re-ask after a skip or a timeout" no longer stops the model when the
USER asks for that app again; the description and the settled-result notes
say so. A builder saw the model refuse a direct user request.
Shown on the Ink TUI and the classic CLI with a real model: a dead gateway and
a 401; "connect fxmail" goes hosted; "install the fx-noauth MCP server" goes
MCP; "connect fx-noauth" reaches the MCP install card in one corrective round
with no hosted mint; a two-target call where one is misrouted still connects
the other with exactly one mint.
* fix(tui): the connection card answers every key, shows what is happening, and is dead once settled
Reproduced on the real Ink TUI with the rig, then fixed:
- The keyboard was dead during the sign-in wait: the card kept a `submitting`
flag that the normal OAuth path never cleared, and Esc went through the same
guard. The in-flight state now belongs to the answered row and clears when
that row moves, when any later frame of the operation arrives, or after
five seconds. Esc skips the row in every phase; Ctrl+C interrupts the turn
(the input handler had no branch for this overlay); Shift+arrows scroll the
transcript and the card ignores them; arrow keys no longer move the text
cursor and the field focus at once.
- The card was lost at turn idle: the overlay flag was cleared while the
operation stayed in the store, and a resume dropped the pending card. The
flag survives idle, a resume shows the pending card again, a session switch
clears it.
- States with no branch: `not_connected` and a row with no link fell into the
credential form; `expired` vanished with no note. The title and the row text
now name the action (connect, reconnect, install, enable, authorize); a
failed or expired row with no fields offers Try again / Skip; a failed row
WITH fields reopens the form over the typed draft, with the failure above it.
- A settled card is dead: at settle the overlay closes and one transcript line
per app states the outcome. A settled or dismissed operation id is
remembered, so no replay or resume can reopen its card. Esc in the last
"Finishing…" moment hides the card and still writes the outcome lines.
- A failed `connection.respond` and a browser that did not open are shown on
the card in one sentence.
Also: `tui_gateway/connector_payload.py` redacted the BOOLEAN `secret` flag of
a credential field to the string "[REDACTED]". On the desktop every credential
field therefore rendered as a password and lost its prefilled default. A
boolean is no longer redacted.
* chore(connectors): remove the comments and docstrings this branch added
Deletions only. Kept: tool directives (`# noqa`, `// eslint-disable`, ...),
`// SAFETY:` lines, and the docstrings of the contract models under
`tui_gateway/contracts/`, which become the descriptions in the generated
OpenRPC and TypeScript.
Checked that no code changed: every Python file has the same AST as before
once docstrings and `pass` are ignored (62 files), and every TypeScript file
prints the same with comments stripped by the TypeScript printer (32 files).
The generated contract files are unchanged.
* fix(connectors): a card restored after a reload answers again; every account RPC names auth and org failures
Found by the end-to-end runs on the pushed head.
- Desktop: after a window reload, Continue on the restored card sent nothing.
The answer looked up the backend that holds the session with the runtime
session id, the lookup wants the stored id, and a failed lookup returned
silently. When the lookup gives no owner the answer now goes out on the
window's active socket, which is what main does.
- `connectors.policy.get` answered `POLICY_UNAVAILABLE` for a rejected sign-in,
a refused scope, a non-member and a missing organisation alike: the handler
runs with the gateway's globals and did not import the reason enum, so its
own error mapping raised. `connectors.accounts.remove` caught auth failures
in its generic branch. `org_required` was mapped on `policy.set` only. All
six account RPCs now answer `NEEDS_NOUS_AUTH`, `FORBIDDEN_SCOPE`,
`ORG_ACCESS_DENIED` and `ORG_REQUIRED` for those four upstream answers.
|
||
|
|
db8dcbe94c |
fix: catalog re-pins ask before widening a plugin; annotated-tag pins keep reviewed trust
Annotated-tag pins (F8): a catalog `sha` recorded as `git rev-parse <tag>` names
the TAG object, while HEAD can only ever be the commit it points at. The scan
trust check compared HEAD against the unpeeled sha (so every tag-pinned entry
lost the reviewed-pin bypass and prompted on caution findings) and the sidecar
recorded the peeled commit, so `update_available` was true forever and every
`update` re-installed. The installer now peels the pin (`<sha>^{commit}`) for
trust, records `pin` on the catalog block only when the checkout satisfies it
(empty for an off-pin `--ref` install), and every at-pin check goes through
`at_catalog_pin(sidecar, entry_sha)` (repin, dashboard payload, TUI rows).
Re-pin consent (F10): `hermes plugins update` on a catalog install replaced the
tree without asking, even when the new pin declared new tools, hooks, Python
dependencies, host capabilities or a Desktop half. `repin_catalog_plugin` now
diffs the installed manifest against the staged clone BEFORE anything moves
(`_install_plugin_core(before_swap=...)`) and, on a widening:
- CLI: prints the delta and asks y/N (non-interactive → not applied, fail
closed); after a changed re-pin it runs the same `_run_capability_consent`
grant path as the git-pull `update`.
- `plugins.manage update` RPC and the dashboard REST route answer
`{ok: false, consent_required: true, delta, delta_lines}` with nothing
changed; a retry with `accept_capabilities: true` applies it. Desktop shows
the delta in its confirm dialog; the web dashboard uses `window.confirm`.
- Gateway contract regenerated (`accept_capabilities` param; `consent_required`,
`delta`, `delta_lines`, `error` result fields).
Catalog audit findings F8 and F10 (low severity, no issue filed).
|
||
|
|
a8b3fe60ac |
fix(plugins): restart_required on every toggle surface; trim salvage to invariant tests
Salvage of #66777 (@chrisyoung2005): the dashboard toggle toast now says a restart is needed (#71595). Moved the ``restart_required`` stamp from a dashboard router wrapper into dashboard_set_agent_plugin_enabled so the ``plugins.manage`` RPC (TUI/Desktop) carries it too; contract + generated TS regenerated. The salvaged endpoint tests stubbed the toggle they were checking — replaced by invariant tests that run the real commands against a temp HERMES_HOME (all red on origin/main): platform disable gates the loader, alias toggle writes the canonical key, status honours bundled defaults + memory.provider, remove forgets config / resets memory.provider / unlinks a symlink only, disabled memory provider is not loaded. test_deferred_platform_client_tools: bundled a2a is keyed ``platforms/a2a`` now. |
||
|
|
9a4a598bfc | fix(contracts): declare profile rename history in roster results | ||
|
|
2632229bcf |
fix(auth): named profiles read the root auth.json again (revert #111724)
Reverts
|
||
|
|
75b083e939 |
fix(ci): eslint ignores *.generated.ts; restore the contract file's header
The on-merge `npm run fix` bot (
|
||
|
|
bc655bfb40 |
fmt(js): npm run fix on merge (#118250)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
180975a3cb |
feat(gateway): plugins.manage remove action for user-installed plugins
The Desktop Plugins hub had no uninstall door: the plugins.manage RPC only offered list/toggle/install/update, so a plugin installed from the hub could only be removed from the CLI or the dashboard. Add a remove action that reuses hermes_cli.plugins_cmd.dashboard_remove_user_plugin (the same core as hermes plugins remove: deletes the tree and its install metadata atomically, refuses bundled plugins and paths outside the plugins dir), and regenerate the shared wire contract. |
||
|
|
afc3b7c6f3 |
feat(connectors): one backend-owned connection operation, with a setup card on Desktop, TUI and CLI (#111008)
* feat(connectors): the desktop connects apps through one backend-owned operation Re-based onto main after #109517, #110368, #110574 and #110843 landed as squash merges ( |
||
|
|
2c78b9b39e |
feat(process_registry): stamp exited_at and expose it on process.list
The live-work docks retire a finished background process ~60 s after it ends; the registry only knew started_at, so the age of an exit was not observable. _move_to_finished is the single choke point every exit path (reader loop, reconcile, kill) passes through, so the stamp lives there. completion_reason rides along so a killed process can read "killed" instead of "exit -15". |
||
|
|
1cf8a9fb41 |
fix(tui): count streamed frames as heartbeat liveness
The TUI's JsonRpcRequestChannel took the 'response' liveness default, so only a ping-ack or a response to a pending call refreshed the deadline: a socket streaming a delta every second through a 134s turn was declared dead at the 45s deadline and force-closed, splitting sessions that went on to complete server-side (#115251). Pass heartbeatLiveness: 'any-inbound' on the TUI channel — the same contract the desktop/web client already uses — so any inbound frame counts as life. A silent drop still trips the deadline, so true dead-transport detection is unchanged; only the false-kill class goes. The channel's 'response' mode keeps its semantics for callers that explicitly choose it; the shared wiring test pins the TUI construction site so the option cannot silently regress. Fixes #115251 |
||
|
|
30f0b22021 |
fix(desktop): honor artifact dismissal across navigation and replay
(cherry picked from commit 4f9b2e783cde917080307566e7200db13434a1b8) |
||
|
|
018531a871 |
fix(desktop): stable-open rule owns all three reconnect counters and covers secondary sockets
Gate review: `escalated` still cleared on every bare 'open', so once a flapping proxy kept `reconnectFailingSince` past the 5-minute escalation, each flap re-fired the persistent "connection lost" toast. The trio (attempt, failingSince, escalated) now resets together inside the stable-open check, matching the manual/wake reset sites. The predicate moves to apps/shared/reconnect-backoff.ts (`isStableOpen`, `RECONNECT_STABLE_OPEN_MS`) so the pooled secondary sockets in store/gateway.ts — which reset their ladder on every 'open' the same way — share one definition of "stable". |
||
|
|
a5ea71c409 | Merge origin/main: browser env socket-safe TMPDIR composed with the Bot Desktop display | ||
|
|
a80ec24fb0 |
Merge pull request #116291 from NousResearch/fix/boa-partial-ultra-pill
fix(desktop,tui): reasoning pill says ultra sends max on this route instead of a distinct Ultra level (#61634) |
||
|
|
e2a02106cf |
feat(tui_gateway): session.info reports the wire level the route sends for the effort
`ultra` is a Hermes-internal ladder step that every route clamps (to `max` on
OpenAI-compatible wires, per-model on Codex). The CLI already says so via
`agent/reasoning_effort.py::effort_display_label` ("ultra (sends max on this
route)"), but the renderers only ever received `reasoning_effort`, so the
Desktop and TUI had nothing to label a clamp with and showed Ultra as a level
of its own.
`_session_info` now also emits `reasoning_effort_wire`: the result of the same
`clamp_effort(route_supported_efforts(provider, model))` the request path uses
("" when unset/none, equal when verbatim). Clients label a clamp from this
value alone instead of duplicating the Codex per-model effort tables in TS.
Contract: `SessionLiveInfo.reasoning_effort_wire` + regenerated apps/shared
outputs. Part of #61634.
|
||
|
|
645e9298b6 |
feat(error-surface): 429 error card shows when the usage limit resets
A "HTTP 429: The usage limit has been reached" turn offered only Retry and never said when a retry would work, so users guessed or babysat the app (#98852). The provider already tells us: Retry-After / resets_at / retry_after are parsed into the turn's error context (extract_api_error_context) and honoured by the backoff, but the datum died there. - agent/turn_recovery.py::_stamp_limit_reset: both terminal paths (max_retries_exhausted_result, nonretryable_client_error_result) stamp failure_resets_at (epoch s) on the failed result and append one plain line ("Limit resets at 14:05 (in 1h 00m).") to final_response, which every text surface (CLI, Ink TUI, messaging gateway) renders. - agent/error_surface.py: result path forwards failure_resets_at as surface.resets_at; the exception path derives it from the same context. - tui_gateway/contracts/events.py::ErrorSurface.resets_at + regenerated apps/shared gateway-contract outputs. - apps/desktop lib/error-surface.ts: parse resets_at -> resetsAt, formatLimitReset("HH:mm (in 1h 05m)", null once passed), diagnostics line; the error card renders "Limit resets at …" next to Retry (i18n copy in every full locale). - Docs: website/docs/user-guide/desktop.md error-card section. Informational only: no scheduled or automatic retry is added — firing a turn unattended on a subscription is the maintainer's call (#98872, #103048). |
||
|
|
8bd0da2b8c |
Revert "feat(desktop): restore native skill and plugin catalogs"
This reverts commit
|
||
|
|
6338e988bd |
refactor(shared): type the handshake close as CloseEvent; drop the dead error-detail branch
Review follow-up: the close listener is a CloseEvent by construction (code is always a number), so the
`as { code?: unknown }` cast and the 'unknown' fallback guarded nothing. Renderer 'error' events carry no
message, and every consumer of this client is renderer-side, so the `: detail` suffix was never produced —
the class alone is the detail. Test: the fake socket loses its unused OPEN/static-last bookkeeping and the
prefix test asserts the real contract (prefix kept, not equal to the bare message) instead of prose.
|
||
|
|
efe042abe9 |
fix(shared): a failed gateway dial says which failure it hit
JsonRpcGatewayClient.connect() rejected every failure with the bare connectErrorMessage, so the Desktop boot overlay showed "Could not connect to Hermes gateway" whether the server closed the handshake with 4403 token_mismatch, the socket errored before opening (TLS, DNS, refused) or nothing answered within the connect timeout. Reporters on #41566 verified their gateways with curl and a plain WebSocket client and still could not say what the app had hit. The rejection now carries the failure class: "WebSocket closed during handshake: code 4403 token_mismatch", "WebSocket error before open[: detail]" or "no WebSocket open within N ms". The base message stays as the prefix, so the web sidebar's includes()-based transport matchers and the desktop recovery tests are unaffected. Refs #41566 |
||
|
|
b1be493b41 | Merge origin/main: SDK export list (WORKSPACE_PAGE_HEADER_AREA beside the profile-group exports) | ||
|
|
fff7c83113 |
test(shared): pin the interrupted-replay race to the issue's 3-socket sequence
Extend the salvaged regression test so it asserts the exact invariant from #114048 rather than only "a replay happens": socket 3 asks for last_seen=1, a live seq=3 racing that replay is parked by the NEW hold (watermark stays at 1 until the gap is recovered), and the final order is [1, 2, 3] with the watermark at 3. On origin/main this fails at the first assertion (no replay is sent on socket 3 because the stale flag is still set), which is the reporter's observed `replayOnThird: []`. |
||
|
|
93b3aa0674 | fix(desktop): restart replay after interrupted reconnect | ||
|
|
34ba61bf67 |
fix(agent): paste title hint reaches the instant title and the prompt.submit contract
Build on #114129 (@KoNit-K), which carries a Desktop-generated large-paste preview from the composer through `prompt.submit` -> `display_metadata` -> turn context -> the shared title input. Two gaps closed: - `apply_instant_title` never received the preview, so the instant title of a paste-only opener was the generated `@file:` path — and stayed that way, because the upgrade thread's `derive_title` fallback writes `derived` provenance, which never replaces the `derived` title already stored. Thread the hint into the instant stage too. - `build_title_input` let the `@file:` ref lead when the opener was nothing but the generated attachment ref; the preview now leads for a ref-only opener (an instruction still leads when the user typed one). - `prompt.submit` gains `title_preview` in the contract (regenerated shared TS/OpenRPC); documented as title-only input in the configuration guide. - Tests trimmed to two invariants (shared input reaches both stages; budget + manual attachments stay unread). |
||
|
|
ba94d111e2 |
fix(desktop): backend claimed-id set is the one owner for live project overlays
Build on #114643 (@KoNit-K): the renderer now keys the live overlay on an authoritative owner map, but that map was derived from the overview tree's `previewSessions` (capped at 3) plus hydrated lanes (empty in overview mode), so any owned row beyond the preview window still fell back to the cwd walk and re-appeared under an ancestor project. - `projects.tree` nodes now carry `sessionIds`: every row `build_tree` assigned to the project (contract + regenerated shared TS/OpenRPC). - `projectOwnerBySessionId` reads `sessionIds` first, keeping the row-derived fallback for a backend that predates the field. - `index.tsx` imports the helper the pick referenced (typecheck failed on the contributor head) and tolerates an undefined tree. - Tests: the exact issue layout (/work explicit, /work/repos/app explicit, cwd=/work/repos/app-2) with git_repo_root null AND populated; the entered ancestor gets nothing, the entered repo shows the linked-worktree lane, a row the tree does not know still lands by cwd; the backend keeps the claimed set complete in overview mode. |
||
|
|
d5bfdb5d7e |
fix(tui): bind complete.slash and skills.reload to the calling session's workspace
`command.dispatch` and `commands.catalog` resolve project-local skills for the session's repo, but the '/' completion popup (`complete.slash`) and `/reload-skills` (`skills.reload`) still ran `get_skill_commands()` / `reload_skills()` unbound on the RPC thread, where `find_project_root()` resolves the launch env ($HOME). The popup never offered `/<project-skill>` even though dispatch accepted it, and a reload right after dispatching one reported it under "Removed skills" with "0 skill(s) available" and republished a registry without it. Both handlers now run inside `_session_home_scope(session, cwd=_completion_cwd(params))` like the catalog; `CompleteSlashParams` / `SkillsReloadParams` gain an optional `session_id` (contracts regenerated). One invariant test covers popup + reload for two sessions in two repos (red on the previous head: popup skill items `[]`). |
||
|
|
f9d178f78e |
fix(tui_gateway): old app builds no longer stall the agent on clarify/approval; late approval choices count
Item 2 of #112548: a Desktop/dashboard build that predates server→client
requests has no response path, so every clarify/approval/sudo/secret/vault/
connection/bridge request sat for the full deadline (clarify: 300s). Only the
tour probed. Clients now advertise once per connection
(`client.capabilities {server_requests: true}`, sent by the shared TypeScript
channel on `gateway.ready`); `send()` / `send_async()` return the
error-response shape (None) at once when every WebSocket peer of the session
is a build that never advertised. Sessions with no client attached still wait
so the reconnect replay (`open_requests`) keeps working; the stdio TUI ships
with the backend and is not gated. The advertisement is dropped on disconnect.
Reviewer minors from #113227:
- tools/approval_gateway_wait.py: the verdict is the choice committed under
the approval lock while leaving the queue, so an /approve that lands after
the deadline check but before the entry is dropped is an answer, not a
timeout (the client was already acked "ok").
- tests/tui_gateway/test_protocol.py: the error-fails-fast test that only
restated pre-existing behaviour is replaced by the two capability
invariants (never advertised → fails fast; advertised → frame written,
waits, forgotten on disconnect).
- server_requests.send try/finally around event.wait already landed on main
(
|
||
|
|
ab6e665807 |
refactor(desktop): a missing server-request registry lets the channel answer -32601
dispatchServerRequest hand-rolled request.fail(JSON_RPC_METHOD_NOT_FOUND, 'Hermes Desktop has no server-request registry yet'), but the channel already owns that reply: JsonRpcRequestChannel.deliverRequest answers -32601 and fires onUnhandledRequest when a ServerRequestHandler returns false, and GatewayBootOptions.handleServerRequest already documents "false = no handler (the channel answers -32601)". Make dispatchServerRequest return false when the registry has no onServerRequest and true after forwarding; dispatchPrimaryServerRequest and both gateway.onRequest registrations (store/gateway.ts secondary sockets, use-gateway-boot.ts primary) now propagate that value to the channel. Keep the desktop-specific wording by wiring onUnhandledRequest on HermesGateway beside the onRequestHandlerError sink (console.warn), which needs the same GatewayClientOptions passthrough onRequestHandlerError got. Drop the now unused JSON_RPC_METHOD_NOT_FOUND import from the store and export JSON_RPC_INTERNAL_ERROR from the shared barrel beside it. Test: the store test asserts the false return with no fail() call when the registry is missing, and true + forwarded profile when it is present. Follow-ups (same class, outside this stack): use-gateway-boot.ts ~L889-891 still hand-rolls -32601 when the registry is present but has no handler; ui-tui has its own copy. |