74e59cd3401093a30615aa8e71d862c47fa525cf
111 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
71704ffb49 |
refactor: move Spotify out of core into the spotify catalog plugin
The spotify toolset (plugins/spotify) and its loopback-PKCE login (hermes_cli/auth_spotify.py)
now live in NousResearch/hermes-spotify, installed from the plugin catalog. Same pattern as the
Home Assistant move (
|
||
|
|
1598b9007c |
onboarding: the account's full name reaches the model only when the user picks it
The name was in the /initiate-setup facts block and in the scripted name card's saved arguments, so it left the machine even when the user typed another name or skipped the card. facts() drops it, and setup_choose adds the suggested row itself after the model's call is saved, the way it fills the tour and fork rows. The result's label carries the name when the user picks it. |
||
|
|
8b89b68fb4 |
Merge origin/main into sid/onboarding
Brings the onboarding branch up to main |
||
|
|
6fec745d45 |
fix(tour): gui_tour rejects preset with steps, and the docs describe the built-in tour (review)
tools-reference.md: gui_tour row and Tours section describe bare start, preset quick|full, and custom tours. tour_tool.py: preset together with steps is an error instead of being ignored. test_tour_tool.py: through the registered handler, an invalid preset and preset+steps are rejected and a valid preset reaches the request. |
||
|
|
c2b13dc3d8 |
feat(desktop): every desktop chat can install catalog plugins; tools gated by session platform
Toolsets that exist only for one kind of session now say so on their spec:
`platforms=frozenset({"desktop"})`. `TOOLSET_SESSION_PLATFORMS` is derived
from the specs that carry the key, `all` / `*` / no selection skip those
toolsets, and `toolsets.agent_tool_drops(agent)` removes their tools from any
agent whose own session platform is not listed. `agent_init._load_tools` and
`tools/mcp_tool_agent.py` (MCP refresh and prefix restore) call that one
helper. `tool_search._DIRECT_SURFACE_TOOLSETS` is derived from
`CLIENT_SURFACE_TOOLSETS` and `TOOLSET_SESSION_PLATFORMS`.
This replaces the profile-role gate: `profile_role_toolsets`, the role code in
`_with_session_toolsets` and the explicit-pin branch, the strip step in
`_select_tool_names`, and main's `setup` toolset (whose only tool moves out)
are deleted. `manage_catalog` moves into a new `catalog` toolset, which the
desktop client folds into every desktop chat like `project` and `desktop_ui`.
It is deferred behind tool_search by default (`config_defaults` defer list).
The shared-metrics schemas' toolset enum follows (`setup` out, `catalog` in).
The install card works outside the setup chat (#129650, with review fixes):
- Parallel installs: `_set_plugin_enabled` holds a per-home lock around the
read-then-admit section, so rows enabled at once no longer refuse each other.
- A catalog plugin already on disk and not enabled is enabled in place, so Try
again after a refused enable can finish.
- A failed catalog row counts as resolved (`contract.resolves`), so it no longer
holds the turn to the 300 s deadline. The snapshot carries `resolved`, and the
card reads it instead of a hand copy of the backend's rule.
- Each card draws its own operation by op id, because models reuse call ids.
- The row carries the approved non-secret choices (`approved`: force, enable,
ref) and the chosen profile, so Try again after the card settled repeats them.
- Install phases are ids (`InstallPhase`, wire `CatalogInstallPhase`) and the
installed-row notes are fields (`enabled`, `missing_env`, `server_errors`,
`already_installed`, `requires_hermes`); the card words them in English
strings (translations follow later). Failure reasons stay raw text, and
`detail` keeps the English notes for the model.
- Installs and search target the calling chat's own home, carried as a path;
the row's profile name is only a label, with no `'default'` fallback in
Python or TypeScript.
- `dashboard_install_plugin` is split into `_resolve_source`, `_place_tree` and
`_enable_placed`.
Desktop English strings for the card move to `en_catalog_install.ts` /
`types_catalog_install.ts`, which offsets the growth of `en.ts` and `types.ts`.
|
||
|
|
2b3afee3fe |
fix(tools): a task's own patch keeps its full-file write baseline
A deployment reported that write_file -> read_file (full) -> patch -> write_file refuses the last write with stale_write_blocked and "exists but this task has not seen its full current content", in 50 of 50 trials. No other writer took part. When the model does not reread before the turn ends, the refusal surfaces as the "File-mutation verifier: N file edit(s) FAILED" footer. Mechanism. The write_file baseline is an exact file version: (st_dev, st_ino, st_size, st_mtime_ns, st_ctime_ns, sha256) (tools/file_tools_read_tracking.py _file_version, _mark_full_write_baseline, _has_full_write_baseline). write_file_tool marks a fresh baseline from the bytes it wrote. patch_tool only ran _note_edited and _reset_patch_failures, so the task's own patch changed the version and left the baseline it held stale. _stale_overwrite_blocker then treated the task like an external writer. Invariant: after a task's own successful patch, the task may replace the file whole exactly when it held a current full baseline for the bytes the patch read and the bytes the patch wrote are what is on disk. Lineage. |
||
|
|
3724ace0c8 |
Merge remote-tracking branch 'origin/main' into feat/store-guest-onboarding
# Conflicts: # apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.ts # apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/types.ts # apps/desktop/src/app/session/hooks/use-message-stream/index.ts |
||
|
|
0f7a95f16d | docs: Home Assistant moves to the plugin catalog | ||
|
|
4efdfb6d84 |
Merge remote-tracking branch 'origin/onboarding/next' into feat/store-guest-onboarding
# Conflicts: # apps/desktop/electron/window-focus-policy.ts # apps/desktop/src/components/assistant-ui/inline-preview-directive.tsx # apps/desktop/src/lib/chat-messages/parts.ts # apps/desktop/src/plugins/hermes-bots/types.ts # apps/desktop/src/sdk/index.ts # hermes_cli/plugins_cmd_install.py # hermes_cli/profile_distribution.py # plugins/web/keyless_mcp.py # tui_gateway/methods_profiles.py # tui_gateway/server.py # website/docs/developer-guide/relay-shared-metrics.md # website/docs/user-guide/features/web-search.md |
||
|
|
7e53b3ef82 | chore(memory): remove bundled honcho provider; install from the plugin catalog | ||
|
|
5b345904ab |
docs: escape <machine> so the docs site builds
MDX reads a bare <machine> as an unclosed JSX tag. #129646 left two of them in prose (onboarding-recommendations.md, the setup_choose row in tools-reference.md), and the Docusaurus build fails on onboarding/next. Write them as <machine>, the form the generated skill pages use. |
||
|
|
a2065efd51 |
fix(setup): each setup card answers only its own call (#130062)
One Skip broke a tester's whole onboarding. The provider named every tool call "call_0", and the desktop bound each setup card to its request by call id alone. After the apps card was skipped, every later card with "call_0" showed that old "Skipped" answer instead of its picker. Composer text was also saved as a pick, so "hey" became a plugin and a complaint became the layout. - The agent renames a tool call whose id is already used earlier in the session (call_0 -> call_0_d2), before the call is stored or run. Ids already in history never change, so the prompt cache holds. - The card's request binding is keyed by session, message and call id. - Composer text on a card with fixed rows counts as a pick only when it names a row by id or label. Otherwise setup_choose returns outcome "typed" with the words in `said`, records no card facts, and tells the model to reply and send the card again. The settled card shows the typed words. |
||
|
|
ff4760bfbd |
The first task after setup connects the picks and finishes something small within minutes (#129646)
* feat(onboarding): the first task chat delivers a small result in minutes Live runs on an RTX Spark showed the task chat surveying the whole machine, asking a plan question, and installing the picked plugins 11.5 minutes after setup. Its suggestions ignored the picks and the scan, and the handoff text said "Call me Sid." and "Start from this; look at my computer again...". - New optional skill first-task. start_chat appends it to the task chat's first message, after the ask and the "What setup learned" block, with a JSON block of the connector and plugin ids picked in setup. No system prompt changes. The history projection cuts the message at its marker, so no surface shows the skill. Its rules: connect and install the picks first, work visible in 1 minute and a finished result in 5, big asks cut to a first slice, three options only for a vague ask, machine setup as a short card interview. - setup_choose records the picked ids and the fork pick. The fork card is filled when shown: two first tasks from the picks and the scan (a daily brief from the picked apps, "Install a few apps for this Spark", a small page), then "I have something in mind", "Help me set up this <machine>", "Let's figure it out together". The machine-first "Something else" step, "automate" and "skip" rows are gone; "figure" hands off at once. - The handoff template is the ask only; no "Call me", no install or plan paragraphs. start_chat drops the "Start from this" line. - initiate-setup SKILL.md keeps only the setup beats (20.5 KB -> 10 KB). - Desktop shows the "What setup learned" block folded under the first message; the model still gets the full text. * fix(setup): one unexpected shutdown reads as singular |
||
|
|
59cbf85cca |
Setup cards name the pick, skip an empty apps or plugins list, and wait for the intro (#129383)
* fix: setup cards tell the model the name the user saw, and an empty apps or plugins list shows no card
The accent card showed "Ultraviolet", but the result only carried "#8a2be2", so
the model guessed a CSS name ("BlueViolet, done."). The card now answers with the
label of each picked row next to its id, the backend passes it through for the
app-owned lists it cannot name itself (accent, theme, layout, connectors,
plugins), and the tool description tells the model to say the label.
A session with no Nous identity (own key, guest sign-up rate limited at boot)
got an apps card that said "This list isn't available right now". Now
setup_choose kind:"connectors" checks connectors_available() in the setup
session's own profile scope before any card: with no identity and the free tier
on, it makes one guest sign-up attempt through ensure_portal_identity (the mint
memo's cooldown still applies), and if connectors are still closed it returns
no_answer with a one-line notice and the next card, without showing a card. An
empty plugins list returns the same way. The skill says one line about the free
Nous account and moves on.
The renderer re-reads a cached "unavailable" connector catalog when the card
mounts, so a guest minted by the card is not hidden behind the prefetch taken at
the start of setup.
* fix: the setup intro holds the thread until its spring settles
The backend prelude writes the first assistant row and the name card while the
intro copy is still typing. The copy's cover uses the chat surface colour, which
is transparent under glass, so the card showed through mid-motion. While the copy
plays ($introHoldsThread, true only for the 'playing' stage), the thread under it
is hidden; it shows when the spring settles, and at once with reduced motion
(the stage goes straight to 'landed'). The backend prelude is unchanged: its
tool call waits for the answer.
|
||
|
|
fe50fc8b60 |
Setup cards: tour and fork always arrive with their rows, apps preselected from the scan, honest skill (#129344)
* feat(setup): the backend fills the tour and fork cards and preselects scanned apps The welcome model fills only the required parameters of setup_choose, so cards that needed options arrived as bare text boxes in play 4. options is now required ([] means the app's list, or free text for a question). Two new kinds, tour and fork, take their rows from the backend whatever the model sends. The fork rows come from the facts the /initiate-setup turn embedded, recorded next to the setup state; "Something else" on a machine-first fork opens the rest in the same call. The connectors and plugins cards start with the apps the scan saw in use, and Blender when it is here, already picked. The result names a picked row (label), so the settled card shows labels for rows that are not in the call's args. * fix(setup): the setup skill answers honestly about the scan and always reaches a handoff Card lines carry options and use the app-owned tour and fork kinds. When asked what Hermes knows, the model answers from the facts and names the scan instead of denying it. A skipped fork goes to a first-task card built from the scan, a second skip offers one concrete task, and a card left for an off-flow question is re-sent. Dead fork-building and fallback text is cut, which keeps the skill under 25 KB. * fix(setup): card facts per setup conversation; the backend steers each beat and carries the handoff The saved card facts were one file per setup profile, so a second /initiate-setup overwrote the first conversation's fork rows. They are now keyed by the session id under the bound Hermes home, and setup_choose reads its own session's block. A fork card with no recorded facts (skill loaded through the inline-shell hook) collects them fresh. setup_choose now returns `next` for the flow's following step (the next card's exact arguments, the skip ladder from the fork on, a re-send of the pending card after typed off-flow text) and, from the fork, `handoff`: the message parts and the plan, cut to this machine, from templates/handoff.md. That moves about 6 KB of handoff and plan text out of the first turn. Rows the model sends for tour, fork and the new machine_use kind are dropped before validation, so a malformed copy can no longer fail the card. The connectors and plugins cards are always multi-select, so preselected rows are never lost to a single-select confirm. The off-desktop voice rule is back in the skill. * docs: regenerate the initiate-setup skill page * feat(initiate-setup): one "I noticed" line before the fork shapes the first tasks |
||
|
|
f9102e4ca4 |
Setup scan stays off backend globals; audit lint and RPC fixes (#129341)
* fix: userscan tmux and zellij socket probes pass the /tmp literal check The four lines read where tmux and zellij put their sockets (and, on macOS, measure the user's own temp dir). The POSIX fallback for those directories is /tmp, so the literal is the fact being probed, not a scratch path. Mark each line so the blocking lint step in lint.yml passes. * fix: setup-profile and start_chat RPCs no longer block the RPC reader onboarding.ensure_setup_profile / ensure_setup_session / reset_setup_profile can run create_profile (a skill-tree copy), state.db writes and the first import of the setup scanner. session.start_chat creates a session and runs prompt.submit. Inline on the reader thread they left session.interrupt and approval.respond unread in the pipe, the failure the _LONG_HANDLERS comment names. profiles.create was already on the list for the same reason. * fix: userscan children run with stdin closed and the served profile's env The /initiate-setup builder runs the user scan on a backend thread, so every child the scan spawned inherited the JSON-RPC stdin pipe (#14036) and the launch profile's os.environ. HostAccess.run, copy_locked and the PowerShell batch now pass stdin=subprocess.DEVNULL, and every spawn takes its env from HostAccess.child_env(). host_facts passes served_profile_child_env() into the run; the standalone run.py keeps the process env. check_subprocess_stdin.py now scans optional-skills/: the backend loads initiate-setup's scripts in-process. The nested-dir skip matches paths relative to the context dir, so a skill's own scripts/ folder is in scope. Three model-run optional-skill scripts it flagged get stdin closed. * refactor: split the userscan spec modules over 2,000 lines by topic Six spec modules under initiate-setup's userscan passed the 2,000-line gate. Each keeps its shared helpers and first families; the rest move verbatim into <stem>_<topic>.py siblings that import the helpers they use from the base module: apps_dev -> apps_dev_agents, apps_dev_devenv browser_files -> browser_files_comms, browser_files_content usage_gaming -> usage_gaming_games, usage_gaming_hardware linux_system -> linux_system_security, linux_system_hardware linux_apps -> linux_apps_dev, linux_apps_browser, linux_apps_files darwin_apps -> darwin_apps_dev, darwin_apps_browser, darwin_apps_files linux_system keeps health and usage: install_age reads usage's _wtmp and usage reads health's _kdump_dirs. References through the ad./bf./ug. aliases point at the module that now defines the name. specs/__init__ imports each sibling right after its base, so per-OS registration order and the registry keys stay the same. No behaviour change: `run.py --list` is byte-identical before and after, and a temp-home scan under --os darwin/linux/windows gives the same fact statuses, errors and insights. * fix: the setup scan never patches process globals or sys.path in the backend The /initiate-setup builder runs the user scan on a backend thread. It put the scanner dir at sys.path[0] of the backend (so a top-level `run` or `userscan` import anywhere resolved to the scanner), and it loaded host.py's deny_reads_under (swaps builtins.open, io.open, sqlite3.connect and subprocess.Popen) and override_env (rewrites os.environ HOME and HERMES_HOME) into the backend's import graph. - userscan/cli.py now holds override_env, the read guard, the aggregate HostAccess and scan_accounts. Only run.py imports it; runner.run() no longer retargets os.environ (overrides stay l0 data), and run.py applies override_env around run() for --home and friends. - The package uses relative imports, and host_facts loads it once per process as initiate_setup_userscan, without touching sys.path. Live: after an in-process scan sys.path, os.environ and the four patched callables are unchanged and no top-level userscan module exists (before: sys.path[0] was the scanner dir and userscan.host was importable). `run.py --list` is unchanged; --all-users and --home give the same output. * refactor: userscan runner runs each layer as a named phase runner._run was one closure-heavy function at cyclomatic complexity 66. Its state moves onto a small _Pass object whose run_l1 / run_l2 (with _run_python_l2) / run_l3 methods each hold one layer, moved verbatim; _run composes them and builds the output. Highest CC in the file is now 21 (run_l3). No behaviour change: temp-home scans under --os darwin/linux/windows give the same fact ids, statuses, methods, tiers, coverage, insights and output keys as before. * chore: name the setup intro flag writer, document gui_tour presets, trim media.ts - hermes_cli/setup_profile.py::mark_seen becomes mark_intro_seen. It writes the setup profile's intro state; agent/onboarding.py::mark_seen flips a config.yaml hint flag, and tui_gateway uses both. The RPC name onboarding.mark_seen is unchanged. - tools-reference: gui_tour's `start` without `steps` runs the built-in tour (`preset` quick or full, default full). - apps/desktop/src/lib/media.ts: drop the blank line at EOF (git diff --check). * docs: describe the setup chat's automatic machine scan The scan runs without a consent step when the desktop app creates the setup profile (the owner kept it automatic). The desktop user guide now says where it runs (the backend's machine), what it reads (T1: counts and categories; finer values are dropped before storage), what reaches the model (the derived fact block in the first setup message, sent to the setup chat's inference provider) and where the cache lives (insights/profile.json in the setup profile's home, 0600, 24 h). The skill's own docs page is generated from SKILL.md, which is the model's prompt, so the user-facing text lives in the desktop guide. * fix: overlapping setup-profile RPCs no longer race on the RPC pool ensure_setup_profile, ensure_setup_session and reset_setup_profile moved off the reader thread, so two kickoffs could overlap: both saw no setup profile, and the losers failed on the half-made profile (5 of 6 barrier-started calls returned ENOENT), or both saw an empty setup chat and seeded it twice. One module lock restores the one-at-a-time order; the prescan stays outside it. * fix: the Linux locale probe reads date formats in a child, not via setlocale locale.user ran setlocale(LC_TIME) on the scan thread inside the backend. setlocale is process-wide and not thread-safe, so strftime in other session threads briefly used the user's LC_TIME. The probe now runs 'locale d_fmt t_fmt' under LC_ALL=<locale> through HostAccess.run (new env overlay on child_env), after checking 'locale -a', because locale prints C formats for a locale that is not installed. * docs: link the setup scan from the /initiate-setup and onboarding references The desktop guide's scan section now says which search-engine names are kept and that the scan runs in the CLI and the gateway too. The /initiate-setup row and the profile_build bullet link to it. |
||
|
|
8feae75347 |
Apps and plugins: drop setup_choose intent, manage_catalog out of setup, cards set up later (#129245)
* fix(setup): manage_catalog leaves the setup chat; installs go to the caller's profile
- The setup profile disables the catalog toolset. The desktop session
folded it in as a client-surface toolset, so manage_catalog was still on
the setup chat's eager tool list.
- Delete default_target_profile: a catalog install and search target the
calling chat's own profile, the setup profile included.
- /initiate-setup still needs "the launch profile when run from the setup
home"; that moves to hermes_cli.setup_profile.primary_profile().
- The manage_catalog description and the reference docs drop the setup
chat exception.
* feat(setup): drop setup_choose intent; apps and plugins cards say they set up later
- setup_choose loses `intent` (the now / when needed / just note it choice)
in the tool, the wire contract and the cards. The result is
{outcome, picked}. Contracts regenerated.
- The apps and plugins pickers carry one footnote, "We'll set these up
when you start.", replacing the plugins-only "Nothing connects or
installs yet" note. Other locales translated; the desktop key list is
regenerated (it was also behind en.ts on appTour and sharedMetrics).
* fix(desktop): an install card stays drawn when a connect card follows it
The renderer keeps one connection operation per session. When a turn asks
twice (a manage_catalog install card, then a manage_connections connect
card), the second operation replaced the first in the store, and the
settled install card fell back to a plain tool row.
A settled operation that the next one replaces now moves to a store keyed
by its tool call, and the catalog and connector cards read their own row's
operation from there. Both cards stay stacked in the turn.
* fix(setup): setup_choose inline calls stop passing intent; /initiate-setup reads the launch home at call time
The inline executor still mapped intent into setup_choose_tool, which no
longer takes it, so every desktop setup_choose call raised TypeError.
primary_profile now gets server._launch_home() instead of the import-time
_hermes_home.
* fix(desktop): keep one card per kind: the latest install card and the latest connect card
The replaced-operation cache kept every settled operation by tool call, so
consecutive manage_connections calls drew one connect card each. It now
keeps, per session, only the settled operation of the other kind (install
vs connect) that the current one replaced. An install card and a connect
card stay stacked in either order, and a later call of the same kind still
takes over its card. clearConnectionRequest clears both caches, and the MCP
setup card reads the same per-row selector, so sessionConnectionRequest is
gone.
|
||
|
|
b3411f4d8c |
feat(start_chat): a handoff from the setup profile marks onboarding complete
When start_chat runs from the marked setup profile's chat and opens the new chat in another profile, the backend now writes completed_at and intro: seen into the setup profile's onboarding marker. The write goes through a new hermes_cli.setup_profile.mark_completed(), next to mark_seen(), so onboarding.state reports completed_at unchanged. Calls from any other profile write nothing. A call from the setup profile into itself writes nothing. Rejected calls write nothing. A reset of the setup profile still writes the fresh state, so it clears completed_at. The tools reference row for start_chat states the new effect. Linear: NS-1018 |
||
|
|
b321a7be69 |
feat(desktop): setup_choose question cards and the start_chat handoff card
The setup bot's two tools now render as native cards in the desktop chat.
setup_choose goes through the clarify card path instead of a second card:
the renderer answers the setup_choose server request, parks it in the
clarify store (so the needs-input dot, the notification, the composer's
type-to-answer skip and resume restore all cover it), and draws it with
the clarify card's shell, key handling and confirm bar. Kind question
uses the clarify question block. Accent, theme and layout reuse today's
onboarding swatches, the settings mode labels and the layout preview
cards, and apply the pick live. Connectors (read on the account owner,
since the setup profile has no manage_connections) and plugins use the
onboarding chips, with a now / later / save control per picked row when
intent is on. The answer returns {picked, intent} on P15's wire; Skip
returns {} (cancelled).
start_chat shows the new chat's title and profile with an Open button.
A live completion moves the user into the new chat only when the calling
chat is in the foreground; history and background completions never
move them. start_chat joins the backend lifecycle UI set so the card
shows with tool progress off.
Open decisions for review: accent and theme picks apply live only (the
accent override is not persisted, and the theme mode lands on the setup
profile); the theme card offers light, dark and system.
|
||
|
|
14dbaec0ab |
fix(catalog): an install from the setup chat targets the launch profile
Sid's decision: the setup chat installs for the profile the user will work in, where start_chat sends the task, not into the setup profile. - default_target_profile() is the chat's own profile, except when that home carries the setup-profile marker; then it is the launch profile (server._hermes_home). Every other chat keeps the chat's own profile. - prepare() and search() use it, so the card row, the install and search's installed_in agree. The card still shows the target, and Advanced still picks another profile. - The manage_catalog description and the reference docs say where the setup chat installs. |
||
|
|
b5eb5fd5c4 |
feat(catalog): manage_catalog is a desktop catalog toolset that installs into the chat's profile
manage_catalog moves out of the setup toolset into a new `catalog` toolset. Desktop sessions get it the same way they get project and desktop_ui: the client surface folds it in, TOOLSET_SESSION_PLATFORMS drops it everywhere else, and `all` does not include it. It stays a direct-surface toolset, so the default tools.tool_search.defer list (which now names manage_catalog) decides deferral and a user defer list takes control. Installs and the search's installed flag now default to the profile of the chat that calls the tool, not to `default`. The Advanced modal can still name another profile. The setup toolset stays, empty, for setup_choose. The metrics schema toolset enum gains `catalog`, and the toolsets, tools and tool-search docs follow. |
||
|
|
e0e0505989 |
feat(start_chat): any desktop agent can start a visible chat through a native tool
The setup chat handed off to a new chat through about 600 lines of renderer code that always built into the default profile. This adds start_chat, a backend tool that does the same job for any desktop agent. - tools/start_chat_tool.py registers start_chat (message; optional title of at most 40 characters; optional existing profile, default the caller's own). The handler late-imports tui_gateway.start_chat. - tui_gateway/start_chat.py finds the calling session from HERMES_UI_SESSION_ID, rejects an empty message, a long title or an unknown profile, and never creates a profile. It creates the session with the caller's source and transport, inside the target profile's runtime scope, so the caller's display config does not leak into the new chat. It latches onboarding.seen.profile_build_offered in the target profile and submits the first message through prompt.submit. The call is not idempotent and keeps no receipt. - The start_chat toolset is desktop sessions only (keyed on the session platform), not in "all", not in `hermes tools`, stays direct instead of deferring behind tool_search, and is blocked for subagents. - Docs: toolsets and tools reference rows, and the subagent block list. |
||
|
|
16bec86daa |
feat(setup): setup_choose asks one question or picker card in the setup chat
The setup profile needs one tool to ask the user a question or show a
picker (accent, theme, layout, connectors, plugins) as a card. Today the
runbook emits ::onboarding and ::ask directives that the app parses out
of the model's text.
setup_choose joins the setup toolset, so it is desktop-only and direct
(never deferred unless a defer list names it). It validates kind,
question and up to 12 {id, label, detail?} options, and passes
options=null through so the app can show its own list. It reaches the
renderer as a setup_choose server request, bridged in
tui_gateway/agent_callbacks.py with the clarify wait timeout. The result
reports submitted (picked, plus intent now/later/save per row for
connectors and plugins), cancelled, or no_answer. It stores nothing.
The wire contract is in tui_gateway/contracts/server_requests.py and the
apps/shared contract files are regenerated. The desktop card and fixed
lists come later. The ::onboarding/::ask directives stay for now.
|
||
|
|
7182a214bb |
fix(setup): gate the setup toolset per session platform on every surface
Review follow-ups to the role deletion. - The setup toolset reached any surface that read platform_toolsets.cli (classic CLI, hermes -z, kanban workers), and "all" / "*" / a None selection pulled manage_catalog into every profile. Now "all" skips toolsets in TOOLSET_SESSION_PLATFORMS, so they arrive only when named, and agent tool loading drops their tools unless the agent's own platform is listed (toolsets.session_platform_tool_drops, applied in _load_tools and in the MCP refresh / pin restore paths). The name filter in tui_gateway's _with_session_toolsets is gone: one gate. - The focus coding posture replaced the configured list, so the guide in a code workspace lost the setup toolset. The posture path now carries the config's session-gated toolsets. - ensure_setup_profile deletes the new profile if a step after create_profile fails, so a retry does not leave unmarked orphans. - Desktop handoff recovery waits until the active session is bound to the selected guide chat, so it records the guide's profile and runtime id after the gateway swap, not the previous profile's. - Delete the renamed catalog test (lane rule) and correct the reference docs. Linear NS-1014. |
||
|
|
c3f85bc54f |
refactor(setup): delete profile role; the setup profile is a marked, plain profile
The setup toolset was reserved for `role: setup` in profile.yaml, resolved by toolsets.profile_role_toolsets, and stripped from every other profile in model_tools._select_tool_names. Onboarding also wrote that role into a user's existing unmarked hermes-setup profile and adopted it, and cloned the literal default profile. Now there is no role anywhere. The setup profile carries a dot-file marker (.setup-profile.json) at its root, and onboarding finds it by that marker. A user's own unmarked hermes-setup is never touched: onboarding makes hermes-setup-<word> instead. The new profile is cloned from the launch profile, and its own config enables the setup toolset in platform_toolsets.cli. The session resolver keeps the toolset only for desktop sessions (toolsets.TOOLSET_SESSION_PLATFORMS). Copies (clone-all, import, distribution install) drop the marker, as they dropped the role. The role field also leaves ProfileInfo, the profiles.list and dashboard rows, the onboarding.ensure_setup_profile result, the gateway contracts, and the desktop ProfileInfo type. The desktop handoff recovery check no longer gates on the roster role; the guide handoff receipt keyed by the stored session already identifies the guide session. Linear NS-1014. |
||
|
|
5eea87882a |
Clarify: one question shape and one result shape on every surface (skip, cancel, timeout and undelivered are told apart) (#127760)
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder
Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.
* feat(clarify): one questions[] shape with per-question status and one outcome
The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.
Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.
The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.
* feat(clarify): every surface asks through the one question card
CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.
Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.
Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.
Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.
* fix(clarify): cancelled for a released messaging card, labels win over the skip word
A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.
A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.
The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.
* fix(tui): re-editing a multi-select answer keeps its picks
The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.
Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.
* test(clarify): align three checks with the one-answer confirm and the regenerated keys
The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.
* test(tui): the one-question card header is singular
|
||
|
|
8cb4fdc925 |
fix(process): heartbeats wake the agent only on new output, and never as a user bubble
A `terminal(background=true, heartbeat=N)` tick queued a notification every N seconds
whether or not the process had printed anything, and every queued event costs the owning
session a full model turn. On Desktop and the TUI that turn painted the wake as a user
bubble ("[Background process ... heartbeat #9 ... (no new output since the last
heartbeat)]") followed by the model's "Still running normally." — over and over, for a
process whose row on the status stack already said it was running — and while the wake
held the session's turn, the user's own prompt sat queued behind it.
- `ProcessRegistry._emit_heartbeat` skips a tick with no new output. The sequence counts
delivered beats only; the "(no new output)" placeholder in the formatter is gone.
- TUI/Desktop type heartbeat rows `display_kind: hidden` (the kind both clients and the
transcript preview already honour); the CLI paints a one-line receipt and persists the
row hidden, so reopening the session in Desktop shows only the agent's reply.
- Desktop hydration drops heartbeat rows persisted by older backends the same way.
- `display.background_process_notifications: off` is honored by the TUI/Desktop poller and
the CLI drain, not just the messaging gateway. `off` mutes process-driven wakes only:
a finished `delegate_task(background=true)` still lands.
Supersedes #123123 (cherry-picked; scoped so `off` keeps subagent results) and #119202
(cherry-picked; `heartbeat: 0` is schema-valid so models that materialize every field
stop tripping the foreground guard).
|
||
|
|
bb3011a202 |
fix(video_gen): resolve OpenRouter and DeepInfra credentials per profile, not from os.environ
The OpenRouter video backend read OPENROUTER_API_KEY and OPENROUTER_BASE_URL straight from os.environ. That broke two setups: - A key added with `hermes auth add openrouter` (API key or OAuth) lives in the credential pool, not the environment. Chat and image_gen/openrouter find it through resolve_runtime_provider. video_gen reported OpenRouter unavailable, and generate() returned missing_credentials. - On a multiplexed gateway, os.environ holds the launch profile's .env. A routed profile's video jobs were submitted, polled and downloaded with the launch profile's key and billed to that account. A profile whose key lived only in its own .env could not use the backend at all. The backend now resolves (api_key, base_url) with resolve_runtime_provider(requested="openrouter"), the same call image_gen/openrouter makes. generate() resolves once and passes the pair to submit, poll and download. With a round-robin pool, resolving per request would poll with a different account's key than the one that created the job. OpenAICompatibleVideoGenProvider, which the DeepInfra video backend uses, had the same raw reads of <NAME>_API_KEY and <NAME>_BASE_URL. Both now go through get_secret_str, as image_gen/deepinfra already does. |
||
|
|
550d74c62f |
fix: manage_catalog documented under its own setup toolset; post-hook ownership contract exercises it
tools-reference.md listed manage_catalog under the connections section, which the reference-docs contract resolves against the connections toolset. The agent-runtime post-hook ownership test enumerates every tool in AGENT_RUNTIME_POST_HOOK_TOOL_NAMES; manage_catalog now runs through both executor paths there. |
||
|
|
31e59b9441 |
feat: setup agent can search the catalog and install plugins and skills through the approval card
The setup profile's `setup` toolset was empty. It now carries one tool, manage_catalog: - search: catalog plugins (the Plugins tab's live catalog resolver) and hub skills, with whether each is already installed in the default profile. Read-only. - install: opens the same connection operation manage_connections opens, with rows of kind plugin / skill. Nothing installs until the user approves a row. An approved row installs into `default` (or the profile the Advanced modal named) through dashboard_install_plugin / the hub's headless install, so the catalog pin, kill list, security scan and live activation (#119644) are the host's. The row settles with the live MCP tool names and the plugin's skill. The model sends catalog ids and an action only; every other key is refused before anything runs. An unknown id or a plugin this OS cannot run is drawn failed with the installer's own text. Anywhere a catalog card cannot be drawn (TUI, CLI, messaging, registry dispatch) the result is the `hermes plugins install` / `hermes skills install` pointer. - contract: plugin/skill targets follow the MCP transitions. - run.apply_answer / reissue route a card answer to the module that owns the operation. - tool_search: `setup` joins the direct-surface toolsets, so the guide's one tool is never deferred behind tool_search. - docs: tools reference, toolsets reference, plugin catalog page. Linear NS-964. |
||
|
|
e636aedebf | fix: preserve known file baselines across partial rereads | ||
|
|
65b0ff38ea |
feat(terminal): heartbeat notifications for long background processes
`terminal(background=true, heartbeat=N)` emits a `heartbeat` event every N seconds (floor 60) carrying only the output produced since the previous one, plus the usual completion notice. The agent stays current on a long bounded job (merge train, full test suite, deploy) and reacts to a failure within N seconds instead of at exit. Why: `notify=true` fires once at exit, so multi-hour merge trains ran silently — the parent's prompt cache went cold and every "what's up" cost 5–10 rediscovery calls; 30 days of batch sessions show 4,930 hand status polls (14% of all tool calls). `notify=[pattern]` cannot serve this: its lifetime cap (8) exists precisely to stop periodic output from flooding the agent. Mechanism: one daemon timer thread for every heartbeat session (reader threads block on the pipe and cannot keep time); delta output via a total-ingested counter over the rolling buffer; heartbeats only where the completion notice can be delivered (same async-support/subagent gates), never after exit. `heartbeat_seconds` is checkpointed. Rendered on CLI, gateway and TUI through the existing process-notification paths. |
||
|
|
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 ( |
||
|
|
6ae4cb88d5 |
fix(docs): reference pages name tools the registry never registered
The shipped tool-surface references still document the pre-consolidation surface: tools-reference.md lists cronjob/todo/process/project_create/ project_list/project_switch/open_preview/close_preview/read_preview/tour/tip (6 uncallable, 5 hidden dispatch-only aliases), and toolsets-reference.md still claims web_search is a member of the browser toolset — membership |
||
|
|
19b29df13b |
fix: video generation tools no longer let the agent pick the model
video_generate advertised an optional `model` argument (and the xAI edit/extend tools a model override) so the LLM could route a single call to a different model family — a different endpoint and billing tier — than the one the user selected in `hermes tools`. image_generate never exposed this, and #83080 asked to extend it there; the ruling is the opposite: models do not choose models. The `model` property is gone from the static and dynamic video_generate schema and from xai_video_edit / xai_video_extend; a `model` smuggled into the call is ignored and the configured `video_gen.model` (then the provider default) is what reaches the request. Config-side selection (`video_gen.model`, `video_gen.<provider>.model`, `<PROVIDER>_VIDEO_MODEL`) is unchanged, and the xAI plugin's explicit-model branch is no longer reachable from the tool layer. Refs #83080 |
||
|
|
2fbcd8b0ea |
docs(website): link pages by relative Markdown path so they open on GitHub (#114428)
Mechanical `check_doc_links.py --fix` pass over website/docs (hand-authored and generated pages) and the zh-Hans mirror: 1,868 route-style links (`](/section/page#anchor)`, `](/docs/...)`) become `](../section/page.md#anchor)`. Every target was asserted to exist on disk; anchors and query strings are preserved; fenced code blocks and inline-code examples are untouched. Two dead targets found by the converter were fixed by hand first: memory-providers.md linked `/user-guide/plugins` (page is `user-guide/features/plugins`), and the zh-Hans learning-path still linked the removed `rl-training` page — ported the EN treatment (external Atropos link). Docusaurus build after: EN locale 0 unresolved Markdown links, 0 broken links, 0 broken anchors. |
||
|
|
98b4efbf48 |
fix: clarify cards that cannot render are re-asked as plain text, never reported as user inactivity
A native clarify card on a messaging platform (Telegram inline keyboard, Slack blocks) could fail without the user ever seeing a question, and the agent then waited out the full clarify_timeout and reported "[user did not respond within Nm]" (#112684): - the platform rejects the card at once -> the wait aborted with a delivery sentinel but the question was never re-asked; - the send outruns the 15 s acknowledgement window and only then fails (pool/connect timeouts, a stale-thread retry) -> the runner never looked at the send future again and blocked for the whole timeout for a card that never posted; - no status adapter at all -> _ask_clarify_question returned ('', False), so a batch reported timed_out=True with an empty notice. gateway/run_turn_runner_clarify_delivery.py (new topical sibling; the send-disposition helpers move out of the gateway/run.py facade) now retries every definitive card failure once through the adapter's plain-text send_clarify (numbered list + text capture) - except a connector egress DECLINE, where re-sending the text is the exfiltration the guard exists to stop - and watches a possibly-delivered send so a late failure releases the waiter with "[clarify prompt could not be delivered]". The no-surface case reports "[clarify prompt could not be delivered: no chat surface]" (same prefix every consumer already treats as a non-answer). Live probe (real TurnRunner + real clarify_gateway, Telegram-shaped adapter, timeout 30 s): card fails after 16 s -> before 45.2 s and "[user did not respond within 0m]", after 16.7 s and the typed answer to the text prompt; card rejected at once -> before sentinel with no text prompt, after the numbered prompt is sent and "2" resolves to the choice. |
||
|
|
b3aef45987 |
fix(clarify): batch result carries the surface's no-answer notice; trim tests
Follow-up to the #112725 salvage. When a gateway clarify batch stops at an unanswered question, the batch payload only said `timed_out: true` with blank answers — so a card the platform REJECTED ("[clarify prompt could not be delivered]") read exactly like a user who walked away, which is the misreport #112684 describes ("a timeout must not be presented as user inactivity when the prompt was never delivered"). - gateway/run_turn_runner.py::_clarify_batch_sync: the unanswered question's own response text rides along as `notice`. - tools/clarify_tool.py::_run_batch/_batch_result: pass `notice` through into the result JSON beside `timed_out` (only when present); schema description mentions it. CLI/TUI batch callbacks send no notice -> byte-identical output. - tests: fold the single-question re-arm pin into the existing bracket-answer test (<=2 new tests), parametrize the end-to-end test over an undeliverable Telegram-shaped adapter so the delivery sentinel is pinned as `notice`. - docs: tools-reference.md describes `notice`. Part of #112684 |
||
|
|
ac63d0eea5 |
fix(agent): execution guidance and browser hints drop the web_search stripper; tests assert the invariant
The rebased guidance text no longer names web_search anywhere, so
execution_guidance_text()'s replace() calls (
|
||
|
|
e819846b10 |
Inspired by Amp: relative time bounds (7d/24h/2w) + wrapper forwarding for session_search after/before
Amp's thread feed supports relative time filters (`after:7d`, `updated_before:7d`) alongside ISO dates. Extend the salvaged after/before bounds (PR #86067 by @Moodtuner997) the same way: - `_parse_iso_bound()` now accepts relative durations `Nh`/`Nd`/`Nw` (case-insensitive) meaning "now minus N", alongside ISO dates/datetimes. Clearer error message names both accepted forms. - Forward after/before/exclude_session_ids through the public `session_search()` wrapper (the PR predates the wrapper/impl split; without this the SQL bounds were unreachable from the registry handler — same class as the earlier `detail` forwarding fix). Appended after `detail` to preserve positional compatibility. - Tool schema descriptions teach both forms. - Tests: relative after/before against the discovery shape, unit checks for h/d/w math, case-insensitivity, and bad-unit rejection. - Docs: tools-reference row mentions time bounds + exclude_session_ids. |
||
|
|
4613f895ab |
test: give the rewrite-hint fixtures a read baseline; align docs row with schema
The stale-write guard now refuses write_file on an existing file the task never read in full, so test_write_file_rewrite_hint's overwrite-without-read fixtures were refused before the hint could be computed. Reading first is the exact read->whole-file-rewrite pattern the hint exists for. tools-reference.md's write_file row now mirrors the WRITE_FILE_SCHEMA description (one-sentence contract + the recovery step) instead of a longer paraphrase. |
||
|
|
6569651b87 |
fix: harden salvage of #65605 — redaction-gated test, sibling test baseline, docs
- test_file_staleness redacted-read case now force-enables redaction (matches tests/agent/test_redact.py convention) so it exercises the sentinel path in hermetic CI where security.redact_secrets is unset. - test_write_verification CRLF case establishes a read baseline first (the new guard refuses unread existing-file overwrites by design). - tools-reference.md documents the read-before-overwrite contract. - contributors/emails mapping for DanSpicyTaco. |
||
|
|
cf35e7351e |
fix(connectors): manage_connections is absent for accounts the portal has not enabled (#111238)
A signed-in, paid Nous account that the portal had not enabled for connectors got `manage_connections` in its schema and a raw "tool gateway request failed with status 404" back from every call. The gateway answers 404 for any such account by design, and Hermes gated the tool on paid access or a free tool pool, which says nothing about that. The gate now reads the portal's own answer: a `managed_tools` token claim, plus the existing free-tier leg. The gate is also the tool's check_fn, so a session without the claim never sees the tool and the model has no 404 to narrate. A token without the claim reads as not enabled. |
||
|
|
ee2f5629b8 |
Desktop connect runs on the connection operation: one card, no link to the model, no renderer polling (NS-868) (#110574)
* refactor(connectors): cut comments that restate the code
Connector modules (tools/connectors, tui_gateway connector RPCs, desktop
connector card/store) keep only comments that carry a non-derivable why or
a cross-module contract. No behaviour change.
* feat(connectors): managed connect runs on the connection operation
Managed `connect` / `reconnect` mint one ConnectionOperation for every target and, on a
desktop session, block the tool turn until the operation settles; the result is per-target
outcomes and never carries a connect link. Off the desktop the result carries the links and
returns at once (PR3 delivers them as their own message).
Why: the previous leg handed the model a URL and a `wait` verb, and the renderer ran its own
2s poller on top of the backend's 5s one; both walked the whole gateway catalog at two vendor
calls per page to read one row (~3 Composio calls/s per pending target). A hidden composer
message started the model's `wait` on the user's behalf. None of it was observable from the
operation the MCP leg already used.
What the operation looks like now:
- `contract.py`: TargetState / Actor / SettleReason enums and the `(kind, from) -> {to: actor}`
transition table. `operation.transition()` enforces it; a card cannot claim a managed
target `connected`, only the backend watcher can.
- `live.py`: one open operation per session, found by `op_id`. `connectors.operation.status`
reads it, `connection.respond` drives it, `pending_connection` on resume replays it.
- `run.py`: the one lifecycle for both target kinds (prepare -> card -> wake/observe loop ->
settle -> result). The managed `observe` hook polls the gateway list once per tick for the
whole operation; the exact-status route replaces that call when the gateway ships it.
- `connection.update` is emitted on every transition and on settlement; registered in the
shared event contract with the operation vocabulary typed on the TS side.
- `wait`, `_rendered_links`, `_seen_instructions`, the just-minted bounce and `_clamp_timeout`
are deleted. `force` on `reconnect` always reinitiates; plain `reconnect` repairs only what
the gateway reports disconnected.
- `connections.wait_timeout_seconds` is removed from config defaults, the example and the
docs. The deadline is `OPERATION_DEADLINE_SECONDS = 300` in `operation.py`; the key was
added on this unmerged train so no migration is needed.
- Wire model: `statusReason` parsed on connection results; the seven-state `connectionStatus`
is typed on list items and an unknown value fails validation; `CONNECTION_REQUIRED` carries
`connect_card_available` instead of the link when the session platform is `desktop`.
Session platform, not callback presence, decides whether a card exists: the GUI bridge
attaches callbacks to every backend session, terminal TUI included.
* feat(desktop): connector card subscribes to the connection operation
The card renders from the backend's operation instead of driving its own: `connector-flow.ts`
(the renderer's 2s `connectors.list` poller, its 120s client deadline and `keepWaiting`) is
deleted, and both hidden composer submits in `connector-tool.tsx` go with it. The model is
never nudged into a `wait`; the tool call is blocked on the backend until the operation
settles.
- `connection-request.ts` is the operation store: keyed by `op_id`, one entry per session,
`applyOperationStatus` / `applyConnectionUpdate` as pure reducers, `respond` leaves the
entry in place (the backend answers with `connection.update`), `ConnectionTargetOutcome`
is a discriminated union the backend's transition table accepts.
- `input-requests.ts` applies `connection.update`; `connection.expire` and the resume
snapshot correlate by `op_id` (a snapshot has no `request_id`).
- `ConnectorOffer` renders one `ConnectorCard` per target from a single
`Record<ConnectionTargetState, phase>` table; Connect opens the stored link, Try again on
failed / expired reissues through `connectors.connect` on the open operation, Not now is a
per-target `skipped`, Continue settles. A settled operation renders `ConnectorSummary` rows
with no live control.
- `tool-render-class.ts`: `manage_connections` renders the card regardless of
`HERMES_GUEST_ONBOARDING`; the flag still gates the onboarding flow, not the card. The
backend gate already decided admission; a card only exists because the tool was admitted.
- `mcp-setup-tool.tsx` speaks the same outcome vocabulary (connected / skipped / failed).
- `ConnectorRow.connectionStatus` is the seven-state literal union, not `string | null`.
- The guided-onboarding poller (`first-build-connectors.ts`) keeps its own row/phase types
and compiles unchanged; PR3 moves it onto the operation.
anti-slop: no net-new findings (17 touched files vs 11d1a12472).
* fix(connectors): the card never parks the tool thread; every update carries the snapshot
Found by the pre-PR adversarial review and a real-path E2E test (both left in the tree).
- The desktop `connection_callback` was still `_block("connection.request", ...)`, which parked
the tool thread on a private request-id Event until a `_respond` that no longer exists for
this event. `connection.respond` settled the operation but the tool waited its full deadline
before the watcher loop even started. The callback now only emits the card; the operation's
own wake loop is the wait. The MCP leg's blocking bridge goes with it: the card answers
through `connection.respond` like every other card.
- `connection.request` and every `connection.update` frame carry the full target snapshot
(state, link, detail). The initial mint happened before the card existed, so the renderer
never saw the links and Connect stayed disabled; a Continue settlement stamped
`not_connected` on the backend while the card still showed `initiated`. The store now
overlays the snapshot; no state is reconstructed from deltas.
- The `connection.update` emitter is a class-level `on_change` slot on the operation, set
once by `register()` (a second `register()` no longer stacks wrappers); session lookup takes
`_sessions_lock`; a re-minted link on an `initiated` target goes through `refresh_link()`
and emits, instead of a bare attribute write.
- `session.interrupt` is checked before the first observe, so an interrupted call settles
`interrupt`, not `all_resolved`.
- A gateway list reporting `expired` for an initiated target is recorded with actor `clock`
(the contract's owner of that edge); it raised `IllegalTransition` before.
- Dead `keepWaiting` i18n keys from the deleted renderer poller removed.
tests/tui_gateway/test_connector_operation_e2e.py runs the desktop lifecycle through the real
tool, registry, gateway RPC handlers and callback bridge with only the HTTP client faked.
* docs(connectors): prompts and docs describe the operation, not the deleted wait verb
The onboarding prompts told the model to call action="wait" with timeout_seconds and to
expect a hidden [setup]/[connectors] note; both are gone. tool-search.md and
toolsets-reference.md said the model gets a connect link on the desktop. tui_gateway/AGENTS.md
gains the connection-operation row of the surface table.
* fix(connectors): the panel re-mints only a dead link
Try again on a failed or expired target mints a fresh link on the open operation. A waiting
target keeps the link it was minted with; the card reopens it and connectors.connect refuses
to spend a second mint (LINK_STILL_VALID). The unused refresh_link() goes. The package
docstring names the new siblings; the nine-name public surface is unchanged.
* test(connectors): the local-batch test answers the operation the way the card does
The callback stopped returning an answer in f782b26d98 (the card answers through
connection.respond); this test still returned one and waited out the 300s deadline in CI.
* ci: retrigger
* fix(connectors): the desktop card appears outside guided onboarding
Live on a signed-in macOS desktop, the two-app connect never showed a card. Three
defects, each hidden by a test that bound state the running app never binds.
The backend read the surface from HERMES_SESSION_PLATFORM only. The desktop and TUI
gateway bind it as HERMES_SESSION_SOURCE (_set_session_context), so session_platform()
was "" and managed connects took the off-desktop branch: links in the model's message,
no operation. session_platform() now reads platform, then source. The E2E test binds
through server._set_session_context instead of set_session_vars(platform="desktop").
The renderer routed manage_connections to the card only under isOnboardingEnabled(),
the HERMES_GUEST_ONBOARDING launch flag, in message-parts.tsx and the run splitter in
fallback.tsx. tool-render-class.ts had already dropped that gate in this PR; the two
routers had not. Both now route on the tool name alone.
ConnectorTool resolved the session owner by the runtime id. Owner routes, hints and
session rows are keyed by the stored id, so in registry topology the owner never
resolved and the card rendered null while the tool blocked. It now resolves by the
stored id, matching the PR1.5 card and every other owner lookup.
message-parts-connectors.test.tsx mounts the real Fallback router with the onboarding
flag off and distinct runtime/stored ids; red before each renderer fix, green after.
* style(connectors): shorter comments, no module mock in the card router test
The router test mocked isOnboardingEnabled to false; jsdom has no preload bridge, so the
real function already returns false. Comments that restated the code are cut to one line.
anti-slop: no net-new findings (25 touched files)
* fix(connectors): Connect on a waiting row opens the stored link
ConnectorCard derived the button's loading state from the phase label, so a managed row that
read "Finish connecting in your browser" (every row, since links are minted up front) had a
disabled Connect button. Nothing on the desktop could open the sign-in link; every managed
connect ended skipped, not_connected, or at the deadline.
The card now takes `busy` for "the action itself is running" and keeps `phase` as a label.
The MCP card passes its in-flight flag; the connector card passes the re-mint wait. Red before:
the Connect button on an initiated row rendered disabled and a click opened nothing.
* fix(connectors): a settled card stays dead; the card binds to its tool call only
A second connect for the same apps revived the finished card on the old tool row. The
connection.request payload carried no id, so the renderer fell back to matching rows by
connector names, and any row with those names qualified, settled or not.
The operation now records the model's tool_call_id and sends it in connection.request and in
the resume snapshot. The card binds to the tool row with that id and to nothing else; the
name-match fallback is deleted. A payload without the id is rejected by the store.
`reason` is removed from the tool: it was the only text the card ever showed from the model
and its absence forked a second tool part, since `reason` doubled as the row-correlation key
in tool-parts.ts. The card never needed it.
`connection.expire` is deleted from the contract and from _EXPIRING_REQUESTS: the card is
raised with _emit, not _block, so nothing has emitted it since the operation lifecycle landed.
Sid's rule of record: a resolved card is fully dead; no path brings it back.
* fix(connectors): the watch loop settles once, on time, and never raises into the result
Three findings from the live review, one loop.
Continue racing a finished sign-in: the loop ran the gateway read, then settled. A read that
returned `connected` for an already-settled or failed target raised IllegalTransition out of
the tool and the model got a generic error instead of the per-app outcomes. The read now skips
targets that are not live (pending, initiated) and skips a settled operation; the loop checks
`settled` after every read.
Settle reason as row text: `settle()` wrote `continue`/`deadline` into each unresolved target's
`detail`, and the card printed it in red. The reason stays on the operation only.
Stop and the deadline waited for the next tick: `/stop` sets a per-thread flag with no wake
hook, so the sleep is sliced at 250 ms and the flag and clock are read each slice. The clock is
also checked before each read, not only after.
Tests: a failed mint that later reads connected settles cleanly; Continue during a read keeps
the settled result; no reason in detail; an interrupt settles within the same second.
* fix(connectors): MCP setup off the desktop returns unavailable instead of blocking
run_mcp_operation treated a non-None connection_callback as "a card exists". Every tui_gateway
session has that callback, the Ink TUI included, so an MCP install from the terminal UI blocked
until the 300 s deadline while the docs promised `unavailable` with the terminal commands.
The MCP path now reads the session surface the same way the managed path does; the callback is
never the predicate. Test binds the surface to `tui` with the callback attached.
* fix(connectors): a failed Try again shows the failure, not the old dead link
The panel's re-mint ignored the gateway's per-app status and moved the row to `initiated` with
whatever link came back, `None` included, so a mint that failed again rendered as waiting on the
link that had already died.
One reader of a mint response now serves both the first mint and Try again
(`managed.mint`, with the actor as a parameter). A repeated failure keeps the row `failed`,
drops the link, and carries the vendor's new text through `operation.refresh`, which emits a
frame without a state change so the card redraws.
* fix(connectors): a forced reconnect waits for the new sign-in before it reports connected
`reconnect` with `force: true` is the account switch. The vendor keeps the old account active
while the new link waits, so the first list read after the mint said `connected` and the
operation settled at once: the new link was dropped and the model was told the switch was done.
A forced target is marked awaiting_new_attempt after the mint. The watcher ignores its row until
the list shows the new attempt (`connectionStatus: initiated`) once, then trusts `connected`.
* fix(connectors): the operation registers under the gateway session key
The tool registered the operation under the agent's session_id; every RPC (connection.respond,
connectors.operation.status, the panel's connectors.connect) and the update emitter looked it up
by the gateway's session key. Those agree until compaction rotates the agent id mid-turn; then
the card's clicks find nothing, no update reaches it, and the tool waits out the deadline.
The registration key is now the bound HERMES_SESSION_KEY, with the agent id as the fallback for
callers with no gateway (unit tests, a bare CLI). The E2E passes a rotated agent id and drives
the card by the gateway key.
* fix(connectors): the forced-reconnect gate reads any non-active row; a failed re-mint of an expired row is failed
Three follow-ups from the verification of the fix pass.
The awaiting_new_attempt gate cleared only on the literal `connectionStatus: initiated`. The
field is optional on the wire and `initializing`, `failed`, `expired` are valid values, so a
forced reconnect could wait the full 300 s and swallow a failed new attempt. The gate now holds
only while the row still reads as the old account (`connected` or `active`) and releases on
anything else.
Try again on an `expired` row whose re-mint fails raised IllegalTransition (no expired → failed
edge). The re-mint steps through `initiated` as the user's attempt, then `failed`, then drops the
dead link.
`detail` never carries a state name any more: `failed` as detail rendered as the row label and
made agent/display.py tag the settled result as a tool error. Only vendor text goes there.
`connection.expire` removed from the renderer's unscoped-stream set; nothing emits it.
|
||
|
|
e0ef0eb9c3 |
manage_connections covers local MCP servers; setup_mcp leaves the schema (NS-867, PR1) (#109517)
* feat(connections): manage_connections covers local MCP servers; setup_mcp leaves the schema
One model tool now connects the user to apps of both kinds. A target
`{"name": "linear", "mcp": true}` is a locally configured MCP server;
`install` / `enable` / `authorize` are its verbs. Bare strings and
`{"name": ...}` stay managed connectors and that leg is unchanged.
MCP targets run through one backend-owned connection operation
(tools/connections_tool_operation.py): created with a server-side
deadline from the new config key `connections.wait_timeout_seconds`
(default 120, floor 5, no ceiling), per-target state, and exactly-once
settlement (all resolved / Continue / deadline / interrupt). Unresolved
targets freeze as `not_connected` with the settle reason.
Why the fold works now: the approval card is reached through
`agent.connection_callback` via the agent-level inline executor table,
which is the only path that carries a GUI callback. Registry dispatch
(every non-GUI surface) settles MCP targets as `unavailable` with the
`hermes mcp install / login` hint; managed targets in the same call
are unaffected.
`setup_mcp` is removed from every advertised toolset and from the
deferral list; an inline-table shim keeps calls from conversations
opened before this change dispatching (prompt-cache protection).
`_LEGACY_TOOL_ALIASES` is not the mechanism: inline tools bypass it.
Gateway: `mcp.setup.request/respond` are replaced by
`connection.request/respond/expire` (no wire compat; desktop ships
with this). The bridge waits exactly the operation's deadline. The
`session.resume` snapshot gains `pending_connection` so a reopened
window restores the card with the original deadline.
`manage_connections` joins `_SEQUENTIAL_DEADLINE_EXEMPT_TOOLS`: the
operation owns its wait; the 420s guard must not report `tool_timeout`
while the card is live.
The portal `check_fn` on the tool is dropped in favour of a
handler-level gate on the managed leg, so signed-out sessions can still
approve local MCPs.
* wip(desktop): connection.request store, resume restore, card routing for MCP targets
Renderer half of the setup_mcp fold, first slice: connection-request store
(mirrors clarify), connection.request/expire handling, pending_connection
resume restore, mcpTargets() + isCardTool(name, args) so MCP-target
manage_connections calls classify as cards. Not yet: the card component
rewrite (mcp-setup-tool.tsx), mcp-directory.ts removal, vitest, docs.
Does not typecheck until the card rewrite lands.
* fix(config): hermes update turns on the connections toolset for saved toolset lists
`hermes tools` writes an explicit `platform_toolsets.<platform>` list, and the
resolver reads absence from that list as "unchecked". The `connections`
toolset (#106842) shipped after most users last saved, so `manage_connections`
is stripped from the schema on every install that ever opened the picker.
The Nous entitlement gate never runs; the agent reports the tool as missing.
Migration 44 -> 45 (renumbered when folded into #109517; main was already at 44) appends `connections` to each explicit per-platform list
that lacks it and records the offer in `known_builtin_toolsets` where that
record exists, so a later uncheck reads as a decline. It skips: platforms
whose record already holds `connections` (the user saw the checkbox and left
it off), bare composite lists ([hermes-cli]) that already inherit it, platforms
where the toolset is not allowed, and any config whose `agent.disabled_toolsets`
names `connections` (Blank Slate, `hermes tools --disable`), because the
resolver subtracts that list last and the enable would never take effect.
The explicit-list test is the resolver's own: any configurable or plugin key.
`hermes update` runs migrations post-pull for the active profile and every
sibling, so one update is enough. Fresh installs and composite users were
never affected.
* refactor: anti-slop pass on the desktop slice; shorten added comments
Parse connection.request at the boundary with a typed wire interface instead of
unknown + typeof; mcpTargets reuses connectorText; comments cut to one or two
lines. slop-ratchet: no net-new findings in 13 touched files.
* feat(desktop): the MCP approval card answers manage_connections; MCP Directory removed
The existing card (mcp-setup-tool.tsx) now reads the connection-request store,
renders for manage_connections calls with mcp:true targets, answers through
connection.respond with a per-target outcome, and no longer calls reload.mcp
after Install; the new server's tools arrive on the between-turns refresh.
A settled operation renders the first target's frozen state.
session.resume restores a pending card with its original deadline on both the
activate and cold-resume paths.
lib/mcp-directory.ts is deleted along with its two fallback branches
(suggestion provider, card install). The catalog was already primary in both;
a catalog miss now yields no suggestion / a notInCatalog error. The GitHub
never-suggest test is rewritten on catalog-shaped data.
vitest: connection-request store (6), suggestion provider, clarify restore.
slop-ratchet: no net-new findings in 19 touched files.
* chore: drop __pycache__ files swept in by an over-broad git add
* fix(desktop): correlate the connection.request row with the model's tool call by reason
The synthetic row from connection.request and the tool.start row carried
different ids and no shared match value (op_id is not in the model's args),
so the card mounted twice. reason is the arg both sides carry.
* docs: manage_connections covers local MCP servers; connections.wait_timeout_seconds
* fix(connections): settle reason derives from target state, never from the renderer
A card that answers one of two targets and claims all_resolved must settle as
continue with the other target not_connected; found live with a two-target call.
* fix(desktop): a pending connection card re-arms on resume and activate
The store entry was restored but the transcript row was not, so navigating
away and back (or reloading) lost the card while the backend kept waiting.
restorePendingClarifyToolCall's core is generalized to any blocking tool
name and both resume paths project the connection row through it.
Verified live: card restored after navigate-away and after a full renderer
reload, deadline_at unchanged, approve settles connected.
* style: literal wording in added comments, docstrings and docs
* fix: shared gateway-event contract and config-schema category for the connection events
connection.request/expire replace mcp.setup.* in apps/shared gateway-events
(json list, BACKEND_EVENT_NAMES, GatewayEventMap) so the renderer's event
union includes them and the tui_gateway contract test passes. The new
`connections` config section folds into the agent tab like the other
single-field sections.
* style: import order (perfectionist) in the desktop and shared files this PR touches
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
|
||
|
|
3304d205be |
feat(video-gen): Kling 3.0 Standard + Pro families on the FAL backend
Adds kling-v3 (fal-ai/kling-video/v3/standard/*) and kling-v3-pro (fal-ai/kling-video/v3/pro/*) to FAL_FAMILIES: start_image_url i2v key, aspect_ratio dropped on i2v, string duration 3-15s, generate_audio and negative_prompt real, no seed/resolution keys per the published llms.txt schemas. Payload shapes pinned in tests; docs mention updated. |
||
|
|
c6f87deb2c |
feat(video): OpenRouter backend covers every model on the live video catalog
The salvaged #103267 plugin hardcoded a single model (minimax/hailuo-3-max) and rejected any other id. OpenRouter's public GET /api/v1/videos/models already publishes every generative model with its supported durations, resolutions, aspect ratios, frame-image support, audio and seed flags, and pricing SKUs, so the provider now reads that catalog (5-min TTL, offline snapshot fallback): - list_models(): all 25+ generative models (edit/upscale/avatar rows that take no duration are outside the unified video_generate surface and are dropped) with a per-second price label where the SKU is per-second - capabilities(): the CONFIGURED model's surface, so the dynamic schema only advertises audio/seed/resolutions the selected model honours - _build_payload(): clamps duration/resolution/aspect ratio to the model's live limits (nearest by value/height/ratio) and drops generate_audio/seed for models that lack them (the API 400s otherwise); reference images ride in input_references; local file inputs are refused (OpenRouter fetches URLs itself), data:image/ URLs from the sandbox chokepoint pass through - bearer key only ever goes to the configured origin (poll + /content), never to a provider-supplied unsigned_urls host (kept from #103267) Also drops the source-grep `_IGNORES_SEED` escape hatch #103267 added to the declaration⇄implementation sweep; the provider now implements seed for real. Docs list OpenRouter and DeepInfra as bundled video backends. Requested by Don Piedro Savastano (Discord): OpenRouter credit for video_generate. |
||
|
|
77e55b4d1f |
fix(desktop): show each in-app tip once, never lap the catalog again
The idle tip rotation walked the catalog as a ring: after the last tip it wrapped to the first, so a user who had already seen every tip kept getting "Start fresh", "Teach it once", ... again every six hours for as long as they used the app. Only the X stopped a tip, and letting a bubble time out (the normal way it leaves) counted for nothing. The walk is now one lap. nextTip also steps over every tip in the seen ledger ($tipShownAt, which already recorded every catalog tip that reached the screen), so a tip shows once however it left, and the rotation runs dry once every tip has had its moment. Settings > Reset clears the seen ledger and the cursor as well as the retired set, and its button counts what a Reset would actually bring back (shown or closed, counted once). Agent tips carry no catalog id and are untouched. Live repro (Playwright against the worktree's Vite renderer, all nine tips seeded as seen, clock fast-forwarded past settle + cooldown): origin/main re-showed "Start fresh"; fixed renderer shows nothing; a fresh user (nothing seen) still gets the first tip. |
||
|
|
f1ccf436a2 |
feat(web): Perplexity Search API as a web_search + web_extract backend
Adds plugins/web/perplexity — a keyed-only WebSearchProvider over httpx: - search: POST https://api.perplexity.ai/search (documented Search API), search_context_size=low so `snippet` stays description-sized; results[].snippet -> description, max_results capped at the API's 20. - extract: POST /sdk/content/snippets — the query-relevant page-excerpt route behind `pplx content snippets` (the CLI's `content fetch` is deprecated upstream). web_extract has no query, so the URLs' path words serve as the relevance query; per-URL `error` entries survive a 200. - Wired into the same touchpoints as the other keyed vendors: legacy backend set + credential ladder + availability probe (web_tools), registry preference walk, OPTIONAL_ENV_VARS, `hermes config`/status/ dump key lists, nous_subscription direct-credential detection, setup summary, test conftests, docs. Not a keyless-ring member (Perplexity has no anonymous tier). Related closed PRs #9192 / #23981 / #45225 predate the plugin ABC. |
||
|
|
428e084dcd |
feat(web): add Tavily web search and extract provider
This commit re-introduces the Tavily provider, which supports both search and content extraction capabilities, which was removed in #99199. |