da1bc7d502317075eba375a7488dfefc1a4fbf89
240 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ed6e37f9c1 | Merge remote-tracking branch 'origin/main' into ethie/pm-clean | ||
|
|
177f275b77 |
fix(plugins): route inject_message to the TUI session_key queue
Ink TUI and desktop never registered an inject host, and sharing set_gateway_message_injector with a live messaging gateway would let the last writer win. A separate host queues the reported session_key onto that session's prompt queue and leaves other keys for the gateway. Fixes #87412 |
||
|
|
708692a1c1 | Merge remote-tracking branch 'origin/main' into ethie/pm-clean | ||
|
|
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.
|
||
|
|
1f48a3d036 | Merge remote-tracking branch 'origin/main' into ethie/pm-clean | ||
|
|
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.
|
||
|
|
207f8fedfd |
Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts: # hermes_cli/local_runtime/binaries.py # hermes_cli/plugins_cmd.py # hermes_constants.py # tests/test_hermes_constants.py # tests/tools/test_clipboard.py # tests/tools/test_voice_wsl_pipewire.py # tools/computer_use/cua_backend.py # tools/voice_mode.py |
||
|
|
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.
|
||
|
|
a49d196b5c |
Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts: # hermes_cli/env_loader.py # hermes_cli/urllib_security.py # tools/terminal_scope.py |
||
|
|
b3f118fa62 |
refactor(plugins): drop the redundant hook-timeout memo
With the lock-free `_load_config_impl` fast path (049576c679, same
contributor), `load_config_readonly()` on a cache hit is already one
`_load_config_cache_sig` stat plus a dict get with no `_CONFIG_LOCK`, so
the `_HOOK_TIMEOUT_CACHE` memo in front of it saved nothing measurable:
probes/S3-quality-memo-cost.py (20k iters, real temp HERMES_HOME) memo
hit 8.40 us vs uncached fast path 8.95 us. It did add a second staleness
rule: the memo keyed on the file sig alone and skipped load_config's
env-snapshot check, so `hook_callback_timeout: ${VAR}` refreshed in
`load_config()` but not in the memo. It is the lock-free fast path that
makes the memo redundant, so remove the cache dict, the wrapper, the
per-path keying (ccb9e36746) and the tuple publish (b1b932a549): the
resolver is the former `_uncached` body calling `load_config_readonly()`
directly, renamed back to `_resolve_hook_callback_timeout`.
PROOF: probes/S3-quality-resolver-cost.py (memo gone, same harness):
`_resolve_hook_callback_timeout` 9.10 us/call. Dropped the memo-only test
`test_hook_timeout_memo_never_pairs_a_new_sig_with_an_old_value` and its
`_bump_mtime` helper; `scripts/run_tests.sh
tests/hermes_cli/test_config_lock_free_cache_hit.py
tests/hermes_cli/test_plugins.py` green (test_plugins patches
`_resolve_hook_callback_timeout` wholesale). Real import asserts
`_HOOK_TIMEOUT_CACHE` and `_resolve_hook_callback_timeout_uncached` are
gone; ruff + check-windows-footguns clean.
|
||
|
|
50a498bfdc |
test(config): drop the hook-timeout call-count proxy test
Shape gate: #117440 carried 4 added tests in tests/hermes_cli/test_config_lock_free_cache_hit.py. The call-count proxy `test_hook_timeout_does_not_read_config_on_every_invocation` rebound `cfgmod.load_config_readonly` by attribute assignment and only counted calls; the memo torn-read test plus the two lock-held reads are the invariants and already cover the behaviour. Its sole helper `_reset_hook_callback_timeout_cache` (plugins.py) had no other caller in tests/ or hermes_cli/, so it goes too. Module docstring unit fixed: "0.024us" -> "0.024ms" (config.py already said ms). PROOF: grep -n _reset_hook_callback_timeout_cache hermes_cli/*.py tests/**/*.py -> only the deleted test + definition. `python -c "import hermes_cli.plugins"` ok with PYTHONSAFEPATH=1. scripts/run_tests.sh on the test file: 3 passed. |
||
|
|
09fe2c1326 |
refactor(config): drop dead guards in the lock-free config fast paths
- plugins.py: the `isinstance(cached_value, float)` guard defended against nothing — `_resolve_hook_callback_timeout_uncached` only ever returns a float and the hit test already requires `sig is not None`. Type the cache value as `float`. `_reset_hook_callback_timeout_cache` is kept because tests/hermes_cli/test_config_lock_free_cache_hit.py calls it; docstring now says test-only (no production caller). - config.py: the 16-line narrative comment in `_load_config_impl` restated the raw-config comment and said 0.024us where the test says 0.024ms; cut to 4 lines with the right unit and a pointer to `_read_raw_config_impl`. - NOT folded: extracting the duplicated cache-hit check into a helper. The fast path swallows every exception and the locked path does not, and the load path also re-derives the sig, so a shared helper is not a pure extraction at the same semantics; left in place. PROOF: no behaviour change; tests/hermes_cli/test_config_lock_free_cache_hit.py 4 passed, probes/S3-fold-thrash.py still 2/100 uncached resolves. |
||
|
|
c6750513c0 |
perf(plugins): key the hook-timeout memo per config path
_HOOK_TIMEOUT_CACHE was one process-global (sig, value) slot. Under multiplex_profiles every profile switch saw a different config signature and re-resolved, so the memo only ever served the last-used profile. Key it by the scope-resolved config path like _LOAD_CONFIG_CACHE: a dict path_key -> (sig, value). Each value is still one tuple stored by a single dict item assignment (atomic under the GIL), so a lock-free reader cannot pair a new sig with an old value. The sentinel initial slot goes away with the slot itself. PROOF: probes/S3-fold-thrash.py alternates HERMES_HOME A/B 100 times and counts uncached resolves: before 100/100 (thrash), after 2/100 (one per home, 2 cache entries). test_hook_timeout_memo_never_pairs_a_new_sig_with_an_old_value now iterates the published tuples and stays green (4 passed). |
||
|
|
f68f9c4424 |
fix(plugins): publish the hook-timeout memo as one tuple
#117440's `_HOOK_TIMEOUT_CACHE` was a two-field dict written under a lock but read lock-free, so a reader could observe the new `sig` paired with the previous `value` (torn read) and serve a stale timeout for a fresh config.yaml. Hold `(sig, value)` in a single module global rebound in one STORE instead; readers unpack one published tuple, and the lock goes away because nothing else needed it. |
||
|
|
a46bc34142 |
fix(config): serve cached config reads without taking _CONFIG_LOCK
A cache hit in `_load_config_impl` costs microseconds, but it was served
from inside `_CONFIG_LOCK` — which `save_config()` holds across an atomic
YAML write. Measured on a clean checkout, driving the real functions
against a temp HERMES_HOME:
cache-hit read, uncontended median 0.0237ms
the SAME cached read while another
thread holds _CONFIG_LOCK 10010.2ms
On a gateway this lands on the event loop. `invoke_hook` calls
`_resolve_hook_callback_timeout`, which reads config, and a gateway fires
hooks on every inbound message — so one background config write stalls
every message for the full duration of that write. The same probe showed
the hook path doing 100 config reads across 100 hook invocations.
Two changes:
1. `_load_config_impl` gets a lock-free fast path for cache hits. The lock
never protected the cache dict: CPython dict get/setitem are atomic under
the GIL, and the cached tuple is replaced wholesale rather than mutated
in place, so a reader observes either the complete old tuple or the
complete new one. The lock's real job is serializing the rebuild
(parse + merge + expand) and the writers. Worst case on a race is a
redundant rebuild, which the locked path re-checks and collapses. The
existing `_load_config_cache_sig()` helper is reused, so the fast and
locked paths cannot drift on freshness.
2. `_resolve_hook_callback_timeout` is memoized on that same signature. The
value only changes when config.yaml does; every other call is a dict
lookup. Validation and clamping move unchanged into
`_resolve_hook_callback_timeout_uncached`.
After: the blocked read returns in 0.0ms and the hook path does 1 config
read per 100 invocations.
Tests (tests/hermes_cli/test_config_lock_free_cache_hit.py, 11 tests) drive
the real functions against a temp HERMES_HOME — no mocks of the code under
test. They cover the blocked-read case for both the readonly and deepcopy
entry points, that the fast path still sees a changed file, that
`load_config()` still returns an isolated object, a 4-reader + 1-writer
concurrency arm asserting no torn observation, and the hook memo's
freshness plus its clamp/fallback/zero-disables contract.
RED/GREEN on this base, impl reverted via git stash:
without the change : 8 failed, 3 passed (blocked read: 30.0s)
with the change : 11 passed
Neighbours green: tests/hermes_cli/test_config.py, test_plugins.py,
test_config_loader_e2e.py, test_managed_scope_loaders.py,
test_read_raw_config_readonly.py — 266 passed, 4 skipped.
(cherry picked from commit b787427bdaef81d8b114194779fe1de3b4efe7bd)
|
||
|
|
c13287c915 |
Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts: # apps/desktop/electron/main.ts # hermes_cli/backup.py # hermes_cli/config.py # hermes_cli/plugin_catalog.py # hermes_cli/plugins_cmd.py # hermes_cli/plugins_cmd_catalog.py # hermes_cli/plugins_discovery.py # hermes_cli/profiles.py # hermes_cli/update_cmd_deps.py # pyproject.toml # tests/gateway/test_dm_topics.py # tests/hermes_cli/test_config.py # tests/hermes_cli/test_plugins_cmd.py # tests/hermes_cli/test_update_autostash.py # tests/tools/test_lazy_deps.py # tools/lazy_deps.py # tools/skill_ledger.py # utils.py # website/docs/user-guide/security.md |
||
|
|
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. |
||
|
|
0e5809566f |
feat(plugins): fire pre/post_auxiliary_call events on every auxiliary LLM call (#79733)
Auxiliary LLM calls (titling, compression, MoA advisors/aggregator, vision, approval, ...) never reached any plugin hook: hook-based observability and cost plugins were structurally blind to them. Teknium's ruling on #79733: NEW events rather than reusing the turn-scoped pre/post_api_request pair, so existing subscribers keep their per-turn semantics. - agent/auxiliary_hooks.py (new sibling): builds the pre_api_request / post_api_request payload shape plus `aux_task`, `api_request_id` (`aux-...`, shared by every attempt of one logical call), `retry_count`, `streaming`, parent-turn `session_id`/`task_id`/`turn_id` when a main turn is in flight; fail-open (a raising/hung subscriber is logged and the aux task proceeds); post carries `error`/`error_type` on failure. - agent/auxiliary_client.py: the three relay funnels every physical attempt shares (_relay_sync_completion / _relay_async_completion / _relay_sync_stream) run under the hook pair — retries and fallbacks included. Main-loop *_api_request events do not fire for aux calls. - Catalogue: VALID_HOOKS, bounded-timeout hook set, `hermes hooks test` sample payloads, hooks.md / plugins index / observer-hooks / plugins.md tables, agent + plugins AGENTS.md. - tests/agent/test_auxiliary_hooks.py: 2 invariants (pair fires with aux_task and no api_request events; raising subscriber never breaks the call). First is red on origin/main. Supersedes #32416 (@zrmnelson), #68060 (@JonZal), #77518 (@hsy5571615), #79826 (@webtecnica) — their relay-boundary placement, usage normalisation and fail-open policy shaped this implementation. Co-authored-by: zrmnelson <zacharynelson1@gmail.com> Co-authored-by: Jonas Zalys <jonas@tryholo.ai> Co-authored-by: saitsuki <nukuom976228@gmail.com> Co-authored-by: webtecnica <webtecnica@gmail.com> |
||
|
|
9863e315f1 |
fix(plugins): per-plugin load deadline so a hung register() no longer hangs startup
A plugin whose import or register() never returns (an infinite loop, a blocking network call) held PluginManager.discover_and_load() forever, and with it every synchronous caller: `hermes chat`, gateway startup, ACP session/new (#108139). Each plugin's import + register() now runs under `plugins.load_timeout_seconds` (default 10, 0 disables, max 600) on a daemon worker. On overrun the plugin is recorded as failed with "load timed out after Ns" (same channel as every other load failure: startup WARNING, `/plugins`, `list_plugins()`), its pre-hang registrations are disposed, and discovery continues with the next plugin. The abandoned worker's later `ctx.register_*`/`subscribe`/`on_unload` calls are refused with a WARNING (the context is marked abandoned), so a late registration can never land in a registry the failure path already swept. Abandoned loaders are capped per process (8); past the cap further loads are refused with a named reason rather than run inline, which would recreate the hang (#98382 shape). Because the worker cannot own the caller's RLocks: the deferred-platform eager fallback now runs outside the replacement transaction, discovery re-entered from a loader worker returns on the already-set discovered flag instead of blocking on the sweep's lock, and such a worker never joins the background discovery thread that is waiting on it. |
||
|
|
727e7342cf |
fix(gateway): await async pre_gateway_dispatch callbacks on the event loop
`GatewayInboundMixin._hm_pre_gateway_dispatch_hook` was a plain `def` calling the sync `hermes_cli.lifecycle.invoke_hook` from the async `_hm_admit_event`, so an `async def pre_gateway_dispatch` callback was resolved through `resolve_plugin_command_result` on a helper thread with its own loop: the gateway loop blocked for the callback's whole duration and any loop-bound await (an `asyncio.Event` set by a loop task, a loop-bound aiohttp session, `asyncio.to_thread`) could never complete, failing at 30s. Add `PluginManager.ainvoke_hook` (+ `hermes_cli.plugins.ainvoke_hook` / `hermes_cli.lifecycle.ainvoke_hook`): same payload narrowing (shared `_hook_callback_kwargs`), observer + isolation semantics and result contract as `invoke_hook`, but awaitable results are awaited on the caller's loop. `pre_gateway_dispatch` stays intentionally unbounded. The inbound hook becomes `async def` and `_hm_admit_event` awaits it; the sync `invoke_hook` is untouched for every other caller. Existing tests that stubbed the hook synchronously are adapted to the async seam. Fixes #110241 Salvages #110265 (cherry picked from commit 22bb10d305c7992f43bbfdf1481ad0ef0015b2b7) |
||
|
|
02ad41df3a |
fix(plugins): a pre_tool_call block outranks an earlier plugin's approve
`_get_pre_tool_call_directive_details` returned the first valid block-or-approve in registration order, so a plugin registered earlier that returned `approve` hid a later security plugin's `block`; under `approvals.mode: off` an approve means no prompt at all, so the veto was dropped silently. Precedence is now `block` > `approve` > none: a valid block still returns immediately (modify directives seen before it stay attached, as before), a valid approve is held back until the whole result list has been scanned for a veto, and among approves the first valid one (with its rule_key) still wins. Modify accumulation is unchanged and now also keeps modify directives that follow the winning approve, since the scan no longer stops there. Docstring and hooks.md no longer describe "first valid directive wins". Slim redo of #68644 (earliest) and #87449 against the modify-aware shape of the function on main; both PRs predate it and could not be cherry-picked. Fixes #87420 Supersedes #68644 Supersedes #87449 Co-authored-by: synscott <1563043+synscott@users.noreply.github.com> Co-authored-by: Jack Lau <72348727+jackulau@users.noreply.github.com> |
||
|
|
89f0ae80d4 |
fix(plugins): accept str skill paths in register_skill
Plugin register() helpers commonly pass the SKILL.md location as a filesystem string (PluginManifest.path itself is stored as str), but register_skill() called path.exists() directly, so a valid string path aborted the whole plugin load with "'str' object has no attribute 'exists'" instead of registering. Coerce to Path up front so the registry entry and find_plugin_skill() keep their Path contract, and a missing location still fails with FileNotFoundError. Fixes #104404 |
||
|
|
aeff56aa35 |
fix(plugins): loader gates read the running version, survive SystemExit, quarantine deps everywhere
- requires_hermes compared against stale editable-install dist metadata (0.21.0) while the checkout ran 0.21.4, skipping plugins that required the release in use; hermes_cli.__version__ is now the source of truth, dist metadata only a fallback. - PEP 440 pre/post suffixes glued to a segment (99.0.0rc1) made a clause permissive and an rc running version disabled every gate; the segment parser drops the suffix. - A plugin calling sys.exit() at import or in register() propagated SystemExit out of discovery: the whole registry emptied and `hermes chat` exited 3 with no output. Load isolation now covers SystemExit (KeyboardInterrupt still propagates). - uv reads [tool.uv] exclude-newer from the cwd project only, so lazy/plugin dep installs launched from $HOME, a gateway service or the Desktop backend were never quarantined; the uv tier now runs from the checkout root when one exists. - A flat user/project manifest naming a bundled key from a differently named directory no longer displaces the bundled plugin (warn + skip); a same-named override is logged at INFO. |
||
|
|
a6ae6ace51 |
Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts: # .github/workflows/js-tests.yml # agent/model_metadata.py # apps/desktop/electron/main.ts # apps/desktop/scripts/bundle-electron-main.mjs # apps/desktop/src/app/settings/about-settings.tsx # apps/desktop/src/app/settings/gateway-settings.test.tsx # apps/desktop/src/app/settings/gateway-settings.tsx # apps/desktop/src/app/updates-overlay.tsx # gateway/shutdown_flush.py # hermes_bootstrap.py # hermes_cli/local_runtime/binaries.py # hermes_cli/main.py # hermes_cli/managed_uv.py # hermes_cli/update_cmd.py # hermes_cli/update_cmd_deps.py # hermes_cli/update_cmd_fleet.py # hermes_cli/update_cmd_maint.py # hermes_cli/update_receipt.py # hermes_cli/update_serve_obligations.py # hermes_constants.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_managed_uv.py # tests/hermes_cli/test_pending_supervisor_recovery.py # tests/hermes_cli/test_startup_fast_guards.py # tests/hermes_cli/test_update_desktop_stale_warning.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_state/test_hermes_state.py # tests/tools/test_tirith_security.py # tools/bot_relay.py # tools/checkpoint_manager.py # tools/write_approval.py # website/docs/getting-started/updating.md # website/docs/reference/environment-variables.md |
||
|
|
9dda4332f8 |
fix(plugins): an installed directory plugin keeps its identity over a same-name pip entry point
The pyproject wrapper shape (#113851) depends on a pip package that often ships its own hermes_agent.plugins entry point under the same name. Discovery appended entry points last with "later wins", so after a catalog install the plugin row became source=entrypoint with no install dir or catalog provenance: Desktop lost "installed from catalog @ version / update to ..." for exactly the shape the catalog now recommends. Entry points no longer displace a directory plugin of the same key, in the loader and in the CLI/RPC listing. Live: catalog install of mnemosyne, row before = entrypoint / mnemosyne_hermes:register / no catalog fields; after = user / ~/.hermes/plugins/mnemosyne / catalog 0.7.0 @ 95ef3be2; provider still discovered. Test red on base. |
||
|
|
b4a294fff9 |
Merge origin/main; keep PM as plugin dependency owner
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths. |
||
|
|
8beeb661c9 |
fix(plugins): report an identically failing hook callback once, not on every call
A plugin callback whose signature names a parameter the hook never sends (on_pre_tool(tool_data) where core provides tool_name/args) raises the same TypeError on every tool call; core logged a WARNING each time — ~1700 lines an hour in the report, burying the freeze signature it was trying to find (finding (c) of #111922). The mis-declared signature is the plugin's bug; the per-call repeat is ours. invoke_hook now reports one WARNING per distinct (hook, callback, error) and names the fields the hook actually provides so the author can fix the signature; identical repeats are logged at DEBUG. A callback failing in a new way still warns. Part of #111922 |
||
|
|
d93f74b758 | fix(auth): isolate dashboard provider registration by launch home | ||
|
|
23036e20a6 |
fix(ux): plain-language, actionable user-facing messages (core)
Squashed integration of the user-facing message audit for this surface set. Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts). |
||
|
|
62b4488cb5 |
fix(cron): routed fires are multiplexed at the worker handoff; managed keys keep policy precedence
Review findings on f5f88d5058. Three are defects the previous round introduced. Managed keys were stripped as launch residue. Recording every dotenv load as residue swept in the administrator-managed `.env`, which `_apply_managed_env` applies LAST with override precisely so it beats the user's own `.env`. A routed child then lost `ORG_POLICY_FLAG=managed-value` to the routed user's `user-value`. Managed keys are now recorded separately, never enter the residue set, and are re-applied over the routed scope in both child builders (`scheduler_script`, the restart-safe handoff) so the child sees the same precedence the launch process does. `kanban_db_dispatch` and `scheduler_delivery` strip without any overlay, so for them the exclusion alone is the guarantee; the test pins the case that exercises it — the same key defined in both the user and the managed file. Private hydration did not record supplied names. `_hydrate_profile_secret_sources` now feeds `provenance` plus `skipped_existing` into the same ownership set the process-global path uses; the provenance label map stays applied-only. Removal cleanup cleared its marker before the fallible work. A raising reload left the removed plugin's credential active with no retry, because the next no-source discovery saw the flag already false. The marker is cleared only after reset, reload and installed-scope refresh succeed. Routed fire not multiplexed at the handoff. `run_one_job` enables the context in `_install_fire_secret_scope`, which runs AFTER `_launch_external_cron_worker`, so a routed desktop fire on the managed path serialized `multiplex_active=False` and built the worker env with launch residue and no scrub. The handoff now treats `routed_profile_fire()` as multiplexed for exactly its own span; the worker re-establishes the state from the payload as before. Each fix was checked by reverting it and confirming its regression fails, including the overlay half and the exclusion half of the managed fix separately. (cherry picked from commit 329cbd8963d68c45b425e95a5b11ade59f513960) |
||
|
|
9d7de6c140 |
fix(cron): close three launch-residue leaks into a routed no_agent child
Review findings on d8c467f223, each reproduced through its production path. Stale launch key. `strip_launch_profile_env` built its residue set from a re-parse of the launch `.env`. A key removed or renamed in that file after boot is still in `os.environ` with the old value (dotenv never unsets), and the current file no longer names it, so it survived into the routed child. `_load_dotenv_with_fallback` — the one chokepoint every dotenv load goes through — now records the KEY names it put into the process env, additive for the process lifetime (`launch_dotenv_keys()`), and the strip unions that record with the current file. Source name that lost to the process env. `_apply_external_secret_sources` snapshots every name a source SUPPLIED (`provenance` + `skipped_existing`), but `secret_source_names()` only exposed `_SECRET_SOURCES`, which is provenance metadata and names applied values alone. A launch-profile source that supplied `CUSTOM_VAULT_SECRET` while the process already had it was therefore invisible to the scrub, and a routed child with an empty scope got the launch value. Supplied names are tracked separately (`_SOURCE_SUPPLIED_NAMES`) so the provenance labels stay honest, and `secret_source_names()` returns the union. Last plugin source removed. `_refresh_secret_sources_after_discovery` returned before the cache reset and the installed-scope refresh whenever no plugin source was enabled — and `discover_and_load(force=True)` unloads the old registration first, so removing the final plugin source hit exactly that return with the removed plugin's names still in the per-home snapshot and the current scope. The manager now remembers that a discovery re-applied plugin sources and, on the next discovery that finds none, reconciles once. A home that never had a plugin source is still a no-op (pinned by the existing tests). Regressions: the stale-key lifecycle and the skipped-existing case through `_run_job_script` against a real routed child, and the removal case through the manager. Each checked by reverting its fix and confirming the test fails. (cherry picked from commit d464f5f6126a394cfb47937f683d3a5e2f141840) |
||
|
|
dbede34f6e |
fix(cron): a routed profile's cron fire in the desktop backend runs under multiplex semantics
The desktop backend ticks EVERY local profile's cron store from one process — its own docstring says "like a multiplex gateway" (hermes_cli/web_server.py) — but never sets the process-global multiplex flag, and cannot: its own chat turns are unscoped and would fail closed. Every isolation in the tree keys on that flag — the guard that keeps a routed `.env` out of the shared `os.environ`, `get_secret`'s fail-closed miss, passthrough resolution, the MCP and kanban subprocess scrubs — so all of it was inert for a sibling profile's fire. Verified: a secondary profile's API keys replaced the launch profile's in `os.environ` with `override=True` and stayed there after the tick, and a scope miss read the launch profile's tokens (#107692). Give multiplex mode a context-local counterpart. `set_multiplex_context` (agent/secret_scope.py) is OR'd into `is_multiplex_active()`. `_profile_cron_scope` only MARKS a fire whose home is not the process's own (`routed_profile_fire`, decided against `get_process_hermes_home()`, the override-immune resolver); `_install_fire_secret_scope` in cron/scheduler.py installs the profile's hydrated secret scope and, for a marked fire, the multiplex context — for exactly that span, dropped again before the scope by `_reset_fire_secret_scope`. Multiplex semantics are therefore never active in cron without a scope to read: `run_one_job`'s restart-safe handoff runs before the body's scope and keeps today's semantics (its own scope is #107413 / #106050's seam, left untouched so this composes with whichever lands). Every existing multiplex-keyed isolation applies inside the routed fire with no per-site patching; the launch profile's own fires and the backend's turns keep single-profile semantics; marker and override both reach the pool worker via `copy_context()`. `get_secret` read the raw global in its miss branch; it now goes through `is_multiplex_active()`. The dotenv guard keeps its pinned flag-only form (#77970). Two consequences of suppressing the write are handled rather than left as regressions: - a `no_agent` script's env is `os.environ.copy()`, which no longer carries the routed `.env`; the runner overlays the installed scope onto the base BEFORE sanitizing, so the same scrub / passthrough rules apply to those values and the parent process is never mutated; - plugin secret sources are discovered on the fire's first agent build, after the scope froze, and the post-discovery reload is hydrate-only under multiplex semantics; the refresh now folds the values into the installed scope in place (`refresh_installed_secret_scope`, the pattern `_publish_env_value` already uses for `.env` writes under multiplex). And the profile's external secret sources are hydrated before the scope is frozen, the order gateway/run.py and the external cron worker already use. Tests pin each direction: the marker without the semantics before the scope, the semantics on and off exactly with it, the marker reaching a copy_context worker; the process's own profile staying single-profile; the restart-safe handoff's child env building without raising under a routed tick with a passthrough key registered; a real child process receiving the routed values while `os.environ` keeps the launch value; a source registered after the freeze reaching the fire through the real PluginManager refresh. Reverting any one direction fails a distinct test. (cherry picked from commit 2f87677425d2cca19286ac83bc45cab23e546669) |
||
|
|
73f808e47f |
fix(plugins): only mark a timed-out hook worker abandoned while it still holds its token
The timeout branch of _run_hook_callback_bounded unconditionally added gate_key to _hook_abandoned. A worker that finishes between done.wait() returning False and the caller taking the lock has already popped its token via _release_token, so nothing would ever clear that entry: the callback stayed blocked for every later call id until reload with no thread behind it. Guard the insert on the worker still being registered. The new test makes the race deterministic by swapping the module's threading.Event for one whose wait() lets the worker finish and then reports a timeout, and asserts a fresh call id still runs. Also pass tool_call_id inline from terminal_tool_result instead of the conditional dict plumbing: an empty id is already treated as "no identity" by _hook_call_identity and unknown fields are withheld from narrow-signature callbacks (same shape as _fire_approval_hook). Update the stale "(hook_name, id(cb))" comment above _hook_running_callbacks. |
||
|
|
cdd58810a4 |
fix(plugins): keep one worker per callback while a timed-out worker is still running
Gating hook callbacks by call identity lets two concurrent calls of the same tool both run their hooks, but it also let a fresh tool_call_id pass the gate once the 60 s suppression window lapsed even though the previous worker for that callback never returned. A hung plugin then leaked one daemon thread per minute for the life of the process; on the old coarse-keyed gate it leaked exactly one. Track abandoned-but-running workers per callback: the timeout branch records the gate key, the worker's own release discards it, and the gate treats any non-empty abandoned set as "still running" for that callback. Healthy callbacks keep distinct-id concurrency; hung ones are back to at most one outstanding worker. |
||
|
|
612d542281 |
Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts: # .gitignore # Dockerfile # agent/onboarding.py # apps/desktop/electron/main.ts # apps/desktop/electron/pool-stop.ts # apps/desktop/src/components/model-picker.test.tsx # apps/desktop/src/store/updates.ts # apps/desktop/vite.config.ts # datagen-config-examples/run_browser_tasks.sh # docs/rca-ssl-cacert-post-git-pull.md # gateway/run.py # hermes_cli/backup.py # hermes_cli/credential_lifecycle.py # hermes_cli/dashboard_procs.py # hermes_cli/doctor_state.py # hermes_cli/env_loader.py # hermes_cli/gateway_windows.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/psutil_android.py # hermes_cli/update_cmd.py # hermes_cli/update_cmd_windows.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_server_config.py # hermes_cli/web_server_cron.py # plugins/memory/hindsight/__init__.py # plugins/memory/holographic/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/mem0/__init__.py # plugins/platforms/google_chat/oauth.py # plugins/platforms/photon/adapter.py # scripts/ci/list_os_marked_tests.py # scripts/run_tests.sh # tests/agent/test_compression_stall_fallback.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/gateway/test_google_chat_oauth_dependencies.py # tests/hermes_cli/conftest.py # tests/hermes_cli/test_cli_init.py # tests/hermes_cli/test_gateway_migrate_multiplex.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_relaunch.py # tests/hermes_cli/test_update_check.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_worktree_gc.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_autostash_conflict_recovery.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_commit_pin_rollback.py # tests/scripts/install/test_install_diverged_update.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_macos_launcher.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_ps1_ascii_only.py # tests/scripts/install/test_install_ps1_browser_install.py # tests/scripts/install/test_install_ps1_managed_node_swap.py # tests/scripts/install/test_install_ps1_native_stderr_eap.py # tests/scripts/install/test_install_ps1_node_path_for_npm.py # tests/scripts/install/test_install_ps1_python_fallback_venv.py # tests/scripts/install/test_install_ps1_resolver_strictmode.py # tests/scripts/install/test_install_ps1_uv_install_fallback.py # tests/scripts/install/test_install_ps1_uv_powershell_host.py # tests/scripts/install/test_install_ps1_venv_process_tree.py # tests/scripts/install/test_install_ps1_venv_recreate_safety.py # tests/scripts/install/test_install_ps1_venv_rename_abort.py # tests/scripts/install/test_install_ps1_venv_transaction_boundary.py # tests/scripts/install/test_install_ps1_web_server_syntax_probe.py # tests/scripts/install/test_install_scripts_computer_use.py # tests/scripts/install/test_install_sh_acp_launcher.py # tests/scripts/install/test_install_sh_bootstrap_marker.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_install_method_stamp.py # tests/scripts/install/test_install_sh_node_deps_failure.py # tests/scripts/install/test_install_sh_node_deps_workspaces.py # tests/scripts/install/test_install_sh_node_global_prefix.py # tests/scripts/install/test_install_sh_node_npm_check.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_node_probe.py # tests/scripts/install/test_install_sh_node_tarball_without_xz.py # tests/scripts/install/test_install_sh_pythonpath_sanitization.py # tests/scripts/install/test_install_sh_reuse_supported_python.py # tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py # tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_termux_network_prereqs.py # tests/scripts/install/test_install_sh_termux_python_bounds.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_run_tests_parallel.py # tests/test_managed_runtime_resolution.py # tests/test_project_metadata.py # tests/tools/test_browser_use_cli.py # tests/tools/test_tts_pythonpath_fallback.py # tests/tui_gateway/test_hosted_room_driver_runtime.py # tests/tui_gateway/test_tui_gateway_server.py # tools/lazy_deps.py # tools/voice_mode.py # uv.lock # website/docs/developer-guide/macos-bundle-updates.md # website/docs/developer-guide/pm-audit-status.md # website/docs/developer-guide/shared-bundle-builds.md # website/docs/developer-guide/source-update-completion.md # website/docs/developer-guide/stable-releases.md |
||
|
|
9b6dcad91d |
fix(utils): writers that published through mkstemp on main keep NEW files at 0600
0dfb4234 made every mode-less atomic write follow the process umask for NEW
targets, restoring what open("w")-based writers did. Ten of the folded sites
were not open("w") writers: they created the file through mkstemp and never
chmod'd, so on main a fresh file was 0600 regardless of umask (bot mailboxes,
relay inbox, turn markers, sessions.json, cron jobs/output, banner snapshot,
plugin toolset cache, presets, shell hooks, install id). CI caught the loosening
in tests/tools/test_bot_live_owner_delivery.py (st_mode 0o077 bits set).
Pass mode=0o600 explicitly at those ten sites; the umask default stays for the
sites that were open("w") on main. Invariant test exercises two real writers.
|
||
|
|
3ef8b384a9 |
refactor(persistence): 24 hand-rolled atomic JSON/text writers go through utils.atomic_json_write / atomic_write_text
Each copy re-implemented temp+replace by hand and lacked one or more of fsync, symlink preservation, atomic_replace's Windows-contention retry and EXDEV/bind-mount fallback, mode preservation, or interrupt-safe temp cleanup. Three (gateway/session_persistence, cron/suggestions, agent/shell_hooks) were verbatim inlines of utils._atomic_write; two modules defined their own directory-fsync helper, now utils.fsync_directory. plugins/google_meet/_jsonfile.write_json_atomic is deleted (callers use the canonical helper directly). Behavior change: every one of these writers now fsyncs the payload, keeps a pre-existing target's mode, cleans its temp file on BaseException, and survives Windows AV/indexer contention and cross-device renames the way config writes already did. cron/suggestions.json is 0600 from creation (previously chmod'ed after the replace). Skipped on purpose: cron/jobs.py two-phase staging, gateway/status._write_json_excl (create-only lock), kanban_transfer staging (not atomic writers); tools/skill_usage. _write_suppressed_names lives inside a PLUGIN-COMPAT block. |
||
|
|
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. |
||
|
|
b681f0c50e |
merge: reconcile origin/main with PM runtime ownership
Preserve upstream fixes without restoring retired dependency installers. Run configured-feature checks in the selected build interpreter. Reuse a supported base Python during bootstrap, and preserve durable backup media. Refresh the dependency lock through PM. Keep the frozen historical import surface unchanged. Adapt incoming native tests to the platform markers. Verification: the incoming 86-file pass found two fixture mismatches; both passed after correction. Targeted PM/update/compatibility checks, Electron and renderer typechecks, and desktop tests passed. Native Windows/macOS update journeys and the full suite remain unrun. |
||
|
|
12fe7684e1 |
fix(plugins): async-await helper thread runs under the caller's ContextVars
Under a running loop `resolve_plugin_command_result` awaited the coroutine on a raw thread, so an async hook saw the process-default HERMES_HOME and no secret scope (get_secret -> UnscopedSecretError on a secondary profile). Run the thread body through `contextvars.copy_context().run`, matching the bounded hook worker. Also fixes async plugin slash commands the same way. |
||
|
|
38dfe6df50 | merge: current main into consolidated PM and onboarding | ||
|
|
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. |
||
|
|
b3bfc3afe5 |
Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts: # apps/desktop/electron/backend-connection-state.test.ts # apps/desktop/electron/backend-connection-state.ts # apps/desktop/electron/backend-exit.test.ts # apps/desktop/electron/main.ts # apps/desktop/electron/pool-spawn-coordinator.test.ts # apps/desktop/electron/pool-stop.ts # apps/desktop/electron/preload.ts # apps/desktop/src/app/settings/about-settings.tsx # apps/desktop/src/app/updates-overlay.tsx # apps/desktop/src/global.d.ts # apps/desktop/src/store/notifications.ts # apps/desktop/src/store/updates.ts # gateway/config_loader.py # hermes_cli/banner.py # plugins/platforms/dingtalk/adapter.py # tests/hermes_cli/test_plugins_cmd.py # tests/test_live_system_guard.py # tui_gateway/server.py # website/docs/user-guide/desktop.md |
||
|
|
08830efd96 |
fix(secrets): secret-source re-pull no longer latches an empty snapshot or wipes sibling profiles
Symptom (#102041): under a multiplex gateway the default profile's vault/1Password/
Bitwarden/plugin-sourced credentials vanished for the rest of the process after the first
cron fire or the post-discovery plugin refresh; with the key already in the process env
(systemd EnvironmentFile=) the scope was empty from boot. Every get_secret() read then
failed closed ("No usable credentials", every Telegram sender rejected).
Why: _apply_external_secret_sources marked the home applied after any real fetch, but only
snapshotted names in report.provenance — the NEWLY applied ones. On a re-apply the previous
apply's own write-back makes every key `skipped_existing`, so the snapshot latched to {} and
_hydrate_profile_secret_sources returned that empty snapshot forever. Separately,
reset_secret_source_cache() was process-wide, so one profile's cron re-pull dropped every
sibling's hydrated snapshot (
|
||
|
|
a27cd5902a |
merge: integrate upstream prompt and plugin fixes
Keep the upstream legacy Bot Mode protocol cleanup and BOM-safe reads. Pin integration at
|
||
|
|
684a2cfbd7 | fix(plugins): give dual-kind memory hooks a single owner | ||
|
|
25ce047f98 |
fix(footguns): utf-8-sig on all reads flagged by check-windows-footguns (164 sites)
The 4,272-commit upstream merge brought 164 new utf-8 read violations across 96 files. Class-fixed: reads utf-8-sig, writes unchanged. check-windows-footguns.py --all: 164 -> 0. |
||
|
|
e8fcb007b9 |
Merge remote-tracking branch 'upstream/main' into ethie/pm-clean
# Conflicts: # AGENTS.md # acp_adapter/edit_approval.py # acp_adapter/server.py # agent/agent_init.py # agent/anthropic_adapter.py # agent/anthropic_credentials.py # agent/auxiliary_client.py # agent/azure_identity_adapter.py # agent/bedrock_adapter.py # agent/browser_registry.py # agent/chat_completion_helpers.py # agent/coding_context.py # agent/context_references.py # agent/conversation_loop.py # agent/copilot_acp_client.py # agent/credits_tracker.py # agent/curator.py # agent/curator_backup.py # agent/deadline.py # agent/display.py # agent/errors.py # agent/estop.py # agent/i18n.py # agent/image_gen_registry.py # agent/image_routing.py # agent/learning_graph.py # agent/learning_mutations.py # agent/lsp/servers.py # agent/model_metadata.py # agent/models_dev.py # agent/monitoring/gateway_health_export.py # agent/monitoring/otlp_exporter.py # agent/pet/store.py # agent/process_bootstrap.py # agent/prompt_builder.py # agent/proxy_sources/iron_proxy.py # agent/secret_sources/_cache.py # agent/secret_sources/bitwarden.py # agent/secret_sources/registry.py # agent/shell_hooks.py # agent/skill_bundles.py # agent/skill_commands.py # agent/skill_utils.py # agent/ssl_guard.py # agent/ssl_verify.py # agent/system_prompt.py # agent/terminal_env_registry.py # agent/trace_upload.py # agent/transcription_registry.py # agent/tts_registry.py # agent/verify/environment.py # agent/vertex_adapter.py # agent/video_gen_registry.py # agent/web_search_registry.py # cli.py # cron/jobs.py # cron/scheduler.py # gateway/agent_cache_pressure.py # gateway/cgroup_cleanup.py # gateway/channel_directory.py # gateway/config.py # gateway/control_socket.py # gateway/dead_targets.py # gateway/drain_control.py # gateway/hooks.py # gateway/kanban_watchers.py # gateway/lifecycle_ledger.py # gateway/mirror.py # gateway/pairing.py # gateway/platform_registry.py # gateway/platforms/helpers.py # gateway/platforms/weixin.py # gateway/readiness.py # gateway/restart_loop_guard.py # gateway/rich_sent_store.py # gateway/run.py # gateway/session.py # gateway/shutdown_flush.py # gateway/shutdown_forensics.py # gateway/slash_commands.py # gateway/status.py # gateway/sticker_cache.py # gateway/whatsapp_identity.py # hermes_bootstrap.py # hermes_cli/_early_recovery.py # hermes_cli/_install_repair.py # hermes_cli/_startup_fast.py # hermes_cli/_subprocess_compat.py # hermes_cli/agent_plugins.py # hermes_cli/auth.py # hermes_cli/backup.py # hermes_cli/banner.py # hermes_cli/browser_connect.py # hermes_cli/build_info.py # hermes_cli/cli_agent_setup_mixin.py # hermes_cli/cli_commands_mixin.py # hermes_cli/codex_models.py # hermes_cli/config.py # hermes_cli/config_defaults.py # hermes_cli/config_migrations.py # hermes_cli/container_boot.py # hermes_cli/dashboard_auth/registry.py # hermes_cli/debug.py # hermes_cli/dep_ensure.py # hermes_cli/doctor.py # hermes_cli/doctor_live.py # hermes_cli/dump.py # hermes_cli/env_loader.py # hermes_cli/foreign_sessions.py # hermes_cli/gateway.py # hermes_cli/gateway_windows.py # hermes_cli/gui_uninstall.py # hermes_cli/image_provenance.py # hermes_cli/install_identity.py # hermes_cli/kanban.py # hermes_cli/kanban_db.py # hermes_cli/linux_desktop_entry.py # hermes_cli/local_runtime/binaries.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/local_runtime/growth.py # hermes_cli/local_runtime/supervisor.py # hermes_cli/logs.py # hermes_cli/macos_tcc_anchor.py # hermes_cli/main.py # hermes_cli/memory_setup.py # hermes_cli/model_catalog.py # hermes_cli/models.py # hermes_cli/nous_subscription.py # hermes_cli/npm_engine.py # hermes_cli/plugin_index.py # hermes_cli/plugins.py # hermes_cli/plugins_cmd.py # hermes_cli/profile_distribution.py # hermes_cli/profiles.py # hermes_cli/prompt_size.py # hermes_cli/psutil_android.py # hermes_cli/runtime_repair.py # hermes_cli/security_advisories.py # hermes_cli/security_audit.py # hermes_cli/security_audit_startup.py # hermes_cli/service_manager.py # hermes_cli/session_export_md.py # hermes_cli/setup.py # hermes_cli/skills_hub.py # hermes_cli/slack_cli.py # hermes_cli/status.py # hermes_cli/subcommands/gateway.py # hermes_cli/subcommands/uninstall.py # hermes_cli/tools_config.py # hermes_cli/uninstall.py # hermes_cli/update_cmd.py # hermes_cli/update_contract.py # hermes_cli/update_inventory.py # hermes_cli/update_lock.py # hermes_cli/update_receipt.py # hermes_cli/urllib_security.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_routers/profiles.py # hermes_cli/web_routers/skills.py # hermes_cli/web_server.py # hermes_constants.py # hermes_state.py # plugins/disk-cleanup/__init__.py # plugins/disk-cleanup/disk_cleanup.py # plugins/google_meet/node/registry.py # plugins/google_meet/node/server.py # plugins/google_meet/process_manager.py # plugins/google_meet/realtime/openai_client.py # plugins/hermes-achievements/dashboard/plugin_api.py # plugins/memory/hindsight/__init__.py # plugins/memory/honcho/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/honcho/client.py # plugins/memory/honcho/oauth.py # plugins/memory/honcho/session.py # plugins/memory/mem0/__init__.py # plugins/memory/mem0/_setup.py # plugins/memory/openviking/__init__.py # plugins/memory/retaindb/__init__.py # plugins/memory/supermemory/__init__.py # plugins/platforms/a2a/protocol.py # plugins/platforms/dingtalk/adapter.py # plugins/platforms/discord/adapter.py # plugins/platforms/feishu/adapter.py # plugins/platforms/google_chat/adapter.py # plugins/platforms/matrix/adapter.py # plugins/platforms/photon/adapter.py # plugins/platforms/photon/auth.py # plugins/platforms/photon/cli.py # plugins/platforms/slack/adapter.py # plugins/platforms/teams/adapter.py # plugins/platforms/telegram/adapter.py # plugins/platforms/wecom/callback_adapter.py # plugins/platforms/whatsapp/adapter.py # plugins/teams_pipeline/store.py # plugins/video_gen/fal/__init__.py # plugins/web/ddgs/provider.py # plugins/web/exa/provider.py # plugins/web/firecrawl/provider.py # plugins/web/parallel/provider.py # tests/agent/test_ssl_ca_guard.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_cmd_update_apt.py # tests/hermes_cli/test_dashboard_unified_launch.py # tests/hermes_cli/test_dep_ensure.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_live.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_kanban_boards.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_memory_setup_provider_arg.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_pip_install_detection.py # tests/hermes_cli/test_profile_export_credentials.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_tui_npm_install.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_ui_build.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/tools/test_browser_chromium_autoinstall.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_lightpanda.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_open_timeout.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_suspect_recycle.py # tests/tools/test_find_shell.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_macos_protected_search.py # tests/tui_gateway/test_compute_host.py # tools/approval.py # tools/blueprints.py # tools/bot_mode_dm.py # tools/bot_mode_probe.py # tools/bot_relay.py # tools/browser_tool.py # tools/browser_use_cli.py # tools/checkpoint_manager.py # tools/code_execution_tool.py # tools/code_kernel.py # tools/computer_use/cua_backend.py # tools/cronjob_tools.py # tools/discord_tool.py # tools/environments/base.py # tools/environments/daytona.py # tools/environments/local.py # tools/environments/modal.py # tools/environments/vercel_sandbox.py # tools/fal_common.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/neutts_synth.py # tools/process_registry.py # tools/read_extract.py # tools/registry.py # tools/skill_ledger.py # tools/skill_linter.py # tools/skill_manager_tool.py # tools/skill_usage.py # tools/skills_ast_audit.py # tools/skills_guard.py # tools/skills_hub.py # tools/skills_sync.py # tools/skills_sync_client.py # tools/skills_tool.py # tools/terminal_scope.py # tools/terminal_tool.py # tools/tirith_security.py # tools/transcription_tools.py # tools/tts_tool.py # tools/vision_tools.py # tools/voice_mode.py # tools/wake_word.py # tools/web_result_cache.py # tools/website_policy.py # tools/working_diff.py # tools/write_approval.py # tui_gateway/entry.py # tui_gateway/methods_tools.py # tui_gateway/server.py |
||
|
|
78288b488b |
compat(plugins): discovery itself refreshes the Desktop's report file
Live Desktop E2E (real Electron, worktree backend, demo plugin on old paths) found the modal never fired: compat_report() was only called from the CLI banner / doctor / update / plugins-compat surfaces, none of which run inside the Desktop's `serve` backend, so .plugin-compat-report.json was never written. PluginManager.discover_and_load now refreshes the report from the manifests it just discovered (fail-open). E2E after the fix, all five acceptance steps green on the real seat: report written and names the plugin; native dialog 'Plugins need an update' with plugin, date, and `hermes plugins compat`; OK persists the dismissal; relaunch with the same userData shows nothing and logs no 'compat notice shown'; fixing the plugin's imports deletes the report and shows nothing. |
||
|
|
642579db60 |
Merge remote-tracking branch 'upstream/main' into ethie/pm-clean
# Conflicts: # .github/actions/detect-changes/action.yml # .github/workflows/ci.yaml # .github/workflows/tests-os.yml # agent/prompt_builder.py # agent/ssl_verify.py # agent/subdirectory_hints.py # apps/desktop/electron/main.ts # apps/desktop/electron/preload.ts # apps/desktop/src/app/settings/about-settings.tsx # apps/desktop/src/global.d.ts # apps/desktop/src/i18n/ar.ts # apps/desktop/src/store/updates.ts # cron/suggestions.py # gateway/channel_directory.py # hermes_cli/config.py # hermes_cli/doctor.py # hermes_cli/linux_desktop_entry.py # hermes_cli/main.py # hermes_cli/web_routers/profiles.py # hermes_constants.py # plugins/platforms/photon/adapter.py # scripts/ci/classify_changes.py # scripts/install.ps1 # tests/agent/test_relay_runtime_plugins.py # tests/ci/test_classify_changes.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_linux_desktop_entry.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/state/test_fts_runtime_rebuild.py # tests/tools/test_lazy_deps.py # tests/tools/test_macos_protected_search.py # tools/browser_tool.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/working_diff.py # uv.lock |