Compare commits

..
Author SHA1 Message Date
alt-glitch 9fb1d973ff fix(clarify): docstring — put options in choices[] only, never enumerate in question text
The model was enumerating options inside the question string (dead prose the UI
can't render as pickable rows). Schema description now spells out: choices[] is
REQUIRED for selectable options; question holds ONLY the question.
2026-06-16 19:27:53 +05:30
Teknium 4d470b3dbb fix(slack): route /debug via /hermes to restore Telegram-parity (#47248)
Slack caps apps at 50 slash commands and the registry is at that ceiling, so
adding /debug clamped it out of the native list and broke the telegram-parity
test (debug on Telegram, absent from Slack native slashes, in neither
exclusion set). Add 'debug' to _SLACK_VIA_HERMES_ONLY — same treatment credits
already gets. /debug stays native on CLI/TUI/Telegram/Discord and reachable via
/hermes debug on Slack.
2026-06-16 06:20:01 -07:00
Teknium 2483200963 test(tui): isolate session-create no-race test from shard-sibling leakage (#47230)
test_session_create_no_race_keeps_worker_alive flaked on CI shard 3 with
'build thread unregistered its own notify despite no race' while passing
20/20 in isolation locally. Root cause: daemon build threads from sibling
session.create tests in the same shard process mutate the shared
server._sessions dict under _sessions_lock and can replace/pop entries
mid-run, flipping this build thread's 'replaced' check (server.py:1011) to
True and triggering a spurious unregister_gateway_notify.

Fix is test-only: snapshot + clear server._sessions before the request so
the test sees only its own session, restore siblings in finally. Also assert
agent_ready.wait() actually returned True (was silently ignoring timeout) and
bump the timeout 2s -> 10s for loaded CI runners.
2026-06-16 05:56:50 -07:00
teknium1 1ac76a9472 chore: add MrDiamondBallz to release AUTHOR_MAP 2026-06-16 05:56:11 -07:00
MrDiamondBallz 9a59ad73dd fix(auth): preserve Codex pool-only rate-limit state
Classify exhausted pool-only openai-codex credentials as quota/rate-limited instead of missing auth. This prevents auth status and runtime credential resolution from reporting missing credentials when a valid manual:device_code pool credential exists but is temporarily in a 429 usage-limit cooldown.

Adds regression coverage for pool-only Codex auth status and runtime resolution.
2026-06-16 05:56:11 -07:00
teknium 6373aba80f feat(gateway): rename to tool_progress_grouping, add config/docs/tests
Follow-up to salvaged PR #41620:
- Rename tool_progress_style -> tool_progress_grouping (clearer intent)
- Add display.tool_progress_grouping to DEFAULT_CONFIG (accumulate default)
- Document in messaging docs incl. 'separate is noisier, only where progress enabled'
- Add resolver tests (default/global/override/invalid/case)
2026-06-16 05:49:24 -07:00
Wolfram Ravenwolf fc956b9db6 feat: add tool_progress_style config (accumulate vs separate)
Add display.tool_progress_style setting to control how tool progress
messages are displayed in chat platforms:

- 'accumulate' (default): Edit a single message with all tool calls
  (new v0.9.0 behavior)
- 'separate': Send each tool call as its own message, interleaved
  with thinking messages (pre-v0.9 behavior, better readability)

The setting participates in the per-platform display override system
and can be set globally or per-platform.

Files: gateway/display_config.py, gateway/run.py
2026-06-16 05:49:24 -07:00
teknium 98ae28657f feat(display): document and test memory_notifications setting
Follow-up to salvaged PR #4684:
- Add display.memory_notifications to DEFAULT_CONFIG (off|on|verbose, default on)
- Document the setting in docs/user-guide/features/memory.md
- Add resolver tests for off/on/verbose memory + skill paths
2026-06-16 05:45:40 -07:00
Wolfram Ravenwolf 4cf9d80fba feat(display): verbose skill change notifications with content previews
When display.memory_notifications is set to 'verbose', skill_manage
notifications now show meaningful change details instead of just the
generic tool message.

Before (verbose mode):
  💾 📝 Patched SKILL.md in skill 'gogcli' (1 replacement).

After (verbose mode):
  💾 📝 Skill 'gogcli' patched: "old pitfall text..." → "new pitfall text..."

Changes:
- skill_manager_tool.py: _patch_skill() now includes old/new string
  previews (truncated to 200 chars) in the result via '_change' key.
  _create_skill() and _edit_skill() include skill description from
  frontmatter for verbose create/edit notifications.
- run_agent.py: Background review notification builder now reads the
  '_change' dict from skill tool results and formats descriptive
  notifications per action type (patch → old→new diff, create/edit →
  description preview). Falls back to generic message when _change
  data is unavailable (backwards compatible).

This is especially useful when subagents patch skills, since neither
the user nor the parent agent can see what the subagent changed.
2026-06-16 05:45:40 -07:00
Wolfram Ravenwolf 20b1f4f3fb feat(memory): configurable background memory update notifications
Background memory reviews now support three notification modes,
configured via display.memory_notifications in config.yaml:

  off     — no chat notification (still logged to stdout/HA log)
  on      — generic '💾 Memory updated' (default, unchanged behavior)
  verbose — content preview with action indicators:
            💾 Memory ➕ Hermes Repo liegt unter /config/amy/hermes-agent/...
            💾 Memory ✏️ Updated repo path from claude-code to hermes-agent...
            💾 Memory ➖ old entry about claude-code path...

Previews are truncated to 120 chars for adds/replaces, 60 for removes.
Each action gets its own line in verbose mode for readability.

Files: run_agent.py, gateway/run.py
2026-06-16 05:45:40 -07:00
Teknium a6364bfa08 fix(telegram): edit streamed previews in place as rich (Bot API 10.1) (#46890)
Streamed Telegram replies that finalize through editMessageText were
converted to MarkdownV2, which has no table syntax and rewrites pipe
tables into bullet lists — users saw a table while streaming that
collapsed to a list at the last moment.

Finalize now edits the existing preview IN PLACE via Bot API 10.1's
editMessageText rich_message parameter when the content has constructs
the legacy path degrades (tables, task lists, <details>, block math).
No fresh send + delete, so no duplicate-preview flicker — the reason
#46206 reverted the fresh-final re-send path. prefers_fresh_final_streaming
stays False; the in-place edit replaces it.

- _needs_rich_rendering(): rich reserved for table/task-list/details/math
  (adapted from #45995, @YonganZhang); plain replies stay on MarkdownV2.
- _try_edit_rich(): editMessageText + rich_message via do_api_request,
  mirroring _try_send_rich's fallback/latch/transient contract.
- edit_message finalize tries rich in place before the 4,096 overflow
  pre-flight (rich cap is 32,768), falling back to legacy on rejection.
- rich_messages default flipped back to True (DEFAULT_CONFIG + adapter).
- docs (en + zh-Hans) + cli-config example updated to default-on.

Closes the root cause behind #45911 / #46009.
2026-06-16 05:26:04 -07:00
5b3fa26366 fix(photon): unify project identifiers and update documentation for Spectrum provisioning
Co-Authored-By: Marvin <marvin@photon.codes>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:25:56 -07:00
brooklyn!andqingshan89 c6b0eb4de0 fix(desktop): open remote-gateway artifacts via authenticated download (#46895)
On a remote gateway connection, agent-written files live on the gateway
host, not the desktop's disk, so the Artifacts view's file:// hrefs failed
("Invalid external URL") and image thumbnails broke.

Make mediaExternalUrl() remote-aware in one place: in remote mode it
rewrites gateway-local paths to GET /api/files/download (a new endpoint
that streams the file as a Content-Disposition: attachment). The artifacts
view now resolves through it, and so do the existing chat-media and
generated-image callers, for free.

The download endpoint stays auth-gated; auth_middleware additionally
accepts the session token as a ?token= query param for this one path so a
shell/browser-opened download (which can't set the session header) still
authenticates — the same query-token tradeoff as the /api/pty WebSocket.
It is NOT added to PUBLIC_API_PATHS.

Salvages #46663 (which carried ~19k lines of CRLF noise and made the
endpoint public). Reimplemented on a clean LF base with the security hole
closed and tests added.

Co-authored-by: qingshan89 <qs2816661685@gmail.com>
2026-06-15 23:50:19 -05:00
Gille 0441b7f19f fix(desktop): route global remote profile REST calls (#47011)
* fix(desktop): route global remote profile REST calls

* fix(dashboard): scope oauth provider routes by profile

* test(tui): isolate notification poller queue
2026-06-15 23:24:55 -05:00
Shannon Sands 7cd71de1f4 Simplify dashboard update detection to containers 2026-06-15 20:08:39 -07:00
Shannon Sands b1d6a57883 Detect containerized dashboard update management 2026-06-15 20:08:39 -07:00
Shannon Sands 0b6b29a30c Hide hosted dashboard update controls 2026-06-15 20:08:39 -07:00
brooklyn! 55cb4103be Merge pull request #46951 from NousResearch/bb/new-session-window
feat(desktop): hotkey to open a new session in a compact window
2026-06-15 21:05:30 -05:00
Brooklyn Nicholson 67233d1c2a fix(desktop): sync new sessions across windows
Broadcast session-list mutations from scratch windows so the main sidebar refreshes without manual reloads.
2026-06-15 20:59:57 -05:00
Brooklyn Nicholson 0f75e9904a feat(desktop): trim scratch window chrome
Hide nonessential Hermes chrome in the new-session pop-out while preserving native window controls and stable first-message positioning.
2026-06-15 20:59:57 -05:00
Brooklyn Nicholson 98c294126b feat(desktop): open new sessions in compact windows
Add the Electron IPC bridge and rebindable shortcut for opening an unkeyed scratch window on the new-session draft.
2026-06-15 20:59:57 -05:00
Teknium 0a8f3e21b8 fix(delegation): forward background flag so delegate_task(background=true) runs async (#46968)
* fix(skills): guard recursive skill delete against tree-escape

Port from Kilo-Org/kilocode#11240. Their issue #11227 lost a user's entire
working directory: a built-in-skill sentinel location resolved to the server
cwd and the skill-removal endpoint ran a recursive delete on it.

Hermes' /skills uninstall path (skills_hub.py) is already hardened, but the
agent-facing skill_manage(action='delete') path did a bare
shutil.rmtree(skill_dir) with no last-line validation. Add _validate_delete_target():
refuse to rmtree a path that (1) isn't strictly inside a known skills root,
(2) is a skills root itself, or (3) is reached via a symlink/junction.

Tests: 4 cases (normal delete works; symlinked dir, skills-root, out-of-tree
all refused). E2E verified with real symlink + file I/O.

* fix(delegation): forward background flag in delegate_task dispatch

delegate_task is an _AGENT_LOOP_TOOLS member, so every surface (CLI,
gateway, desktop/TUI) routes it through AIAgent._dispatch_delegate_task.
That forwarder passed every schema field except background, so
delegate_task(background=true) was silently downgraded to a synchronous
run and returned the sync results payload instead of a delegation_id.

The model sees background in the schema (the call validates), but the
value never reached the function. Add the one missing kwarg so async
background delegation actually engages.
2026-06-15 18:52:02 -07:00
Teknium 2dbc3bd937 fix(skills): guard recursive skill delete against tree-escape (#46929)
Port from Kilo-Org/kilocode#11240. Their issue #11227 lost a user's entire
working directory: a built-in-skill sentinel location resolved to the server
cwd and the skill-removal endpoint ran a recursive delete on it.

Hermes' /skills uninstall path (skills_hub.py) is already hardened, but the
agent-facing skill_manage(action='delete') path did a bare
shutil.rmtree(skill_dir) with no last-line validation. Add _validate_delete_target():
refuse to rmtree a path that (1) isn't strictly inside a known skills root,
(2) is a skills root itself, or (3) is reached via a symlink/junction.

Tests: 4 cases (normal delete works; symlinked dir, skills-root, out-of-tree
all refused). E2E verified with real symlink + file I/O.
2026-06-15 17:14:59 -07:00
Dominik 9d2ec8d35a Merge pull request #46244 from skyc1e/fix/desktop-explorer-refresh
fix(desktop): keep file tree refresh clickable
2026-06-15 23:52:00 +00:00
brooklyn! 423d24780b Merge pull request #46909 from NousResearch/bb/coalesce-interleaved-reasoning
fix(desktop): coalesce interleaved reasoning/content stream parts
2026-06-15 18:38:24 -05:00
Brooklyn Nicholson 37d717054e refactor(desktop): unify stream-part coalescing into one helper
Collapse segmentMergeIndex + mergeTextInto + the three append helpers
into a single segment-aware appendStreamPart core plus a part-factory
table. Same behavior, DRY.
2026-06-15 18:13:52 -05:00
Brooklyn Nicholson 1cb75b7971 fix(desktop): coalesce interleaved reasoning/content stream parts
Models that interleave their reasoning_content and content token streams
(Kimi/DeepSeek/GLM-style routes) emit text -> reasoning -> text deltas
within a single tool-bounded segment. Appending each delta as its own
part shredded one sentence into "Let me" / Thinking / "verify the file",
with a Thinking disclosure wedged mid-sentence.

Coalesce streaming deltas into the most recent same-type part within the
current segment (bounded by any non-streaming part, e.g. a tool call).
The opposite streaming channel is transparent, so a reasoning burst
between two content deltas no longer opens a fresh text part, while a
real tool call still starts a new segment and preserves narration order.

Data-layer only; the renderer already groups consecutive reasoning.
2026-06-15 17:48:35 -05:00
Teknium 5bfed0fe07 feat(skills): add optional payments skills (Stripe Link, MPP, Projects) (#31343)
* feat(skills): add optional payments skills (Stripe Link, MPP, Projects)

Adds four optional skills under optional-skills/payments/ wrapping the
Stripe Link CLI, the Machine Payments Protocol (MPP) clients, and the
Stripe Projects CLI plugin. Plus a router skill (payments) that picks
between them based on user intent.

All four are gated [linux, macos] — Stripe's Link CLI does not yet
support Windows. The other CLIs (mppx, stripe projects) are
cross-platform on paper but the payments cluster moves as a unit until
Link CLI gains Windows support.

Skills:
- stripe-link-cli  - one-time virtual cards + Shared Payment Tokens
- mpp-agent        - HTTP 402 payments via mppx/Tempo/Privy/AgentCash
- stripe-projects  - provision SaaS services + credential sync
- payments         - router/index skill for the cluster

Hard invariants encoded in every skill:
- Card PANs/wallet keys never enter agent transcripts, logs, or memory
- Spend approvals are not self-bypassable (Link app / wallet UI / CLI prompt)
- Final totals confirmed with user before any --request-approval call
- Credential output files cleaned up after one-time use

Zero core touches. Skills install via:
  hermes skills install official/payments/<skill>

* chore(skills/payments): drop router skill — skills shouldn't depend on other skills

Removed optional-skills/payments/payments/ — the router skill that
existed to hand off between stripe-link-cli, mpp-agent, and
stripe-projects.

Per project convention: skills should be independently loadable; a
router is a footgun because (a) it assumes the loader will follow its
recommendation rather than just loading what the user asked for, and
(b) it duplicates the trigger logic that already lives in each
sub-skill's '## When to Use' section.

The three remaining skills declare their own triggers and routing
hints. The optional-skills catalog still groups them under '## payments',
which is the appropriate place for cluster-level discoverability.

Also drops 'payments' from each remaining skill's 'related_skills' list
and removes the corresponding entries from the docs catalog + sidebars.

* feat(skills/payments): fold in danhill-stripe review feedback

- mpp-agent: add link-cli as a client option (when Link is already set
  up, or the 402 challenge advertises method="stripe")
- stripe-link-cli: reframe Link account / payment method / approval app
  as first-run setup, not hard preconditions (CLI configures them on
  first run)
- regenerate the two affected optional-skills docs pages
2026-06-15 15:28:42 -07:00
TekniumandWolfram Ravenwolf 5a0e0d35b9 fix(mattermost): preserve thread-local delivery hygiene
Salvage the valid thread-routing pieces from #41640:
- route Mattermost progress/status sends through metadata thread IDs
- treat top-level Mattermost channel posts as thread roots for progress
- preserve thread metadata through media/file sends
- allow flat fallback only for final notify-worthy replies on confirmed broken roots

Co-authored-by: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de>
2026-06-15 15:06:23 -07:00
kshitij d2b34e89b0 Merge pull request #44431 from erosika/feat/honcho-identity-tree
feat(honcho): gateway-gated identity tree + canonicalize on pinUserPeer
2026-06-16 03:35:24 +05:30
Erosika 6dde7d4657 docs(memory-providers): cover gateway identity mapping for Honcho
The Honcho provider page documented the per-profile peer model (user
peer / AI peer / observation) but never the gateway axis — how platform
runtime IDs map to peers. Adds the three keys to the config table and a
short Gateway identity mapping subsection that points at the Honcho page
for the resolver ladder.

Uses the corrected pinUserPeer wording (pins non-agent users, overrides
aliases) so the provider-comparison reader gets the same accurate framing
as the dedicated page.
2026-06-15 21:50:24 +00:00
Erosika c7513df4f9 docs(honcho): clarify pinUserPeer pins only non-agent users
'everyone collapses to your peer' read as a promise about all traffic.
pinUserPeer pins the user-side peer and is checked before userPeerAliases
(session.py:335), so a pin overrides every alias — including agent peers.
For a multi-agent operator that silently pools distinct agents onto one
peer, the opposite of intent.

Scopes the wording to 'every non-agent gateway user', notes the pin
overrides aliases, and points agent-mesh operators at pinUserPeer:false +
userPeerAliases instead. Same correction in the wizard menu/echo text,
the plugin README, and the website Honcho page.
2026-06-15 21:34:09 +00:00
ethernet 062c17d34f Merge pull request #46867 from NousResearch/hermes-always-run
fix(ci): always run pull_request checks
2026-06-15 17:09:36 -04:00
ethernet e0492aa2dc fix(ci): always run pull_request checks
no waiting for pending forever!
2026-06-15 17:03:55 -04:00
kshitij cffd6e3c8d Merge pull request #46078 from xxxigm/fix/discord-slash-command-100-cap
fix(discord): cap slash commands at Discord's 100-command limit
2026-06-16 02:05:31 +05:30
Teknium c66ecf0bc3 feat(delegation): async background subagents via delegate_task(background=true) (#40946)
* feat(delegation): async background subagents via delegate_task(background=true)

delegate_task(background=true) dispatches a subagent that runs in the
background and returns a handle immediately, so the user and model keep
working while it runs. The full result — plus the original task source —
re-enters the conversation as a new turn when the subagent finishes,
riding the same completion-queue rail as terminal background processes.

- tools/async_delegation.py: daemon-executor registry, capacity cap,
  rich self-contained completion event pushed onto the shared
  process_registry.completion_queue (type='async_delegation').
- delegate_tool.py: background param + single-task dispatch branch;
  batch async rejected (v1).
- process_registry.py: format_process_notification renders the rich
  task-source block (goal/context/toolsets/model/status/result).
- gateway/run.py: dedicated _async_delegation_watcher drains + injects
  results into the originating session (idle + post-turn), session_key
  routing enrichment, shutdown interrupt of dangling delegations.
- config: delegation.max_async_children (default 3).

Reuses the existing idle-drain wiring rather than mutating a running
agent loop, preserving message-role alternation and prompt-cache
invariants. 13 targeted tests; CLI + gateway paths E2E-verified.

* test(delegation): make async non-blocking tests environment-independent

CI 'test (5)' flaked on a cold, 8-worker runner: the first
delegate_task(background=true) call measured 2.27s of one-time setup
(config load + child-agent construction + imports), tripping the
elapsed < 1.0 wall-clock assertion. That assertion was testing setup
overhead, not blocking.

Replace the wall-clock thresholds with the real invariant: dispatch
returns while the child is still gated (active_count == 1, completion
queue empty), which a synchronous impl could not do. Keep only a loose
4s sanity backstop well under the runner's 5s gate.

* fix(delegation): harden async background delegation

Follow-up review fixes:
- Detach background child from parent._active_children at dispatch —
  otherwise parent-turn interrupts (Ctrl+C, mid-turn steering), cache
  evicts (release_clients), and session close (/new) kill/close the
  detached subagent mid-run, defeating the point of background mode.
  Lifecycle is owned by the async registry's interrupt_fn.
- Make the capacity check atomic with the record insert (TOCTOU: two
  concurrent dispatches could both pass active_count() and exceed the cap).
- TUI dedup: key async_delegation events by delegation_id — the
  fallthrough keyed them all as ("", type), suppressing every completion
  after the first in the desktop/TUI status feed.
- CLI /stop now interrupts running background delegations and /agents
  lists them (they live outside the process registry and were invisible).
- Drop stray unbalanced ']' line from the re-injection block and the
  unused _ASYNC_DEFAULT import.

Tests: detach-at-dispatch + concurrent-capacity race added (15 total in
test_async_delegation.py); 137 delegate + 140 process-registry/notify/watch
+ 7 TUI dedup tests pass.

* fix(delegation): harden async background completion drains
2026-06-15 13:33:12 -07:00
Austin Pickettandjeffrobodie-glitch 368fcf1ff0 fix(desktop): read HERMES_HOME from the Windows registry when env is stale (#46772)
A GUI app launched from Explorer inherits the environment block captured at
login, so a HERMES_HOME set via 'setx' AFTER login is invisible in process.env
even though the CLI (a fresh shell) sees it. The desktop then silently fell
back to %LOCALAPPDATA%\hermes and reported 'No inference provider configured'
despite a valid configured home (#45471).

resolveHermesHome() now consults the live HKCU\Environment registry value on
Windows before the LOCALAPPDATA default. New windows-user-env.cjs helper parses
'reg query' output, expands %VAR% refs, and fails safe (returns null off-Windows,
on spawn error, or empty value). The registry value is normalized through the
same normalizeHermesHomeRoot() path as the env var for consistency.

Co-authored-by: jeffrobodie-glitch <jeffrobodie@gmail.com>
2026-06-15 15:16:55 -05:00
ethernet 39f479cba8 Merge pull request #46085 from xxxigm/fix/bundled-node-global-npm-path
fix(install): make `npm install -g` packages reachable on PATH
2026-06-15 15:47:54 -04:00
Austin Pickettandrodboev ed20f5ed06 fix(desktop): let explicit model switches escape broken config providers (#42241) (#46796)
When a desktop/dashboard session had no agent built yet and the user explicitly
picked a provider in the model picker, config.set('model', ...) would first try
to initialize the agent from the (possibly broken) config default provider —
failing before the user's explicit switch could take effect, trapping them on a
misconfigured default.

config.set now pre-parses the model flags: if an explicit --provider is present
and no agent exists yet, it skips the default-provider agent build and routes
straight through _apply_model_switch with the explicit provider. _apply_model_switch
gained a parsed_flags passthrough (avoids double-parsing) and only falls back to
resolve_runtime_provider(requested=None) when no explicit provider was given.

The desktop hook now sends config.set instead of slash.exec for active-session
model changes, so errors from the selected provider surface to the user instead
of being swallowed.

Co-authored-by: rodboev <rod.boev@gmail.com>
2026-06-15 15:36:51 -04:00
xxxigm 2a08b8c86f test(dump): cover terminal backend override reporting
Verifies `hermes debug` surfaces a TERMINAL_ENV override of
terminal.backend, reports the config value when no override is present,
and emits no spurious note when env and config agree.
2026-06-15 12:31:23 -07:00
xxxigm b2a4766463 fix(dump): report effective terminal backend in hermes debug
`terminal.backend` in config.yaml is bridged to the TERMINAL_ENV env var,
but a TERMINAL_ENV set in .env / the shell overrides config and is what
terminal_tool actually uses. The dump printed only the config value, so a
user whose agent was jailed in a docker/podman sandbox via a stale
TERMINAL_ENV still saw `terminal: local` — hiding the real cause. Report
the effective backend and flag when TERMINAL_ENV overrides config.yaml.
2026-06-15 12:31:23 -07:00
liuhao1024 60cc42e38b fix(inventory): deduplicate models between user-defined and aggregator providers
When a user-defined provider (e.g. litellm-proxy) and an aggregator
(e.g. openrouter) both advertise the same model name, the Desktop/TUI
model picker would show the model under both groups. Selecting it from
the aggregator row silently set model.provider to the aggregator,
breaking calls because the aggregator doesn't actually serve that model
ID.

Fix: after list_authenticated_providers() returns, collect all models
from user-defined provider rows and filter them out of aggregator rows.
Uses is_aggregator() from hermes_cli/providers.py to identify
aggregators. Case-insensitive matching.

Fixes #45954
2026-06-15 12:25:41 -07:00
liuhao1024 9df1a1a8de fix(doctor): recognize nvidia as vendor-slug-accepting provider
NVIDIA NIM API uses vendor-prefixed model IDs (e.g. qwen/qwen3.5-122b-a10b,
nvidia/nemotron-3-super-120b-a12b). The doctor command incorrectly warns that
vendor-prefixed slugs belong to aggregators like openrouter when nvidia is
the configured provider.

Add 'nvidia' to the providers_accepting_vendor_slugs set so doctor no longer
raises false-positive warnings for valid NVIDIA NIM configurations.

Fixes #35425
2026-06-15 12:24:46 -07:00
brooklyn! c33e0457d7 Merge pull request #46836 from NousResearch/bb/salvage-macos-electron-pack
fix(desktop): restore Electron binary before macOS pack rename (salvage #38673)
2026-06-15 14:22:05 -05:00
Austin PickettandTeknium f7c1cbe66f docs: point desktop download links to site root (deprecate /desktop) (#46795)
The /desktop page is deprecated and redirects to the home page. The
landing page for the desktop app is now simply
https://hermes-agent.nousresearch.com/. Update all docs and the
Docusaurus nav/footer links accordingly.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-15 15:02:24 -04:00
Brooklyn Nicholson c23a2eec15 chore: map salvaged contributor email for attribution (#38673) 2026-06-15 13:53:23 -05:00
ChasLui f3b32e9f52 fix(desktop): restore Electron binary before macOS pack rename (salvage #38673)
electron-builder 26.8.x can stage an Electron.app without its
Contents/MacOS/Electron binary, then fail renaming it to Hermes:

    ENOENT: no such file or directory, rename .../MacOS/Electron -> .../MacOS/Hermes

This breaks `npm run pack` and the installer desktop stage before a
launchable Hermes.app exists.

- Point build.electronDist at the already-installed Electron dist so
  electron-builder reuses it instead of re-unpacking from cache.
- Add a darwin-only prebuilder patch that restores the missing main
  binary from the runtime dist before the rename. Idempotent (marker
  guard), soft-fails on shape mismatch, survives node_modules reinstall.

Co-authored-by: ChasLui <chaslui@outlook.com>
2026-06-15 13:53:01 -05:00
5f6be7f31b fix(teams): package Microsoft Teams SDK as an installable extra (salvage #43945) (#46764)
* fix(teams): package Microsoft Teams SDK as an installable extra

The Teams adapter imports the microsoft-teams-apps SDK, but it was never
declared as a dependency, so source/local installs hit ImportError and the
adapter silently reported the SDK as unavailable. Add a 'teams' extra
(microsoft-teams-apps==2.0.13.4 + aiohttp) and document 'uv sync --extra teams'.

Per the 2026-05-12 [all] policy, opt-in messaging-platform SDKs are NOT added
to [all] (they would break every fresh install on a quarantined release); the
teams extra is installed on demand like the other platform backends.

Co-authored-by: rio-jeong <rio.jeong@thebytesize.ai>

* chore: map rio-jeong contributor email for attribution (#43945)

* feat(teams): lazy-install the Teams SDK on demand (parity with other channels)

The teams extra alone left Teams as the only messaging platform that wouldn't
auto-install its SDK — every other channel (telegram, discord, slack, matrix,
dingtalk, feishu) lazy-installs via tools.lazy_deps on first connect. Bring
Teams to parity:

- Add 'platform.teams' to LAZY_DEPS (microsoft-teams-apps + aiohttp).
- Replace the passive 'check_teams_requirements = check_requirements' alias with
  a real lazy-installer that calls ensure_and_bind('platform.teams', ...),
  rebinding all Teams SDK globals on success (mirrors check_slack_requirements).
- Call check_teams_requirements() at the top of TeamsAdapter.connect() so
  enabling Teams installs the SDK on demand.
- Keep the passive check_requirements() as the registry check_fn so 'gateway
  status' probes never trigger a pip install.

The 'teams' extra remains for packagers / explicit 'uv sync --extra teams'.

Tests: rework the alias test into shortcircuit + lazy-install assertions, and
update test_connect_fails_without_sdk to simulate an uninstallable SDK.

---------

Co-authored-by: rio-jeong <rio.jeong@thebytesize.ai>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-15 14:35:15 -04:00
xxxigm 1db8f7ea80 fix(install): repair existing managed-Node global prefix on re-run
The initial fix only wrote the prefix npmrc on a fresh Node install, so
pre-existing bundled-Node installs (Node already present) were not repaired
by re-running the installer — install_node/ensure_node skip when Node is
already up to date.

Extract the redirect into an idempotent helper
(configure_managed_node_npm_prefix / _nb_configure_npm_prefix) that no-ops
when there's no Hermes-managed npm, and call it unconditionally from
check_node (install.sh) and at the top of ensure_node (node-bootstrap.sh).
Re-running the install command now repairs an affected install in place,
not just brand-new ones.
2026-06-14 17:34:11 +07:00
xxxigm 98205da008 test(install): cover bundled-Node npm global prefix redirect
Guards that install.sh and node-bootstrap.sh redirect the bundled Node's
npm global prefix to the command link dir's parent via a prefix-local
global npmrc, so `npm install -g` binaries land on PATH instead of the
off-PATH $HERMES_HOME/node/bin.
2026-06-14 17:21:25 +07:00
xxxigm a4ee1f223d fix(install): make npm install -g packages reachable on PATH
When the installer falls back to a bundled Node under $HERMES_HOME/node,
npm's default global prefix is that Node dir, so `npm install -g <pkg>`
drops the package binary in $HERMES_HOME/node/bin. Only node/npm/npx are
symlinked into the command link dir (~/.local/bin, /usr/local/bin, or
$PREFIX/bin) — so user-installed global package binaries are NOT on PATH
and can't be run, even though `npm i -g` reports success. They also get
wiped on every Node upgrade (the dir is rm -rf'd and re-extracted).

Redirect the bundled Node's npm global prefix to the command link dir's
parent, so global bins land in the link dir (already on PATH, alongside
node/npm/npx) and survive Node upgrades. Scoped to the bundled Node via
its prefix-local global npmrc ($HERMES_HOME/node/etc/npmrc), so the user's
other Node installs and their ~/.npmrc are untouched. Hermes's own global
installs (agent-browser) pass an explicit --prefix and are unaffected.
2026-06-14 17:21:20 +07:00
xxxigm 8f4a718f95 test(discord): guard slash-command registration against the 100 cap
Registers 200 plugin commands on top of the native + COMMAND_REGISTRY set
and asserts the tree never exceeds Discord's 100-command limit, that native
high-priority commands survive the cap, and that overflow is actually
dropped. Regression guard for the recurring error 30032
("Maximum number of application commands reached") sync failures.
2026-06-14 17:02:21 +07:00
xxxigm 5e851bc6bc fix(discord): cap slash commands at Discord's 100-command limit
Discord enforces a hard cap of 100 global application commands per app.
The adapter registers ~27 native commands plus every gateway-available
entry in COMMAND_REGISTRY plus all plugin commands plus the consolidated
/skill group. On a loaded install (many plugins/quick commands) the
desired set exceeds 100, so tree.sync() / _safe_sync_slash_commands()
hits error 30032 ("Maximum number of application commands reached") and
Discord rejects the ENTIRE batch — silently breaking every slash command,
not just the overflow.

Cap registration at the 100-command limit: native commands (registered
first, highest priority) and the /skill group are always kept; lower-
priority auto-registered COMMAND_REGISTRY and plugin commands are added
only until the cap is reached, with a single concise warning telling the
user how to surface the rest. Since both sync paths read from
tree.get_commands(), bounding the tree fixes the root cause for both.
2026-06-14 17:01:28 +07:00
Erosika 1544813bfe chore(honcho): replace example Telegram UID with placeholder 2026-06-11 15:06:07 -04:00
Erosika 2708c33c75 docs(honcho): anonymize example peer name to alice 2026-06-11 15:04:01 -04:00
Erosika 23a7458acf docs(website): cover gateway identity mapping in Honcho feature page
The identity-mapping keys never made it to the site docs. Add the three keys
to the config reference and a Gateway Identity Mapping section: when it
applies (gateway only, setup-gated), the intent tree, resolver order, the
un-pin orphan warning, and the deprecated pinPeerName alias.
2026-06-11 14:58:19 -04:00
Erosika 99feb03607 docs(honcho): demote pinPeerName to deprecated alias; document gateway identity tree
Drop pinPeerName from the key table (now a deprecated-alias note), and replace
the single/multi/hybrid 'deployment shapes' section with the gateway-gated
intent tree the wizard actually presents, including the [e] raw-edit hatch and
the un-pin pooling steer.
2026-06-10 16:15:17 -04:00
Erosika d7dfeed6dc feat(honcho-setup): replace deployment-shape prompt with gateway-gated identity tree
The single/multi/hybrid 'deployment shape' was a misnomer: these keys only
affect the gateway (the one entrypoint supplying a runtime user ID), and the
three preset names stamped a lossy taxonomy onto three orthogonal knobs while
hiding which keys got written.

Replace it with an intent-led tree gated on gateway detection:
- _gateway_platforms() lazily inspects the gateway config (best-effort, no
  hard dependency); the step auto-skips when no platform is connected.
- 'who talks to this?' → just me / me+others (pooled?) / only others, deriving
  pinUserPeer + userPeerAliases + runtimePeerPrefix and echoing the result.
- [e] drops to a raw-knob editor for power users.
- The single→multi orphan guard survives as a pooling steer.
2026-06-10 16:14:24 -04:00
Erosika bb5cb32838 refactor(honcho): canonicalize identity-mapping on pinUserPeer, migrate legacy key
The setup wizard wrote the legacy pinPeerName even though pinUserPeer is
the canonical key that outranks it in the resolver — so it had to scrub
the canonical key afterward to stop it winning. Write pinUserPeer directly
and migrate any legacy pinPeerName onto it on touch (setup load + clone),
which removes the precedence-fighting entirely.

Resolver still reads pinPeerName as a back-compat alias; that's deferred.
2026-06-10 16:07:53 -04:00
131 changed files with 6701 additions and 1021 deletions
+2 -3
View File
@@ -1,12 +1,11 @@
name: Contributor Attribution Check
on:
pull_request:
branches: [main]
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
permissions:
contents: read
+4 -5
View File
@@ -18,13 +18,12 @@ on:
- docker/**
- .hadolint.yaml
- .github/workflows/docker-lint.yml
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- Dockerfile
- docker/**
- .hadolint.yaml
- .github/workflows/docker-lint.yml
permissions:
contents: read
+5 -8
View File
@@ -11,16 +11,13 @@ on:
- 'docker/**'
- '.github/workflows/docker-publish.yml'
- '.github/actions/hermes-smoke-test/**'
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- '**/*.py'
- 'pyproject.toml'
- 'uv.lock'
- 'Dockerfile'
- 'docker/**'
- '.github/workflows/docker-publish.yml'
- '.github/actions/hermes-smoke-test/**'
release:
types: [published]
+9 -7
View File
@@ -1,10 +1,12 @@
name: Docs Site Checks
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
paths:
- 'website/**'
- '.github/workflows/docs-site-checks.yml'
branches: [main]
workflow_dispatch:
permissions:
@@ -14,9 +16,9 @@ jobs:
docs-site-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
@@ -26,9 +28,9 @@ jobs:
run: npm ci
working-directory: website
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
python-version: "3.11"
- name: Install ascii-guard
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
+5 -2
View File
@@ -14,6 +14,9 @@ name: History Check
# the PR head and main to be non-empty.
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
@@ -24,9 +27,9 @@ jobs:
check-common-ancestor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # full history both sides for merge-base
fetch-depth: 0 # full history both sides for merge-base
- name: Reject PRs with no common ancestor on main
run: |
+4 -5
View File
@@ -15,12 +15,12 @@ on:
- "**/*.md"
- "docs/**"
- "website/**"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
- "website/**"
permissions:
contents: read
@@ -154,7 +154,6 @@ jobs:
});
}
ruff-blocking:
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
+10 -16
View File
@@ -20,29 +20,23 @@ name: OSV-Scanner
# vulnerabilities in pinned deps that we may need to patch deliberately.
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- 'uv.lock'
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'ui-tui/package.json'
- 'website/package.json'
- 'website/package-lock.json'
- '.github/workflows/osv-scanner.yml'
push:
branches: [main]
paths:
- 'uv.lock'
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'website/package-lock.json'
- "uv.lock"
- "pyproject.toml"
- "package.json"
- "package-lock.json"
- "website/package-lock.json"
schedule:
# Weekly scan against main — catches CVEs published after merge for
# deps that haven't changed since.
- cron: '0 9 * * 1'
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
@@ -54,7 +48,7 @@ permissions:
jobs:
scan:
name: Scan lockfiles
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
# Scan explicit lockfiles rather than recursing, so we only look at
# the three sources of truth and skip vendored / test / worktree dirs.
+6 -6
View File
@@ -1,11 +1,11 @@
name: Supply Chain Audit
on:
pull_request:
types: [opened, synchronize, reopened]
# No paths filter — the jobs must always run so required checks
# report a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
@@ -32,7 +32,7 @@ jobs:
# True when the curated MCP catalog / bundled MCP manifests changed.
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Check for relevant file changes
@@ -72,7 +72,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -207,7 +207,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -286,7 +286,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
+4 -4
View File
@@ -6,11 +6,11 @@ on:
paths-ignore:
- "**/*.md"
- "docs/**"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
permissions:
contents: read
@@ -219,4 +219,4 @@ jobs:
env:
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
NOUS_API_KEY: ""
+3
View File
@@ -4,6 +4,9 @@ name: Typecheck
on:
push:
branches: [main]
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
+9 -9
View File
@@ -47,15 +47,15 @@ on:
push:
branches: [main]
paths:
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/uv-lockfile-check.yml'
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/uv-lockfile-check.yml"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/uv-lockfile-check.yml'
permissions:
contents: read
@@ -71,10 +71,10 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
# `uv lock --check` re-resolves the project from pyproject.toml and
# compares the result to uv.lock, exiting non-zero if they disagree.
+1
View File
@@ -299,6 +299,7 @@ def init_agent(
# would mangle the escape sequences. None = use builtins.print.
agent._print_fn = None
agent.background_review_callback = None # Optional sync callback for gateway delivery
agent.memory_notifications = "on" # Memory update notifications: "off", "on", "verbose"
agent.skip_context_files = skip_context_files
agent.load_soul_identity = load_soul_identity
agent.pass_session_id = pass_session_id
+121 -19
View File
@@ -237,18 +237,25 @@ _COMBINED_REVIEW_PROMPT = (
def summarize_background_review_actions(
review_messages: List[Dict],
prior_snapshot: List[Dict],
notification_mode: str = "on",
) -> List[str]:
"""Build the human-facing action summary for a background review pass.
Walks the review agent's session messages and collects "successful tool
action" descriptions to surface to the user (e.g. "Memory updated").
Tool messages already present in ``prior_snapshot`` are skipped so we
don't re-surface stale results from the prior conversation that the
review agent inherited via ``conversation_history`` (issue #14944).
Walks the review agent's session messages and collects successful memory
and skill-management actions to surface to the user. Tool messages already
present in ``prior_snapshot`` are skipped so stale inherited results are
not re-surfaced as fresh background work (issue #14944).
Matching is by ``tool_call_id`` when available, with a content-equality
fallback for tool messages that lack one.
``notification_mode`` controls display detail:
- ``off``: return no actions.
- ``on``: generic "Memory updated"/tool messages.
- ``verbose``: include compact content previews from tool-call arguments.
"""
mode = str(notification_mode or "on").lower()
if mode == "off":
return []
verbose = mode == "verbose"
existing_tool_call_ids = set()
existing_tool_contents = set()
for prior in prior_snapshot or []:
@@ -262,6 +269,42 @@ def summarize_background_review_actions(
if isinstance(content, str):
existing_tool_contents.add(content)
# Map review-agent tool results back to the calls that produced them. The
# result JSON only says "Entry added"; the call arguments contain action,
# target, and content previews. Restricting to notify_tools also prevents
# helper tools from surfacing as memory work just because they succeeded.
notify_tools = {"memory", "skill_manage"}
all_tool_call_ids: set = set()
call_details: dict = {}
for msg in review_messages or []:
if not isinstance(msg, dict) or msg.get("role") != "assistant":
continue
for tc in msg.get("tool_calls", []) or []:
if not isinstance(tc, dict):
continue
fn = tc.get("function", {}) or {}
fn_name = fn.get("name", "")
tcid = tc.get("id")
if tcid:
all_tool_call_ids.add(tcid)
if fn_name not in notify_tools:
continue
try:
args = json.loads(fn.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
args = {}
if tcid:
call_details[tcid] = {
"tool": fn_name,
"action": args.get("action", "?"),
"target": args.get("target", "memory"),
"content": args.get("content", ""),
"old_text": args.get("old_text", ""),
"name": args.get("name", ""),
"old_string": args.get("old_string", ""),
"new_string": args.get("new_string", ""),
}
actions: List[str] = []
for msg in review_messages or []:
if not isinstance(msg, dict) or msg.get("role") != "tool":
@@ -273,6 +316,8 @@ def summarize_background_review_actions(
content_str = msg.get("content")
if isinstance(content_str, str) and content_str in existing_tool_contents:
continue
if tcid and all_tool_call_ids and tcid not in call_details:
continue
try:
data = json.loads(msg.get("content", "{}"))
except (json.JSONDecodeError, TypeError):
@@ -280,19 +325,75 @@ def summarize_background_review_actions(
if not isinstance(data, dict) or not data.get("success"):
continue
message = data.get("message", "")
target = data.get("target", "")
if "created" in message.lower():
actions.append(message)
elif "updated" in message.lower():
actions.append(message)
elif "added" in message.lower() or (target and "add" in message.lower()):
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
actions.append(f"{label} updated")
elif "Entry added" in message:
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
actions.append(f"{label} updated")
elif "removed" in message.lower() or "replaced" in message.lower():
detail = call_details.get(tcid, {})
target = data.get("target", "") or detail.get("target", "")
is_skill = detail.get("tool") == "skill_manage"
message_lower = message.lower()
if not verbose:
if "created" in message_lower:
actions.append(message)
continue
if "updated" in message_lower:
actions.append(message)
continue
if is_skill and "patched" in message_lower:
actions.append(message)
continue
if is_skill:
label = "Skill"
elif target:
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
else:
continue
if verbose:
action = detail.get("action", "")
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
max_preview = 120
if is_skill:
change = data.get("_change", {})
old_string = change.get("old", "") or detail.get("old_string", "")
new_string = change.get("new", "") or detail.get("new_string", "")
description = change.get("description", "")
if action == "patch" and (old_string or new_string):
old_preview = old_string[:80].replace("\n", " ") + (
"…" if len(old_string) > 80 else ""
)
new_preview = new_string[:80].replace("\n", " ") + (
"…" if len(new_string) > 80 else ""
)
actions.append(
f"📝 Skill '{skill_name}' patched: "
f"\"{old_preview}\" → \"{new_preview}\""
)
elif action == "create" and description:
actions.append(f"📝 Skill '{skill_name}' created: {description}")
elif action == "edit" and description:
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
else:
actions.append(f"📝 {message}" if message else f"Skill {action}")
elif action == "add" and content:
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
actions.append(f"{label} ➕ {preview}")
elif action == "replace" and content:
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
actions.append(f"{label} ✏️ {preview}")
elif action == "remove" and old_text:
preview = old_text[:60] + ("…" if len(old_text) > 60 else "")
actions.append(f"{label} ➖ {preview}")
else:
actions.append(f"{label} updated")
elif (
"added" in message_lower
or "replaced" in message_lower
or "removed" in message_lower
or (target and "add" in message.lower())
or "Entry added" in message
):
actions.append(f"{label} updated")
return actions
@@ -522,6 +623,7 @@ def _run_review_in_thread(
actions = summarize_background_review_actions(
review_messages,
messages_snapshot,
notification_mode=getattr(agent, "memory_notifications", "on"),
)
if actions:
+1 -1
View File
@@ -34,7 +34,7 @@ It builds and launches the GUI against your existing install — same config, ke
### Prebuilt installers
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop).
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/).
---
@@ -166,6 +166,39 @@ function profileRemoteOverride(config, profile) {
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}
/**
* In global-remote mode one backend serves every Desktop profile, so REST calls
* that are scoped by renderer-side `request.profile` must carry that scope as a
* query parameter. Local pooled backends and per-profile remote overrides do not
* need this: they already run against a backend scoped to the target profile.
*/
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
return path
}
const rawPath = String(path || '')
if (!rawPath) {
return path
}
let parsed
try {
parsed = new URL(rawPath, 'http://hermes.local')
} catch {
return path
}
if (parsed.searchParams.has('profile')) {
return path
}
parsed.searchParams.set('profile', scopedProfile)
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
function tokenPreview(value) {
const raw = String(value || '')
@@ -247,6 +280,7 @@ module.exports = {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -24,6 +24,7 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -90,6 +91,72 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride(null, 'coder'), null)
})
// --- pathWithGlobalRemoteProfile ---
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=iris'
)
})
test('pathWithGlobalRemoteProfile preserves existing query params', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/options?force=1&profile=iris'
)
})
test('pathWithGlobalRemoteProfile does not replace an explicit profile query', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info?profile=default', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=default'
)
})
test('pathWithGlobalRemoteProfile skips local and per-profile remote override paths', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: false,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: true
}),
'/api/model/info'
)
})
test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', '', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
''
)
})
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
+90 -58
View File
@@ -39,6 +39,7 @@ const { waitForDashboardPort } = require('./backend-ready.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
@@ -62,6 +63,7 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -242,6 +244,16 @@ if (INSTALL_STAMP) {
function resolveHermesHome() {
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
if (IS_WINDOWS) {
// A GUI app launched from Explorer inherits the environment block captured
// at login, so a HERMES_HOME set via `setx` AFTER login is invisible in
// process.env even though the CLI (a fresh shell) sees it. Without this the
// backend silently falls back to %LOCALAPPDATA%\hermes and reports "No
// inference provider configured" despite a valid configured home (#45471).
// Consult the live User-scoped registry value before the default below.
const fromRegistry = readWindowsUserEnvVar('HERMES_HOME')
if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry)
}
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
const legacy = path.join(app.getPath('home'), '.hermes')
@@ -5072,65 +5084,75 @@ function focusWindow(win) {
win.focus()
}
function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(win)
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch,
newSession
})
)
return win
}
// Open (or focus) a standalone window for a single chat session.
function createSessionWindow(sessionId, { watch = false } = {}) {
return sessionWindows.openOrFocus(sessionId, () => {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
}
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(win)
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch
})
)
return win
})
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
// like ⌘N in a browser, every press opens a new window — and a draft window that
// later converts to a real session must not get refocused as if it were blank.
function createNewSessionWindow() {
return spawnSecondaryWindow({ newSession: true })
}
function createWindow() {
@@ -5317,6 +5339,11 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
return { ok: true }
})
ipcMain.handle('hermes:window:openNewSession', async () => {
createNewSessionWindow()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
@@ -5586,9 +5613,14 @@ ipcMain.handle('hermes:api', async (_event, request) => {
await prepareProfileDeleteRequest(request)
const connection = await ensureBackend(request?.profile)
const profile = request?.profile
const connection = await ensureBackend(profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
globalRemote: globalRemoteActive(),
profileRemoteOverride: profileHasRemoteOverride(profile)
})
const url = `${connection.baseUrl}${requestPath}`
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
// the OAuth partition — route through Electron's net stack bound to that
// session so the cookie attaches automatically. Token/local modes keep using
+1
View File
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
+7 -6
View File
@@ -15,12 +15,13 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
// treated as the route by HashRouter and would break routeSessionId(). The
// renderer reads the flag from window.location.search to suppress the install /
// onboarding overlays and the global session sidebar. `watch=1` marks a
// spectator window (e.g. a running subagent's session): the renderer resumes
// it lazily so the gateway never builds an agent just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) {
const query = `?win=secondary${watch ? '&watch=1' : ''}`
const route = `#/${encodeURIComponent(sessionId)}`
// onboarding overlays and the global session sidebar. `new=1` marks the compact
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
// session): the renderer resumes it lazily so the gateway never builds an agent
// just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
@@ -82,6 +82,12 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
})
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
})
test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry()
let built = 0
@@ -0,0 +1,76 @@
// windows-user-env.cjs
//
// Read a User-scoped environment variable straight from the Windows registry
// (HKCU\Environment).
//
// A GUI app launched from Explorer inherits the environment block captured at
// login, so a variable set via `setx` AFTER login is invisible in process.env
// even though a fresh shell — and the Hermes CLI — sees it immediately. The
// desktop's HERMES_HOME resolution relies on process.env, so that stale-snapshot
// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading
// the live registry value closes the gap. See #45471.
const { execFileSync } = require('node:child_process')
// Parse the output of `reg query HKCU\Environment /v <name>`, which looks like:
//
// HKEY_CURRENT_USER\Environment
// HERMES_HOME REG_SZ F:\Hermes\data
//
// Returns the raw value string (spaces inside the value preserved), or null when
// the requested value line isn't present.
function parseRegQueryValue(stdout, name) {
if (!stdout || !name) return null
const typePattern =
/^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
for (const rawLine of String(stdout).split(/\r?\n/)) {
const line = rawLine.trim()
const match = line.match(typePattern)
if (match && match[1].toLowerCase() === name.toLowerCase()) {
return match[2]
}
}
return null
}
// Expand %VAR% references against an env map. REG_EXPAND_SZ values store
// unexpanded references; plain REG_SZ paths have none, so this is a no-op for
// the common F:\... case. Unknown references are left verbatim.
function expandWindowsEnvRefs(value, env = process.env) {
if (!value) return value
return value.replace(/%([^%]+)%/g, (whole, name) => {
const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase())
return key != null && env[key] != null ? env[key] : whole
})
}
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
// (the value doesn't exist), or when the value is empty.
function readWindowsUserEnvVar(
name,
{ platform = process.platform, env = process.env, exec = execFileSync } = {}
) {
if (platform !== 'win32' || !name) return null
let stdout
try {
stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], {
encoding: 'utf8',
windowsHide: true,
timeout: 5000
})
} catch {
// `reg` missing, or value absent (reg exits 1) — caller falls back.
return null
}
const raw = parseRegQueryValue(stdout, name)
if (raw == null) return null
const expanded = expandWindowsEnvRefs(raw, env).trim()
return expanded || null
}
module.exports = {
expandWindowsEnvRefs,
parseRegQueryValue,
readWindowsUserEnvVar
}
@@ -0,0 +1,90 @@
const assert = require('node:assert/strict')
const { test } = require('node:test')
const {
expandWindowsEnvRefs,
parseRegQueryValue,
readWindowsUserEnvVar
} = require('./windows-user-env.cjs')
// ── parseRegQueryValue ─────────────────────────────────────────────────────
test('parseRegQueryValue extracts a REG_SZ value', () => {
const out = [
'',
'HKEY_CURRENT_USER\\Environment',
' HERMES_HOME REG_SZ F:\\Hermes\\data',
''
].join('\r\n')
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data')
})
test('parseRegQueryValue matches the name case-insensitively', () => {
const out = 'HKEY_CURRENT_USER\\Environment\r\n Hermes_Home REG_EXPAND_SZ %USERPROFILE%\\h\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), '%USERPROFILE%\\h')
})
test('parseRegQueryValue preserves spaces inside the value', () => {
const out = ' HERMES_HOME REG_SZ C:\\Program Files\\Hermes\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'C:\\Program Files\\Hermes')
})
test('parseRegQueryValue returns null when the value line is absent', () => {
const out = 'HKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\x\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), null)
assert.equal(parseRegQueryValue('', 'HERMES_HOME'), null)
assert.equal(parseRegQueryValue('garbage', 'HERMES_HOME'), null)
})
// ── expandWindowsEnvRefs ───────────────────────────────────────────────────
test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => {
assert.equal(
expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }),
'C:\\Users\\jeff\\h'
)
})
test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => {
assert.equal(expandWindowsEnvRefs('F:\\Hermes\\data', {}), 'F:\\Hermes\\data')
assert.equal(expandWindowsEnvRefs('%NOPE%\\x', {}), '%NOPE%\\x')
})
// ── readWindowsUserEnvVar ──────────────────────────────────────────────────
test('readWindowsUserEnvVar returns null off Windows without spawning', () => {
let spawned = false
const exec = () => {
spawned = true
return ''
}
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null)
assert.equal(spawned, false)
})
test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => {
const calls = []
const exec = (cmd, args) => {
calls.push([cmd, args])
return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n'
}
const value = readWindowsUserEnvVar('HERMES_HOME', {
platform: 'win32',
env: { DRIVE: 'F:' },
exec
})
assert.equal(value, 'F:\\Hermes')
assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]])
})
test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)', () => {
const exec = () => {
throw new Error('reg exited 1')
}
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
})
test('readWindowsUserEnvVar returns null for an empty value', () => {
const exec = () => ' HERMES_HOME REG_SZ \r\n'
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
})
+3 -1
View File
@@ -20,6 +20,7 @@
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
@@ -36,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -134,6 +135,7 @@
},
"build": {
"electronVersion": "40.9.3",
"electronDist": "../../node_modules/electron/dist",
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",
@@ -0,0 +1,59 @@
const fs = require('node:fs')
const path = require('node:path')
if (process.platform !== 'darwin') {
process.exit(0)
}
const desktopRoot = path.resolve(__dirname, '..')
const repoRoot = path.resolve(desktopRoot, '..', '..')
const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js')
const marker = 'hermes-macos-electron-binary-fallback'
const needle = ` await Promise.all([
doRename(path.join(contentsPath, "MacOS"), electronBranding.productName, appPlist.CFBundleExecutable),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
]);`
const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes copy
// Electron.app without its main MacOS/Electron binary before this rename.
// Restore it from the installed Electron runtime so local desktop installs
// do not fail with ENOENT during macOS arm64 packaging.
const macosDir = path.join(contentsPath, "MacOS");
const bundledElectronBinary = path.join(macosDir, electronBranding.productName);
if (!fs.existsSync(bundledElectronBinary)) {
const candidates = [
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
];
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
if (sourceBinary == null) {
throw new Error("Electron binary missing from packaged app and Electron runtime: " + bundledElectronBinary);
}
await (0, promises_1.copyFile)(sourceBinary, bundledElectronBinary);
await (0, promises_1.chmod)(bundledElectronBinary, 0o755);
}
await Promise.all([
doRename(macosDir, electronBranding.productName, appPlist.CFBundleExecutable),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
]);`
if (!fs.existsSync(electronMacPath)) {
console.warn(`[patch-electron-builder] skipped: ${electronMacPath} not found`)
process.exit(0)
}
const source = fs.readFileSync(electronMacPath, 'utf8')
if (source.includes(marker)) {
console.log('[patch-electron-builder] macOS Electron binary fallback already applied')
process.exit(0)
}
if (!source.includes(needle)) {
console.warn('[patch-electron-builder] skipped: expected electronMac.js shape not found')
process.exit(0)
}
fs.writeFileSync(electronMacPath, source.replace(needle, replacement))
console.log('[patch-electron-builder] applied macOS Electron binary fallback')
+4 -8
View File
@@ -23,6 +23,7 @@ import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
import { mediaExternalUrl } from '@/lib/media'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
@@ -124,17 +125,12 @@ function artifactKind(value: string): ArtifactKind {
}
function artifactHref(value: string): string {
if (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('file://') ||
value.startsWith('data:')
) {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) {
return value
}
if (value.startsWith('/')) {
return `file://${encodeURI(value)}`
if (value.startsWith('file://') || value.startsWith('/')) {
return mediaExternalUrl(value)
}
return value
+6 -2
View File
@@ -42,6 +42,7 @@ import {
$sessions,
sessionPinId
} from '@/store/session'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
@@ -122,7 +123,7 @@ function ChatHeader({
// A brand-new session has no session to pin/delete/rename, so the header is
// just a dead "New session" label + chevron. Drop it (and its border)
// entirely until there's a real session to act on.
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
return null
}
@@ -302,7 +303,10 @@ export function ChatView({
// waiting for the resume effect (which paints a frame later) to clear them.
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
!isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
@@ -77,6 +77,7 @@ import {
setSessionsLoading,
setSessionsTotal
} from '../store/session'
import { onSessionsChanged } from '../store/session-sync'
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
import { isSecondaryWindow } from '../store/windows'
@@ -464,6 +465,17 @@ export function DesktopController() {
void refreshSessions()
}, [refreshSessions])
// Another window mutated the shared session list (e.g. a chat started in the
// pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so
// only real windows bother.
useEffect(() => {
if (isSecondaryWindow()) {
return
}
return onSessionsChanged(() => void refreshSessions().catch(() => undefined))
}, [refreshSessions])
// ALL-profiles view pages one profile at a time: fetch that profile's next
// page and merge it in place, leaving every other profile's rows untouched.
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
@@ -37,6 +37,7 @@ import {
switcherActive,
switcherJustClosed
} from '@/store/session-switcher'
import { openNewSessionInNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
@@ -132,6 +133,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.newWindow': () => void openNewSessionInNewWindow(),
'session.next': () => stepSession(1),
'session.prev': () => stepSession(-1),
...sessionSlotHandlers,
@@ -0,0 +1,75 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirResult } from '@/global'
import { $connection, setCurrentCwd } from '@/store/session'
import { resetProjectTreeState } from './files/use-project-tree'
import { RightSidebarPane } from './index'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const selectPaths = vi.fn()
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
return { entries }
}
function installBridge() {
;(
window as unknown as {
hermesDesktop: {
readDir: typeof readDir
selectPaths: typeof selectPaths
}
}
).hermesDesktop = { readDir, selectPaths }
}
describe('RightSidebarPane', () => {
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
setCurrentCwd('/repo')
readDir.mockReset()
selectPaths.mockReset()
readDir.mockResolvedValue(ok([{ name: 'README.md', path: '/repo/README.md', isDirectory: false }]))
selectPaths.mockResolvedValue(['/repo-next'])
installBridge()
})
afterEach(() => {
cleanup()
$connection.set(null)
setCurrentCwd('')
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('refreshes the current tree without opening the folder picker', async () => {
const onChangeCwd = vi.fn()
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} onChangeCwd={onChangeCwd} />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh tree' }).hasAttribute('disabled')).toBe(false))
readDir.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Refresh tree' }))
await waitFor(() => expect(readDir).toHaveBeenCalledWith('/repo'))
expect(selectPaths).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Open folder' }))
await waitFor(() =>
expect(selectPaths).toHaveBeenCalledWith({
defaultPath: '/repo',
directories: true,
multiple: false,
title: 'Change working directory'
})
)
await waitFor(() => expect(onChangeCwd).toHaveBeenCalledWith('/repo-next'))
})
})
+5 -5
View File
@@ -126,12 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps {
onRefresh: () => void
}
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
// stays visible while any folder is expanded.
// Sidebar palette + hover-reveal: header actions stay reachable while moving
// from the project label to the action buttons.
const HEADER_ACTION_CLASS =
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
function FilesystemTab({
canCollapse,
@@ -158,7 +158,7 @@ function FilesystemTab({
return (
<div className="flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<div className="peer/project-label flex min-w-0 flex-1">
<div className="flex min-w-0 flex-1">
<button
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
@@ -216,7 +216,7 @@ function FilesystemTab({
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
return <div className="group/project-header flex h-7 shrink-0 items-center px-2.5">{children}</div>
}
interface FileTreeBodyProps {
@@ -47,6 +47,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { setSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
@@ -641,6 +642,9 @@ export function useMessageStream({
})
void refreshSessions().catch(() => undefined)
// Sync the freshly-titled row to other windows (e.g. main, when the turn
// ran in the pop-out).
broadcastSessionsChanged()
if (compactedTurnRef.current.delete(sessionId)) {
shouldHydrate = false
@@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import { cleanup, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelInfo } from '@/hermes'
@@ -13,12 +13,51 @@ import {
import { useModelControls } from './use-model-controls'
const setGlobalModel = vi.fn()
const notifyError = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelInfo: vi.fn(),
setGlobalModel: vi.fn()
setGlobalModel: (...args: Parameters<typeof setGlobalModel>) => setGlobalModel(...args)
}))
describe('useModelControls.refreshCurrentModel', () => {
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
desktop: {
modelSwitchFailed: 'Model switch failed'
}
}
})
}))
vi.mock('@/store/notifications', () => ({
notifyError: (...args: Parameters<typeof notifyError>) => notifyError(...args)
}))
type Controls = ReturnType<typeof useModelControls>
function Harness({
activeSessionId,
onReady,
requestGateway
}: {
activeSessionId: string | null
onReady: (controls: Controls) => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const controls = useModelControls({
activeSessionId,
queryClient: new QueryClient(),
requestGateway
})
onReady(controls)
return null
}
describe('useModelControls', () => {
beforeEach(() => {
$activeSessionId.set(null)
setCurrentModel('')
@@ -26,6 +65,7 @@ describe('useModelControls.refreshCurrentModel', () => {
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
$activeSessionId.set(null)
setCurrentModel('')
@@ -74,4 +114,55 @@ describe('useModelControls.refreshCurrentModel', () => {
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
expect($currentProvider.get()).toBe('deepseek')
})
it('routes active-session picker changes through config.set with an explicit provider', async () => {
const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never)
let controls!: Controls
render(
<Harness
activeSessionId="session-1"
onReady={value => (controls = value)}
requestGateway={requestGateway}
/>
)
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic'
})
).resolves.toBe(true)
expect(requestGateway).toHaveBeenCalledWith('config.set', {
session_id: 'session-1',
key: 'model',
value: 'claude-sonnet-4.6 --provider anthropic'
})
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('keeps the global path on setGlobalModel when there is no active session', async () => {
setGlobalModel.mockResolvedValue(undefined)
let controls!: Controls
render(
<Harness
activeSessionId={null}
onReady={value => (controls = value)}
requestGateway={vi.fn()}
/>
)
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic'
})
).resolves.toBe(true)
expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6')
})
})
@@ -82,9 +82,10 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
try {
if (activeSessionId) {
await requestGateway('slash.exec', {
await requestGateway('config.set', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
key: 'model',
value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
if (selection.persistGlobal) {
@@ -42,6 +42,7 @@ import {
setYoloActive,
workspaceCwdForNewSession
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { reportBackendContract } from '@/store/updates'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
@@ -472,6 +473,9 @@ export function useSessionActions({
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
// Other windows (e.g. the main window when this is the pop-out) can't
// see this session until they re-pull the shared list.
broadcastSessionsChanged()
}
setFreshDraftReady(false)
+8 -3
View File
@@ -16,7 +16,7 @@ import {
} from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
@@ -80,6 +80,7 @@ export function AppShell({
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
const hideTitlebarControls = isNewSessionWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
// on macOS, where window controls sit on the left and are reported via
@@ -162,7 +163,9 @@ export function AppShell({
} as CSSProperties
}
>
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
{!hideTitlebarControls && (
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
)}
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
<PaneShell className="min-h-0 flex-1">
@@ -183,7 +186,9 @@ export function AppShell({
the panes' z-20 resize handles, keeping every pane resizable. */}
{mainOverlays}
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
{/* The compact pop-out drops the statusbar — it's a scratch window, not
the full shell. */}
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
</main>
{overlays}
@@ -1,5 +1,6 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import {
type CSSProperties,
type ComponentProps,
type FC,
memo,
@@ -21,6 +22,7 @@ import {
resetThreadScroll,
setThreadAtBottom
} from '@/store/thread-scroll'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import { MessageRenderBoundary } from './message-render-boundary'
@@ -132,6 +134,13 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
const hiddenCount = firstVisible
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null)
const newSessionWindow = isNewSessionWindow()
const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)'
const threadContentTopPad = newSessionWindow
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: isSecondaryWindow()
? 'pt-6'
: 'pt-[calc(var(--titlebar-height)+1.5rem)]'
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
useEffect(() => () => resetThreadScroll(), [])
@@ -235,7 +244,12 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
style={
{
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {})
} as CSSProperties
}
>
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
@@ -252,9 +266,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
</div>
) : (
<div
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
className={cn('mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6', threadContentTopPad)}
data-slot="aui_thread-content"
ref={contentRef as React.RefCallback<HTMLDivElement>}
>
+2
View File
@@ -24,6 +24,8 @@ declare global {
// a spectator window (lazy resume — no agent build) for live-streaming
// a running subagent's session.
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
// Open (or focus) a compact secondary window on the new-session draft.
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
+1
View File
@@ -189,6 +189,7 @@ export const en: Translations = {
'nav.cron': 'Open scheduled jobs',
'nav.agents': 'Open agents',
'session.new': 'New session',
'session.newWindow': 'New session in window',
'session.next': 'Next session',
'session.prev': 'Previous session',
'session.slot.1': 'Switch to recent session 1',
+1
View File
@@ -185,6 +185,7 @@ export const zh: Translations = {
'nav.cron': '打开定时任务',
'nav.agents': '打开智能体',
'session.new': '新建会话',
'session.newWindow': '在新窗口中新建会话',
'session.next': '下一个会话',
'session.prev': '上一个会话',
'session.slot.1': '切换到最近会话 1',
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { ChatMessage, ChatMessagePart } from './chat-messages'
import {
appendAssistantTextPart,
appendReasoningPart,
chatMessageText,
preserveLocalAssistantErrors,
renderMediaTags,
@@ -175,6 +176,52 @@ describe('renderMediaTags', () => {
})
})
describe('interleaved reasoning/text coalescing', () => {
it('keeps narration contiguous when reasoning interrupts mid-sentence', () => {
// Models that interleave reasoning_content + content deltas emit
// text → reasoning → text within one tool-bounded segment. The two text
// fragments are really one sentence and must not be split by the
// "Thinking" block between them.
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me ')
parts = appendReasoningPart(parts, 'checking the file...')
parts = appendAssistantTextPart(parts, 'verify the full file is correct:')
expect(parts.map(p => p.type)).toEqual(['text', 'reasoning'])
expect((parts[0] as { text: string }).text).toBe('Let me verify the full file is correct:')
expect((parts[1] as { text: string }).text).toBe('checking the file...')
})
it('merges reasoning bursts that straddle a narration fragment', () => {
let parts: ChatMessagePart[] = appendReasoningPart([], 'first thought ')
parts = appendAssistantTextPart(parts, 'Working on it.')
parts = appendReasoningPart(parts, 'second thought')
expect(parts.map(p => p.type)).toEqual(['reasoning', 'text'])
expect((parts[0] as { text: string }).text).toBe('first thought second thought')
expect((parts[1] as { text: string }).text).toBe('Working on it.')
})
it('starts a fresh text part after a tool call (segment boundary)', () => {
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me check.')
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
parts = appendAssistantTextPart(parts, 'Now editing.')
expect(parts.map(p => p.type)).toEqual(['text', 'tool-call', 'text'])
expect((parts[0] as { text: string }).text).toBe('Let me check.')
expect((parts[2] as { text: string }).text).toBe('Now editing.')
})
it('does not merge reasoning across a tool call', () => {
let parts: ChatMessagePart[] = appendReasoningPart([], 'before tool')
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
parts = appendReasoningPart(parts, 'after tool')
expect(parts.map(p => p.type)).toEqual(['reasoning', 'tool-call', 'reasoning'])
expect((parts[0] as { text: string }).text).toBe('before tool')
expect((parts[2] as { text: string }).text).toBe('after tool')
})
})
describe('preserveLocalAssistantErrors', () => {
it('preserves a local user+error pair when hydration omits the failed turn', () => {
const nextMessages: ChatMessage[] = [
+49 -29
View File
@@ -178,50 +178,70 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
}
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const next = [...parts]
const last = next.at(-1)
if (last?.type === 'text') {
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
return next
}
next.push(textPart(delta))
return next
const STREAM_PART: Record<'reasoning' | 'text', (text: string) => ChatMessagePart> = {
reasoning: reasoningPart,
text: textPart
}
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const next = appendTextPart(parts, delta)
const last = next.at(-1)
// Coalesce a streaming delta into the most recent same-type part within the
// current segment, where a segment is bounded by any non-streaming part (a
// tool call, image, …). The opposite streaming channel (text <-> reasoning) is
// transparent, so a reasoning burst between two content deltas can't shred one
// sentence into text / Thinking / text — the fragmentation models that
// interleave reasoning_content + content otherwise produce. Tool calls still
// open a fresh part, preserving narration order across steps.
function appendStreamPart(
parts: ChatMessagePart[],
type: 'reasoning' | 'text',
delta: string
): { index: number; parts: ChatMessagePart[] } {
const next = [...parts]
if (last?.type === 'text') {
const current = last.text
for (let i = next.length - 1; i >= 0; i--) {
const part = next[i]
const deltaMayContainMedia =
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
if (part.type === type) {
next[i] = { ...part, text: `${(part as { text: string }).text}${delta}` } as ChatMessagePart
const needsMediaPass = deltaMayContainMedia || current.includes('MEDIA:')
const nextText = needsMediaPass ? renderMediaTags(current) : current
next[next.length - 1] = nextText === current ? last : { ...last, text: nextText }
return { index: i, parts: next }
}
if (part.type !== 'text' && part.type !== 'reasoning') {
break
}
}
return next
next.push(STREAM_PART[type](delta))
return { index: next.length - 1, parts: next }
}
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
return appendStreamPart(parts, 'text', delta).parts
}
export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const next = [...parts]
const last = next.at(-1)
return appendStreamPart(parts, 'reasoning', delta).parts
}
if (last?.type === 'reasoning') {
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const { index, parts: next } = appendStreamPart(parts, 'text', delta)
const part = next[index]
if (part?.type !== 'text') {
return next
}
next.push(reasoningPart(delta))
const mayContainMedia =
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
if (mayContainMedia || part.text.includes('MEDIA:')) {
const rendered = renderMediaTags(part.text)
if (rendered !== part.text) {
next[index] = { ...part, text: rendered }
}
}
return next
}
+1
View File
@@ -66,6 +66,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Session ──────────────────────────────────────────────────────────────
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
{ id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] },
// ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
{ id: 'session.next', category: 'session', defaults: ['ctrl+tab'] },
+33 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway } from './media'
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl } from './media'
describe('isRemoteGateway', () => {
afterEach(() => {
@@ -35,6 +35,38 @@ describe('filePathFromMediaPath', () => {
})
})
describe('mediaExternalUrl', () => {
afterEach(() => {
$connection.set(null)
})
it('passes through http(s) URLs untouched', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 't' } as never)
expect(mediaExternalUrl('https://example.com/a.png')).toBe('https://example.com/a.png')
})
it('keeps file:// form in local mode', () => {
$connection.set({ mode: 'local' } as never)
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
expect(mediaExternalUrl('file:///tmp/a.png')).toBe('file:///tmp/a.png')
})
it('rewrites gateway-local paths to an authenticated download URL', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 's e/cret' } as never)
expect(mediaExternalUrl('file:///tmp/a b.png')).toBe(
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
)
expect(mediaExternalUrl('/tmp/a b.png')).toBe(
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
)
})
it('falls back to file:// when remote connection lacks a token', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://gw' } as never)
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
})
})
describe('gatewayMediaDataUrl', () => {
const api = vi.fn(async () => ({ data_url: 'data:image/png;base64,ZHVtbXk=' }))
+18 -1
View File
@@ -56,8 +56,25 @@ export function mediaMarkdownHref(path: string): string {
return `#media:${encodeURIComponent(path)}`
}
// Resolve a media path to a URL the shell can open. Remote mode rewrites
// gateway-local paths to an authenticated /api/files/download URL (the file
// lives on the gateway, not this disk); local mode keeps the file:// form.
export function mediaExternalUrl(path: string): string {
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
if (/^https?:/i.test(path)) {
return path
}
if (isRemoteGateway()) {
const conn = $connection.get()
if (conn?.baseUrl && conn.token) {
const file = encodeURIComponent(filePathFromMediaPath(path))
return `${conn.baseUrl}/api/files/download?path=${file}&token=${encodeURIComponent(conn.token)}`
}
}
return /^file:/i.test(path) ? path : `file://${path}`
}
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
+25
View File
@@ -0,0 +1,25 @@
// Cross-window session-list sync. Each desktop window is its own renderer
// process with its own gateway socket and session store, so a mutation in one
// (e.g. a new chat started in the compact pop-out) never reaches another
// window. This bus pings every window to re-pull the shared session list; the
// data already lives in the backend, the other window just doesn't know to look.
const CHANNEL = 'hermes:sessions'
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL)
// A window that mutated the session list (created / titled a chat) tells the
// others to refresh. A BroadcastChannel never delivers to its own poster, so the
// caller refreshes locally as it already does.
export function broadcastSessionsChanged(): void {
channel?.postMessage(1)
}
export function onSessionsChanged(handler: () => void): () => void {
if (!channel) {
return () => {}
}
channel.addEventListener('message', handler)
return () => channel.removeEventListener('message', handler)
}
+43 -3
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { canOpenSessionWindow, openSessionInNewWindow } from './windows'
import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow } from './windows'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
@@ -11,9 +11,13 @@ vi.mock('./notifications', () => ({
notifyError: (...args: unknown[]) => notifyError(...args)
}))
function installBridge(openSessionWindow?: Window['hermesDesktop']['openSessionWindow']) {
function installBridge(
openSessionWindow?: Window['hermesDesktop']['openSessionWindow'],
openNewSessionWindow?: Window['hermesDesktop']['openNewSessionWindow']
) {
desktopWindow.hermesDesktop = {
...(openSessionWindow ? { openSessionWindow } : {})
...(openSessionWindow ? { openSessionWindow } : {}),
...(openNewSessionWindow ? { openNewSessionWindow } : {})
} as unknown as Window['hermesDesktop']
}
@@ -101,3 +105,39 @@ describe('openSessionInNewWindow', () => {
expect(notifyError).toHaveBeenCalledTimes(1)
})
})
describe('openNewSessionInNewWindow', () => {
it('no-ops gracefully when the bridge is absent (web fallback)', async () => {
delete desktopWindow.hermesDesktop
await openNewSessionInNewWindow()
expect(notifyError).not.toHaveBeenCalled()
})
it('no-ops when openNewSessionWindow is missing', async () => {
installBridge(vi.fn().mockResolvedValue({ ok: true }))
await openNewSessionInNewWindow()
expect(notifyError).not.toHaveBeenCalled()
})
it('invokes the bridge', async () => {
const openNew = vi.fn().mockResolvedValue({ ok: true })
installBridge(vi.fn().mockResolvedValue({ ok: true }), openNew)
await openNewSessionInNewWindow()
expect(openNew).toHaveBeenCalledTimes(1)
expect(notifyError).not.toHaveBeenCalled()
})
it('notifies on an ok:false result', async () => {
installBridge(vi.fn().mockResolvedValue({ ok: true }), vi.fn().mockResolvedValue({ ok: false, error: 'nope' }))
await openNewSessionInNewWindow()
expect(notifyError).toHaveBeenCalledTimes(1)
})
})
+45 -7
View File
@@ -6,6 +6,7 @@ import { notifyError } from './notifications'
// never from the router. A "secondary" window renders a single chat without the
// global session sidebar or the install / onboarding overlays.
const SECONDARY_WINDOW_FLAG = 'secondary'
const NEW_SESSION_WINDOW_FLAG = '1'
let secondaryWindowCache: boolean | null = null
@@ -27,6 +28,26 @@ export function isSecondaryWindow(): boolean {
return result
}
let newSessionWindowCache: boolean | null = null
export function isNewSessionWindow(): boolean {
if (newSessionWindowCache !== null) {
return newSessionWindowCache
}
let result = false
try {
result = new URLSearchParams(window.location.search).get('new') === NEW_SESSION_WINDOW_FLAG
} catch {
result = false
}
newSessionWindowCache = result
return result
}
let watchWindowCache: boolean | null = null
// A "watch" window spectates a session that is being driven elsewhere (a
@@ -57,6 +78,22 @@ export function canOpenSessionWindow(): boolean {
return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === 'function'
}
type WindowOpenResult = { ok: boolean; error?: string } | undefined
// Run a window-open bridge call, surfacing any failure as a toast. Shared by the
// session pop-out and the new-session pop-out.
async function openWindow(call: () => Promise<WindowOpenResult>, failMessage: string): Promise<void> {
try {
const result = await call()
if (!result?.ok) {
notifyError(new Error(result?.error || 'unknown error'), failMessage)
}
} catch (err) {
notifyError(err, failMessage)
}
}
// Open (or focus) a standalone OS window for a single chat session. No-ops
// gracefully outside Electron so callers can wire it unconditionally.
// `watch: true` opens a spectator window (lazy resume, live-mirror stream).
@@ -65,13 +102,14 @@ export async function openSessionInNewWindow(sessionId: string, opts?: { watch?:
return
}
try {
const result = await window.hermesDesktop.openSessionWindow(sessionId, opts)
await openWindow(() => window.hermesDesktop.openSessionWindow(sessionId, opts), 'Could not open chat in a new window')
}
if (!result?.ok) {
notifyError(new Error(result?.error || 'unknown error'), 'Could not open chat in a new window')
}
} catch (err) {
notifyError(err, 'Could not open chat in a new window')
// Open a fresh compact window on the new-session draft.
export async function openNewSessionInNewWindow(): Promise<void> {
if (!canOpenSessionWindow() || typeof window.hermesDesktop.openNewSessionWindow !== 'function') {
return
}
await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window')
}
+1 -1
View File
@@ -724,7 +724,7 @@ platform_toolsets:
# # allowed_chats: ["-1001234567890"]
# extra:
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2
#
# Discord-specific settings (config.yaml top-level, not under platforms:):
#
+5
View File
@@ -977,6 +977,11 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
_cleanup_all_terminals()
except Exception:
pass
try:
from tools.async_delegation import interrupt_all as _interrupt_async_delegations
_interrupt_async_delegations(reason="CLI shutdown")
except Exception:
pass
try:
_cleanup_all_browsers()
except Exception:
+4
View File
@@ -32,6 +32,7 @@ from typing import Any
_GLOBAL_DEFAULTS: dict[str, Any] = {
"tool_progress": "all",
"tool_progress_grouping": "accumulate", # "accumulate" = edit one bubble; "separate" = one msg per tool
"show_reasoning": False,
"tool_preview_length": 0,
"streaming": None, # None = follow top-level streaming config
@@ -238,6 +239,9 @@ def _normalise(setting: str, value: Any) -> Any:
if isinstance(value, str):
return value.lower() in {"true", "1", "yes", "on"}
return bool(value)
if setting == "tool_progress_grouping":
val = str(value).lower()
return val if val in ("accumulate", "separate") else "accumulate"
if setting == "tool_preview_length":
try:
return int(value)
+140 -21
View File
@@ -419,11 +419,13 @@ class TelegramAdapter(BasePlatformAdapter):
self._mention_patterns = self._compile_mention_patterns()
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
# Bot API 10.1 Rich Messages: when explicitly enabled, send final
# replies via sendRichMessage with the raw agent markdown so
# tables/task lists/etc. render natively. Disabled by default because
# several Telegram clients accept but render rich messages poorly.
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
# Bot API 10.1 Rich Messages: render constructs the legacy MarkdownV2
# path degrades (tables → bullet lists, task lists, <details>, block
# math) via sendRichMessage / editMessageText's rich_message param using
# the raw agent markdown. Enabled by default; users can opt out for
# clients that accept but render rich messages poorly via
# platforms.telegram.extra.rich_messages: false.
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True)
# Latched off after a capability failure on sendRichMessage /
# sendRichMessageDraft (e.g. older python-telegram-bot without the
# endpoint) so later sends skip the doomed rich attempt entirely.
@@ -979,18 +981,54 @@ class TelegramAdapter(BasePlatformAdapter):
return True
return False
def _needs_rich_rendering(self, content: str) -> bool:
"""Return True for markdown constructs that the legacy path degrades.
Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram
clients render a consistent font weight/spacing. The rich endpoint is
reserved for constructs where raw markdown materially improves output:
pipe tables (MarkdownV2 has no table syntax and rewrites them into
bullet lists), GFM task lists, collapsible ``<details>`` blocks, and
block math. Adapted from #45995 (@YonganZhang).
"""
if not content:
return False
if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()):
return True
if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content):
return True
if re.search(r"(?m)^<details\b|^</details>|^<summary\b|^</summary>", content):
return True
if "$$" in content:
return True
return False
def _rich_eligible(self, content: str) -> bool:
"""Capability/content eligibility for rich, ignoring ``expect_edits``.
Shared core of :meth:`_should_attempt_rich` minus the per-call
``expect_edits`` metadata gate. The rich EDIT-finalize path
(:meth:`_try_edit_rich`) needs this: a streamed preview is sent with
``expect_edits=True`` to stay on the editable path mid-stream, but the
FINAL edit should still upgrade to rich when the content warrants it.
"""
return bool(
getattr(self, "_rich_messages_enabled", True)
and not getattr(self, "_rich_send_disabled", False)
and content
and content.strip()
and self._needs_rich_rendering(content)
and not self._has_telegram_desktop_details_math_crash_shape(content)
and self._content_fits_rich_limits(content)
and self._bot_supports_rich()
)
def _should_attempt_rich(
self, content: str, metadata: Optional[Dict[str, Any]] = None
) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
and not (metadata or {}).get("expect_edits")
and content
and content.strip()
and not self._has_telegram_desktop_details_math_crash_shape(content)
and self._content_fits_rich_limits(content)
and self._bot_supports_rich()
not (metadata or {}).get("expect_edits")
and self._rich_eligible(content)
)
def prefers_fresh_final_streaming(
@@ -998,12 +1036,13 @@ class TelegramAdapter(BasePlatformAdapter):
) -> bool:
"""Whether to replace a streamed preview with a fresh rich final.
Keep this disabled for Telegram. The fresh-final path briefly shows two
copies of the final answer, then deletes the streaming preview after the
rich send succeeds. That is especially visible on clients that support
rich messages well, and it looks like duplicate delivery at the end of
every streamed turn. Until Telegram rich edits are wired directly, final
streamed replies should edit the existing preview in place.
Disabled for Telegram. The fresh-final path briefly shows two copies of
the final answer, then deletes the streaming preview after the rich send
succeeds — it looks like duplicate delivery at the end of every streamed
turn (the reason #46206 reverted it). Rich finalize is instead handled
by editing the existing preview in place via Bot API 10.1's
``editMessageText`` ``rich_message`` parameter (see
:meth:`_try_edit_rich`), so no fresh re-send / delete is needed.
"""
return False
@@ -1019,7 +1058,7 @@ class TelegramAdapter(BasePlatformAdapter):
streams split exactly as before.
"""
if (
getattr(self, "_rich_messages_enabled", False)
getattr(self, "_rich_messages_enabled", True)
and not getattr(self, "_rich_send_disabled", False)
and self._bot_supports_rich()
):
@@ -1207,9 +1246,74 @@ class TelegramAdapter(BasePlatformAdapter):
message_id=str(message_id) if message_id is not None else None,
)
async def _try_edit_rich(
self,
chat_id: str,
message_id: str,
content: str,
) -> Optional[SendResult]:
"""Edit an existing message in place as a rich message (Bot API 10.1).
Uses ``editMessageText`` with the ``rich_message`` parameter so a
streamed preview can finalize as rich (tables/task lists/details/math)
WITHOUT a fresh send + delete — no duplicate preview. Mirrors
:meth:`_try_send_rich`'s error contract:
- success → ``SendResult(success=True, message_id=...)``
- permanent / capability error → ``None`` (caller falls back to the
legacy MarkdownV2 edit; capability errors latch rich off)
- transient / unknown → ``SendResult(success=False)`` with retry
semantics (the message may already be edited; do NOT legacy-resend)
"""
payload: Dict[str, Any] = {
"chat_id": int(chat_id),
"message_id": int(message_id),
"rich_message": self._rich_message_payload(content),
}
if getattr(self, "_disable_link_previews", False):
payload["link_preview_options"] = {"is_disabled": True}
try:
# Raw Bot API result; do not request return_type=Message (PTB does
# not fully model the 10.1 response shape yet — a post-edit parse
# error must not be mistaken for a failed edit).
await self._bot.do_api_request("editMessageText", api_kwargs=payload)
except Exception as exc:
if self._is_rich_fallback_error(exc):
if self._is_rich_capability_error(exc):
self._rich_send_disabled = True
# "Message is not modified" — content identical to the current
# rich message; treat as a successful no-op so the caller does
# not fall through to a redundant legacy edit.
if "not modified" in str(exc).lower():
return SendResult(success=True, message_id=message_id)
logger.debug(
"[%s] rich editMessageText rejected (%s) — falling back to MarkdownV2 edit",
self.name, exc,
)
return None
if "not modified" in str(exc).lower():
return SendResult(success=True, message_id=message_id)
err_str = str(exc).lower()
try:
from telegram.error import TimedOut as _TimedOut
except (ImportError, AttributeError):
_TimedOut = None
is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
is_connect_timeout = self._looks_like_connect_timeout(exc)
logger.warning(
"[%s] rich editMessageText transient failure (no legacy resend): %s",
self.name, exc,
)
return SendResult(
success=False,
error=str(exc),
retryable=(is_connect_timeout or not is_timeout),
)
return SendResult(success=True, message_id=message_id)
def _should_attempt_rich_draft(self, content: str) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", False)
getattr(self, "_rich_messages_enabled", True)
and not getattr(self, "_rich_send_disabled", False)
and not getattr(self, "_rich_draft_disabled", False)
and content
@@ -2555,6 +2659,21 @@ class TelegramAdapter(BasePlatformAdapter):
if not self._bot:
return SendResult(success=False, error="Not connected")
# Rich finalize (Bot API 10.1): when the completed content has
# constructs the legacy MarkdownV2 edit degrades (tables → bullet
# lists, task lists, <details>, block math) and rich is available,
# edit the preview IN PLACE via editMessageText's rich_message param.
# No fresh send + delete → no duplicate preview (the problem #46206
# reverted the fresh-final path for). Attempted before the 4,096
# overflow pre-flight because the rich text cap is 32,768 — a rich
# table that exceeds the MarkdownV2 limit must not be split into legacy
# chunks. Falls back to the legacy edit path (overflow split included)
# on capability/permanent rejection.
if finalize and self._rich_eligible(content):
rich_result = await self._try_edit_rich(chat_id, message_id, content)
if rich_result is not None:
return rich_result
# Pre-flight: if content already exceeds the limit, split-and-deliver
# without round-tripping a doomed edit.
if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
+151 -15
View File
@@ -402,6 +402,17 @@ async def _send_or_update_status_coro(adapter, chat_id, status_key, content, met
return await adapter.send(chat_id, content, metadata=metadata)
def _resolve_progress_thread_id(platform: Any, source_thread_id: Any, event_message_id: Any) -> Optional[str]:
"""Return thread/root ID that progress/status bubbles should target."""
platform_value = getattr(platform, "value", platform)
platform_key = str(platform_value or "").lower()
if source_thread_id:
return str(source_thread_id)
if platform_key in {"slack", "mattermost"} and event_message_id:
return str(event_message_id)
return None
def _telegramize_command_mentions(text: str, platform: Any) -> str:
"""Rewrite slash-command mentions to Telegram-valid command names.
@@ -1921,9 +1932,42 @@ def _format_gateway_process_notification(evt: dict) -> "str | None":
text += "]"
return text
if evt_type == "async_delegation":
# Reuse the shared rich formatter (self-contained task-source block).
from tools.process_registry import format_process_notification
return format_process_notification(evt)
return None
def _drain_gateway_watch_events(completion_queue) -> "list[dict]":
"""Drain gateway-owned watch events without spinning on requeued events.
Watch events are handled by the post-turn gateway drain. Process
completions are owned by their per-process watcher task, and async
delegation completions are owned by ``_async_delegation_watcher``.
Requeueing async events inside ``while not queue.empty()`` would make the
loop non-terminating, so detach the current batch first, then requeue any
events this drain does not own after the queue is empty.
"""
watch_events: list[dict] = []
requeue: list[dict] = []
while not completion_queue.empty():
try:
evt = completion_queue.get_nowait()
except Exception:
break
evt_type = evt.get("type", "completion")
if evt_type in {"watch_match", "watch_disabled"}:
watch_events.append(evt)
elif evt_type == "async_delegation":
requeue.append(evt)
# else: process completion events are handled by the watcher task
for evt in requeue:
completion_queue.put(evt)
return watch_events
# Module-level weak reference to the active GatewayRunner instance.
# Used by tools (e.g. send_message) that need to route through a live
# adapter for plugin platforms. Set in GatewayRunner.__init__().
@@ -5353,6 +5397,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# turn so the agent kicks off the new chat.
asyncio.create_task(self._handoff_watcher())
# Start background async-delegation watcher — drains completion events
# from delegate_task(background=true) subagents and injects each
# result back into its originating session as a new turn, covering the
# idle case where the subagent finishes with no agent turn running.
asyncio.create_task(self._async_delegation_watcher())
logger.info("Press Ctrl+C to stop")
return True
@@ -5989,6 +6039,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
except Exception as _e:
logger.debug("process_registry.kill_all (%s) error: %s", phase, _e)
try:
from tools.async_delegation import interrupt_all as _interrupt_async
_async_n = _interrupt_async(reason=f"gateway shutdown ({phase})")
if _async_n:
logger.info(
"Shutdown (%s): interrupted %d background delegation(s)",
phase, _async_n,
)
except Exception as _e:
logger.debug("async interrupt_all (%s) error: %s", phase, _e)
try:
from tools.terminal_tool import cleanup_all_environments
cleanup_all_environments()
@@ -8995,18 +9055,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.error("Process watcher setup error: %s", e)
# Drain watch pattern notifications that arrived during the agent run.
# Watch events and completions share the same queue; completions are
# already handled by the per-process watcher task above, so we only
# inject watch-type events here.
# Watch events and completions share the same queue; process
# completions are already handled by the per-process watcher task
# above, so we only inject watch-type events here.
#
# Async-delegation completions ALSO ride this shared queue but are
# owned by the dedicated _async_delegation_watcher (started at
# boot), which covers both the idle and post-turn cases with a
# single consumer — so we leave them on the queue here.
try:
from tools.process_registry import process_registry as _pr
_watch_events = []
while not _pr.completion_queue.empty():
evt = _pr.completion_queue.get_nowait()
evt_type = evt.get("type", "completion")
if evt_type in {"watch_match", "watch_disabled"}:
_watch_events.append(evt)
# else: completion events are handled by the watcher task
_watch_events = _drain_gateway_watch_events(_pr.completion_queue)
for evt in _watch_events:
synth_text = _format_gateway_process_notification(evt)
if synth_text:
@@ -12265,6 +12324,74 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception as e:
logger.error("Watch notification injection error: %s", e)
def _enrich_async_delegation_routing(self, evt: dict) -> None:
"""Fill platform/chat_id/thread_id/chat_type on an async-delegation event.
Async-delegation completion events only carry ``session_key`` (the
daemon worker has no access to the per-message routing metadata the
terminal background watcher captures at spawn time). Parse the
session_key into the routing fields ``_build_process_event_source``
expects. Best-effort: a CLI-origin event (empty session_key) is left
as-is and simply won't route on the gateway.
"""
if evt.get("platform"):
return # already enriched
parsed = _parse_session_key(evt.get("session_key", "") or "")
if not parsed:
return
evt["platform"] = parsed.get("platform", "")
evt["chat_type"] = parsed.get("chat_type", "")
evt["chat_id"] = parsed.get("chat_id", "")
if parsed.get("thread_id"):
evt["thread_id"] = parsed["thread_id"]
async def _async_delegation_watcher(self, interval: float = 2.0) -> None:
"""Drain async-delegation completions and inject them as new turns.
Background subagents (``delegate_task(background=true)``) run on the
async-delegation daemon executor — they have no per-process watcher
task, so their completion events would only be seen by the post-turn
queue drain. This watcher covers the IDLE case: when a background
subagent finishes while no agent turn is running, its result still
re-enters the originating session promptly.
Mirrors the CLI's idle ``process_loop`` drain. Stays silent when the
queue has nothing for us; ignores non-async event types (those are
handled by ``_run_process_watcher`` / the post-turn drain).
"""
await asyncio.sleep(3) # let platforms finish connecting
from tools.process_registry import process_registry as _pr
while self._running:
try:
# Peek the queue for async-delegation events. We must NOT
# consume watch/completion events here (other drains own them),
# so requeue anything that isn't ours.
requeue = []
async_events = []
while not _pr.completion_queue.empty():
try:
evt = _pr.completion_queue.get_nowait()
except Exception:
break
if evt.get("type") == "async_delegation":
async_events.append(evt)
else:
requeue.append(evt)
for evt in requeue:
_pr.completion_queue.put(evt)
for evt in async_events:
self._enrich_async_delegation_routing(evt)
synth_text = _format_gateway_process_notification(evt)
if not synth_text:
continue
try:
await self._inject_watch_notification(synth_text, evt)
except Exception as e:
logger.error("Async delegation injection error: %s", e)
except Exception as e:
logger.debug("Async delegation watcher error: %s", e)
await asyncio.sleep(interval)
async def _run_process_watcher(self, watcher: dict) -> None:
"""
Periodically check a background process and push updates to the user.
@@ -13497,6 +13624,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if _env_tp and not _tool_progress_configured
else (_resolved_tp or _env_tp or "all")
)
# Tool progress grouping: "accumulate" (edit one bubble) or "separate" (one msg per tool)
progress_grouping = resolve_display_setting(user_config, platform_key, "tool_progress_grouping") or "accumulate"
# Disable tool progress for webhooks - they don't support message editing,
# so each progress line would be sent as a separate message.
from gateway.config import Platform
@@ -13768,10 +13897,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# - Feishu only honors reply_in_thread when sending a reply, so topic
# progress uses the triggering event message as the reply target
# - Other platforms should use explicit source.thread_id only
if source.platform == Platform.SLACK:
_progress_thread_id = source.thread_id or event_message_id
else:
_progress_thread_id = source.thread_id
_progress_thread_id = _resolve_progress_thread_id(
source.platform, source.thread_id, event_message_id,
)
_progress_metadata = (
self._thread_metadata_for_source(source, event_message_id)
if _progress_thread_id == source.thread_id
@@ -13804,7 +13932,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
progress_lines = [] # Accumulated tool lines for the CURRENT editable bubble
progress_msg_id = None # ID of the current progress message to edit
can_edit = True # False once an edit fails (platform doesn't support it)
can_edit = progress_grouping != "separate" # "separate" = one message per tool (pre-v0.9 behavior)
_last_edit_ts = 0.0 # Throttle edits to avoid Telegram flood control
_PROGRESS_EDIT_INTERVAL = 1.5 # Minimum seconds between edits
@@ -14571,6 +14699,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_pdc = getattr(_status_adapter, "_post_delivery_callbacks", None)
if _pdc is not None:
_pdc[session_key] = _release_bg_review_messages
# Memory update notifications in chat. Config: display.memory_notifications
# off — no chat notification (still logged to stdout)
# on — generic "💾 Memory updated" (default)
# verbose — content preview: "💾 Memory ➕ Hermes Repo..."
_mem_notif = user_config.get("display", {}).get("memory_notifications")
if isinstance(_mem_notif, bool):
_mem_notif = "on" if _mem_notif else "off"
agent.memory_notifications = str(_mem_notif).lower() if _mem_notif else "on"
# ------------------------------------------------------------------
# Clarify callback: present a clarify prompt and block on a response.
+127 -11
View File
@@ -3806,6 +3806,26 @@ def resolve_codex_runtime_credentials(
"last_refresh": None,
"auth_mode": "chatgpt",
}
pool_rate_limit = _codex_pool_rate_limit_status()
if pool_rate_limit:
reset_at = pool_rate_limit.get("reset_at")
if isinstance(reset_at, (int, float)) and reset_at > time.time():
remaining = int(reset_at - time.time())
message = (
f"Codex provider quota exhausted (429); retry after {remaining}s. "
"Credentials are still valid."
)
else:
message = (
"Codex provider quota exhausted (429). Credentials are still valid; "
"retry after the usage limit resets."
)
raise AuthError(
message,
provider="openai-codex",
code=CODEX_RATE_LIMITED_CODE,
relogin_required=False,
)
if read_error is not None:
raise read_error
raise AuthError(
@@ -3852,6 +3872,79 @@ def resolve_codex_runtime_credentials(
}
def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]:
"""Return metadata for a pool-only Codex credential in quota cooldown."""
def _parse_reset_at(value: Any) -> Optional[float]:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
numeric = float(value)
if numeric <= 0:
return None
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
try:
numeric = float(raw)
except ValueError:
numeric = None
if numeric is not None:
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
return None
try:
with _auth_store_lock():
auth_store = _load_auth_store()
pool = auth_store.get("credential_pool")
if not isinstance(pool, dict):
return None
entries = pool.get("openai-codex")
if not isinstance(entries, list):
return None
now = time.time()
for entry in entries:
if not isinstance(entry, dict):
continue
token = entry.get("access_token")
if not isinstance(token, str) or not token.strip():
continue
if entry.get("last_status") != "exhausted":
continue
code = entry.get("last_error_code")
reason = str(entry.get("last_error_reason") or "").lower()
message = str(entry.get("last_error_message") or "").lower()
is_rate_limited = (
code == 429
or "rate_limit" in reason
or "usage_limit" in reason
or "quota" in reason
or "rate limit" in message
or "usage limit" in message
or "quota" in message
)
if not is_rate_limited:
continue
reset_at = _parse_reset_at(entry.get("last_error_reset_at"))
if reset_at is not None and reset_at <= now:
continue
return {
"label": entry.get("label"),
"last_refresh": entry.get("last_refresh"),
"reset_at": reset_at,
"reason": entry.get("last_error_reason"),
"message": entry.get("last_error_message"),
}
except Exception:
logger.debug("Codex pool rate-limit lookup failed", exc_info=True)
return None
def _pool_codex_access_token() -> str:
"""Return the most-recent usable access_token from the openai-codex pool.
@@ -5763,18 +5856,24 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]:
# subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms"
# was firing the refresh ~31× during one menu paint, racking up >13s of HTTP and burning
# single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json
# mtime so that `hermes auth login/logout/add/remove` invalidate naturally on the next call.
# path + mtime so that profile switches do not share a process memo and
# `hermes auth login/logout/add/remove` invalidate naturally on the next call.
_NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds
_nous_auth_status_cache: Optional[Tuple[float, Optional[float], Dict[str, Any]]] = None
_nous_auth_status_cache: Optional[Tuple[float, str, Optional[float], Dict[str, Any]]] = None
def _auth_file_mtime() -> Optional[float]:
def _auth_file_cache_key() -> Tuple[str, Optional[float]]:
auth_file = _auth_file_path()
try:
return _auth_file_path().stat().st_mtime
except FileNotFoundError:
return None
auth_file_key = str(auth_file.resolve(strict=False))
except Exception:
return None
auth_file_key = str(auth_file)
try:
return auth_file_key, auth_file.stat().st_mtime
except FileNotFoundError:
return auth_file_key, None
except Exception:
return auth_file_key, None
def invalidate_nous_auth_status_cache() -> None:
@@ -5806,18 +5905,19 @@ def get_nous_auth_status() -> Dict[str, Any]:
"""
global _nous_auth_status_cache
now = time.monotonic()
mtime = _auth_file_mtime()
auth_file_key, mtime = _auth_file_cache_key()
cached = _nous_auth_status_cache
if cached is not None:
cached_at, cached_mtime, cached_status = cached
cached_at, cached_auth_file_key, cached_mtime, cached_status = cached
if (
cached_mtime == mtime
cached_auth_file_key == auth_file_key
and cached_mtime == mtime
and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL
):
return dict(cached_status)
status = _compute_nous_auth_status()
_nous_auth_status_cache = (now, mtime, dict(status))
_nous_auth_status_cache = (now, auth_file_key, mtime, dict(status))
return status
@@ -5900,6 +6000,22 @@ def get_codex_auth_status() -> Dict[str, Any]:
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
"api_key": api_key,
}
rate_limit = _codex_pool_rate_limit_status()
if rate_limit:
return {
"logged_in": True,
"auth_store": str(_auth_file_path()),
"last_refresh": rate_limit.get("last_refresh"),
"auth_mode": "chatgpt",
"source": f"pool:{rate_limit.get('label') or 'unknown'}",
"rate_limited": True,
"error_code": CODEX_RATE_LIMITED_CODE,
"error": (
rate_limit.get("message")
or "Codex provider quota exhausted; retry after the usage limit resets."
),
"reset_at": rate_limit.get("reset_at"),
}
except Exception:
pass
+35 -5
View File
@@ -225,7 +225,8 @@ class CLICommandsMixin:
print(" Usage: /snapshot [list|create [label]|restore <id>|prune [N]]")
def _handle_stop_command(self):
"""Handle /stop — kill all running background processes.
"""Handle /stop — kill all running background processes and
background (async) delegations.
Inspired by OpenAI Codex's separation of interrupt (stop current turn)
from /stop (clean up background processes). See openai/codex#14602.
@@ -235,13 +236,26 @@ class CLICommandsMixin:
processes = process_registry.list_sessions()
running = [p for p in processes if p.get("status") == "running"]
if not running:
# Background subagents dispatched via delegate_task(background=true)
# live in their own registry, not the process registry.
try:
from tools.async_delegation import active_count, interrupt_all
n_async = active_count()
except Exception:
n_async = 0
interrupt_all = None
if not running and not n_async:
print(" No running background processes.")
return
print(f" Stopping {len(running)} background process(es)...")
killed = process_registry.kill_all()
print(f" ✅ Stopped {killed} process(es).")
if running:
print(f" Stopping {len(running)} background process(es)...")
killed = process_registry.kill_all()
print(f" ✅ Stopped {killed} process(es).")
if n_async and interrupt_all is not None:
stopped = interrupt_all(reason="/stop")
print(f" ✅ Interrupted {stopped} background delegation(s).")
def _handle_agents_command(self):
"""Handle /agents — show background processes and agent status."""
@@ -261,6 +275,22 @@ class CLICommandsMixin:
if finished:
_cprint(f" Recently finished: {len(finished)}")
# Background (async) delegations — delegate_task(background=true)
try:
from tools.async_delegation import list_async_delegations
delegations = list_async_delegations()
except Exception:
delegations = []
running_d = [d for d in delegations if d.get("status") == "running"]
if delegations:
_cprint(f" Background delegations: {len(running_d)} running")
for d in delegations:
goal = (d.get("goal") or "")[:60]
_cprint(
f" {d.get('delegation_id', '?')} · "
f"{d.get('status', '?')} · {goal}"
)
agent_running = getattr(self, "_agent_running", False)
_cprint(f" Agent: {'running' if agent_running else 'idle'}")
+2 -1
View File
@@ -1053,7 +1053,8 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
# the telegram-parity test reads it so an entry here is a deliberate
# "Slack-via-/hermes" decision, not a silent clamp.
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits"})
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "debug"})
def _sanitize_slack_name(raw: str) -> str:
+14 -1
View File
@@ -1428,6 +1428,12 @@ DEFAULT_CONFIG = {
"tui_agents_nudge": True,
"bell_on_complete": False,
"show_reasoning": False,
# Background self-improvement review notifications surfaced in chat.
# "off" — no chat notification (the review still runs and writes)
# "on" — generic "💾 Memory updated" line (default)
# "verbose" — include a compact content preview of what changed
# Per-platform overrides via display.platforms.<platform>.memory_notifications.
"memory_notifications": "on",
"streaming": False,
"timestamps": False, # Show [HH:MM] on user and assistant labels
"final_response_markdown": "strip", # render | strip | raw
@@ -1479,6 +1485,12 @@ DEFAULT_CONFIG = {
"tool_progress_command": False, # Enable /verbose command in messaging gateway
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
# How gateway tool-progress is grouped on platforms that support message
# editing: "accumulate" (default) edits one bubble in place; "separate"
# sends one message per tool (the pre-v0.9 behavior, noisier). Only
# applies where tool_progress is already enabled. Per-platform override
# via display.platforms.<platform>.tool_progress_grouping.
"tool_progress_grouping": "accumulate",
# Auto-delete system-notice replies (e.g. "✨ New session started!",
# "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
# that support message deletion (currently Telegram; other platforms
@@ -1775,6 +1787,7 @@ DEFAULT_CONFIG = {
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
# "low", "minimal", "none" (empty = inherit parent's level)
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
"max_async_children": 3, # max concurrent background (background=true) subagents; new dispatches rejected at capacity
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
# raise deliberately, each level multiplies API cost.
@@ -1990,7 +2003,7 @@ DEFAULT_CONFIG = {
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
"allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
"extra": {
"rich_messages": False, # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
"rich_messages": True, # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2
},
},
+1
View File
@@ -796,6 +796,7 @@ def run_doctor(args):
"huggingface",
"lmstudio",
"nous",
"nvidia",
}
provider_accepts_vendor_slug = (
provider_policy_id in providers_accepting_vendor_slugs
+17 -2
View File
@@ -252,9 +252,24 @@ def run_dump(args):
except Exception:
profile = "(default)"
# Terminal backend
# Terminal backend — report the EFFECTIVE backend, not just config.yaml.
# ``terminal.backend`` in config.yaml is bridged to the TERMINAL_ENV env var,
# but a TERMINAL_ENV set directly in .env / the shell overrides config and is
# what terminal_tool actually uses (tools/terminal_tool.py reads TERMINAL_ENV).
# Reporting only the config value hides that override and sends users chasing
# the wrong cause when the agent runs in a docker/podman sandbox even though
# config says "local" (and vice-versa). run_dump() has already loaded .env,
# so os.environ reflects the real override here.
terminal_cfg = config.get("terminal", {})
backend = terminal_cfg.get("backend", "local")
config_backend = terminal_cfg.get("backend", "local")
env_backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower()
if env_backend and env_backend != str(config_backend).strip().lower():
backend = (
f"{env_backend} (TERMINAL_ENV overrides config.yaml "
f"terminal.backend={config_backend})"
)
else:
backend = config_backend
# OpenAI SDK version
try:
+30
View File
@@ -157,6 +157,36 @@ def build_models_payload(
max_models=max_models,
)
# --- Deduplicate: remove models from aggregators that overlap with
# user-defined providers. When a local proxy (e.g. litellm-proxy)
# serves a model whose name also appears in an aggregator's curated
# catalog, the picker would show the model under both providers.
# Selecting it from the aggregator row sets model.provider to the
# aggregator (e.g. openrouter) instead of the user's proxy — silently
# breaking the call. Filtering at the payload level keeps the
# aggregator rows honest: they only show models the user can't get
# from a more-specific provider. (#45954)
try:
from hermes_cli.providers import is_aggregator as _is_aggregator
except Exception:
_is_aggregator = None # type: ignore[assignment]
if _is_aggregator is not None:
user_models: set[str] = set()
for row in rows:
if row.get("is_user_defined"):
user_models.update(m.lower() for m in (row.get("models") or []))
if user_models:
for row in rows:
slug = row.get("slug", "")
if not _is_aggregator(slug):
continue
original = row.get("models") or []
filtered = [m for m in original if m.lower() not in user_models]
if len(filtered) < len(original):
row["models"] = filtered
row["total_models"] = len(filtered)
if include_unconfigured:
rows = list(rows) + _append_unconfigured_rows(rows, ctx)
if picker_hints:
+267 -107
View File
@@ -247,6 +247,19 @@ def _has_valid_session_token(request: Request) -> bool:
return hmac.compare_digest(auth.encode(), expected.encode())
# Routes that may also authenticate via a ``?token=`` query param, for download
# links opened by the OS shell or a new browser tab where the session header
# can't be set. Kept narrow — same query-token tradeoff as the /api/pty WS.
_QUERY_TOKEN_API_PATHS: frozenset[str] = frozenset({"/api/files/download"})
def _has_valid_query_token(request: Request, path: str) -> bool:
if path not in _QUERY_TOKEN_API_PATHS:
return False
token = request.query_params.get("token", "")
return bool(token) and hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
def _require_token(request: Request) -> None:
"""Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
@@ -403,7 +416,7 @@ async def auth_middleware(request: Request, call_next):
return await call_next(request)
path = request.url.path
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
if not _has_valid_session_token(request):
if not _has_valid_session_token(request) and not _has_valid_query_token(request, path):
return JSONResponse(
status_code=401,
content={"detail": "Unauthorized"},
@@ -1224,6 +1237,22 @@ def _default_hermes_root_is_opt_data() -> bool:
return root == _HOSTED_MANAGED_FILES_ROOT
def _dashboard_local_update_managed_externally() -> bool:
"""Return true when the dashboard should not offer ``hermes update``.
Containerized dashboards are updated by the outer launcher/image, not by an
in-browser local update action. Keep this dashboard capability separate
from install-method detection: manual git/pip installs inside containers can
still behave like their actual install method in the CLI.
"""
try:
from hermes_constants import is_container
return is_container()
except Exception:
return False
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
if raw_forced_root:
@@ -1393,6 +1422,40 @@ async def read_managed_file(request: Request, path: str):
}
@app.get("/api/files/download")
async def download_managed_file(request: Request, path: str):
"""Stream a managed file as an attachment download.
Remote clients (desktop app, browser dashboard) open agent-written files
that live on *this* gateway's disk, not theirs. Auth-gated like every other
managed-files route — ``auth_middleware`` additionally accepts the session
token as a ``?token=`` query param here so a shell/browser-opened download
(which can't set the session header) still authenticates. See ``/api/pty``
for the same query-token precedent.
"""
policy, target, _display_path = _resolve_managed_path(path, request)
if not target.exists():
raise HTTPException(status_code=404, detail="File not found")
if not target.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
try:
size = target.stat().st_size
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}")
if size > _MANAGED_FILE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File is too large")
mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
return FileResponse(
path=str(target),
media_type=mime_type,
filename=target.name,
content_disposition_type="attachment",
)
@app.post("/api/files/upload")
async def upload_managed_file(payload: ManagedFileUpload, request: Request):
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
@@ -1654,6 +1717,7 @@ async def get_status():
"release_date": __release_date__,
"config_version": current_ver,
"latest_config_version": latest_ver,
"can_update_hermes": not _dashboard_local_update_managed_externally(),
"gateway_running": gateway_running,
"gateway_state": gateway_state,
"gateway_platforms": gateway_platforms,
@@ -2165,6 +2229,22 @@ async def restart_gateway():
@app.post("/api/hermes/update")
async def update_hermes():
"""Kick off ``hermes update`` in the background."""
if _dashboard_local_update_managed_externally():
message = (
"Hermes updates are managed outside this dashboard in "
"containerized environments. The built-in local updater is "
"disabled here."
)
_record_completed_action("hermes-update", message, exit_code=1)
return {
"ok": False,
"pid": None,
"name": "hermes-update",
"error": "dashboard_update_managed_externally",
"message": message,
"update_command": "managed outside dashboard",
}
install_method = detect_install_method(PROJECT_ROOT)
if install_method == "docker":
message = format_docker_update_message()
@@ -2264,6 +2344,20 @@ async def check_hermes_update(force: bool = False):
desktop's remote update overlay renders this as "what's
changed". Additive: existing consumers ignore it.
"""
if _dashboard_local_update_managed_externally():
return {
"install_method": "managed-runtime",
"current_version": __version__,
"behind": None,
"update_available": False,
"can_apply": False,
"update_command": "managed outside dashboard",
"message": (
"Hermes updates are managed outside this dashboard in "
"containerized environments."
),
}
install_method = detect_install_method(PROJECT_ROOT)
update_command = recommended_update_command_for_method(install_method)
@@ -5144,7 +5238,7 @@ def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str,
@app.get("/api/providers/oauth")
async def list_oauth_providers():
async def list_oauth_providers(profile: Optional[str] = None):
"""Enumerate every OAuth-capable LLM provider with current status.
Response shape (per provider):
@@ -5161,83 +5255,89 @@ async def list_oauth_providers():
expires_at ISO timestamp string or null
has_refresh_token bool
"""
providers = []
for p in _OAUTH_PROVIDER_CATALOG:
status = _resolve_provider_status(p["id"], p.get("status_fn"))
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
providers.append({
"id": p["id"],
"name": p["name"],
"flow": p["flow"],
"cli_command": p["cli_command"],
"docs_url": p["docs_url"],
"disconnect_hint": disconnect_hint,
"disconnectable": disconnect_hint is None,
"status": status,
})
return {"providers": providers}
with _profile_scope(profile):
providers = []
for p in _OAUTH_PROVIDER_CATALOG:
status = _resolve_provider_status(p["id"], p.get("status_fn"))
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
providers.append({
"id": p["id"],
"name": p["name"],
"flow": p["flow"],
"cli_command": p["cli_command"],
"docs_url": p["docs_url"],
"disconnect_hint": disconnect_hint,
"disconnectable": disconnect_hint is None,
"status": status,
})
return {"providers": providers}
@app.delete("/api/providers/oauth/{provider_id}")
async def disconnect_oauth_provider(provider_id: str, request: Request):
async def disconnect_oauth_provider(
provider_id: str,
request: Request,
profile: Optional[str] = None,
):
"""Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
_require_token(request)
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
provider = catalog_by_id.get(provider_id)
if provider is None:
raise HTTPException(
status_code=400,
detail=f"Unknown provider: {provider_id}. "
f"Available: {', '.join(sorted(catalog_by_id))}",
)
with _profile_scope(profile):
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
provider = catalog_by_id.get(provider_id)
if provider is None:
raise HTTPException(
status_code=400,
detail=f"Unknown provider: {provider_id}. "
f"Available: {', '.join(sorted(catalog_by_id))}",
)
disconnect_hint = _oauth_provider_disconnect_hint(provider, {})
if disconnect_hint:
raise HTTPException(
status_code=400,
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
)
disconnect_hint = _oauth_provider_disconnect_hint(provider, {})
if disconnect_hint:
raise HTTPException(
status_code=400,
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
)
status = _resolve_provider_status(provider_id, provider.get("status_fn"))
disconnect_hint = _oauth_provider_disconnect_hint(provider, status)
if disconnect_hint:
raise HTTPException(
status_code=400,
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
)
status = _resolve_provider_status(provider_id, provider.get("status_fn"))
disconnect_hint = _oauth_provider_disconnect_hint(provider, status)
if disconnect_hint:
raise HTTPException(
status_code=400,
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
)
# Anthropic clears only the Hermes-managed PKCE file and auth-store entry.
# The separate claude-code catalog row is external/read-only and rejected
# above so we never pretend to remove ~/.claude/* credentials owned by the CLI.
if provider_id == "anthropic":
cleared = False
try:
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
if _HERMES_OAUTH_FILE.exists():
_HERMES_OAUTH_FILE.unlink()
cleared = True
except Exception:
pass
# Also clear the credential pool entry if present.
try:
from hermes_cli.auth import clear_provider_auth
cleared = clear_provider_auth("anthropic") or cleared
except Exception:
pass
_log.info("oauth/disconnect: %s", provider_id)
return {"ok": bool(cleared), "provider": provider_id}
# Anthropic clears only the Hermes-managed PKCE file and auth-store entry.
# The separate claude-code catalog row is external/read-only and rejected
# above so we never pretend to remove ~/.claude/* credentials owned by the CLI.
if provider_id == "anthropic":
cleared = False
try:
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
if _HERMES_OAUTH_FILE.exists():
_HERMES_OAUTH_FILE.unlink()
cleared = True
except Exception:
pass
# Also clear the credential pool entry if present.
try:
from hermes_cli.auth import clear_provider_auth
cleared = clear_provider_auth("anthropic") or cleared
except Exception:
pass
_log.info("oauth/disconnect: %s", provider_id)
return {"ok": bool(cleared), "provider": provider_id}
try:
from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache
cleared = clear_provider_auth(provider_id)
if provider_id == "nous":
invalidate_nous_auth_status_cache()
_log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared)
return {"ok": bool(cleared), "provider": provider_id}
except Exception as e:
_log.exception("disconnect %s failed", provider_id)
raise HTTPException(status_code=500, detail=str(e))
from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache
cleared = clear_provider_auth(provider_id)
if provider_id == "nous":
invalidate_nous_auth_status_cache()
_log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared)
return {"ok": bool(cleared), "provider": provider_id}
except Exception as e:
_log.exception("disconnect %s failed", provider_id)
raise HTTPException(status_code=500, detail=str(e))
# ---------------------------------------------------------------------------
@@ -5319,13 +5419,32 @@ def _gc_oauth_sessions() -> None:
_oauth_sessions.pop(sid, None)
def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]]:
def _oauth_profile_name(profile: Optional[str]) -> Optional[str]:
requested = (profile or "").strip()
if not requested or requested.lower() == "current":
return None
return requested
def _validate_oauth_profile(profile: Optional[str]) -> None:
profile_name = _oauth_profile_name(profile)
if profile_name:
_resolve_profile_dir(profile_name)
def _new_oauth_session(
provider_id: str,
flow: str,
profile: Optional[str] = None,
) -> tuple[str, Dict[str, Any]]:
"""Create + register a new OAuth session, return (session_id, session_dict)."""
sid = secrets.token_urlsafe(16)
profile_name = _oauth_profile_name(profile)
sess = {
"session_id": sid,
"provider": provider_id,
"flow": flow,
"profile": profile_name,
"created_at": time.time(),
"status": "pending", # pending | approved | denied | expired | error
"error_message": None,
@@ -5335,6 +5454,17 @@ def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]
return sid, sess
def _oauth_session_profile(
session_id: str,
fallback: Optional[str] = None,
) -> Optional[str]:
"""Return the profile that owns an OAuth session, if one was provided."""
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
profile = sess.get("profile") if sess else None
return profile or _oauth_profile_name(fallback)
def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
"""Persist Anthropic PKCE creds to both Hermes file AND credential pool.
@@ -5402,12 +5532,12 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a
_log.warning("anthropic pool add (dashboard) failed: %s", e)
def _start_anthropic_pkce() -> Dict[str, Any]:
def _start_anthropic_pkce(profile: Optional[str] = None) -> Dict[str, Any]:
"""Begin PKCE flow. Returns the auth URL the UI should open."""
if not _ANTHROPIC_OAUTH_AVAILABLE:
raise HTTPException(status_code=501, detail="Anthropic OAuth not available (missing adapter)")
verifier, challenge = _generate_pkce_pair()
sid, sess = _new_oauth_session("anthropic", "pkce")
sid, sess = _new_oauth_session("anthropic", "pkce", profile=profile)
sess["verifier"] = verifier
sess["state"] = verifier # Anthropic round-trips verifier as state
params = {
@@ -5429,7 +5559,11 @@ def _start_anthropic_pkce() -> Dict[str, Any]:
}
def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
def _submit_anthropic_pkce(
session_id: str,
code_input: str,
profile: Optional[str] = None,
) -> Dict[str, Any]:
"""Exchange authorization code for tokens. Persists on success."""
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
@@ -5483,7 +5617,8 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
expires_at_ms = int(time.time() * 1000) + (expires_in * 1000)
try:
_save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms)
with _profile_scope(_oauth_session_profile(session_id, profile)):
_save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms)
except Exception as e:
with _oauth_sessions_lock:
sess["status"] = "error"
@@ -5495,7 +5630,10 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
return {"ok": True, "status": "approved"}
async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
async def _start_device_code_flow(
provider_id: str,
profile: Optional[str] = None,
) -> Dict[str, Any]:
"""Initiate a device-code flow (Nous, OpenAI Codex, or MiniMax).
Calls the provider's device-auth endpoint via the existing CLI helpers,
@@ -5535,7 +5673,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
device_data, effective_scope = await asyncio.get_running_loop().run_in_executor(
None, _do_nous_device_request
)
sid, sess = _new_oauth_session("nous", "device_code")
sid, sess = _new_oauth_session("nous", "device_code", profile=profile)
sess["device_code"] = str(device_data["device_code"])
sess["interval"] = int(device_data["interval"])
sess["expires_at"] = time.time() + int(device_data["expires_in"])
@@ -5556,7 +5694,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
if provider_id == "openai-codex":
# Codex uses fixed OpenAI device-auth endpoints; reuse the helper.
sid, _ = _new_oauth_session("openai-codex", "device_code")
sid, _ = _new_oauth_session("openai-codex", "device_code", profile=profile)
# Use the helper but in a thread because it polls inline.
# We can't extract just the start step without refactoring auth.py,
# so we run the full helper in a worker and proxy the user_code +
@@ -5623,7 +5761,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
device_data = await asyncio.get_event_loop().run_in_executor(
None, _do_minimax_request
)
sid, sess = _new_oauth_session("minimax-oauth", "device_code")
sid, sess = _new_oauth_session("minimax-oauth", "device_code", profile=profile)
# The CLI flow names this `interval_ms` because MiniMax's
# `interval` field is in milliseconds (defensive default 2000ms
# in _minimax_poll_token).
@@ -5677,7 +5815,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
_XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0
def _start_xai_loopback_flow() -> Dict[str, Any]:
def _start_xai_loopback_flow(profile: Optional[str] = None) -> Dict[str, Any]:
"""Begin the xAI loopback PKCE flow.
Binds the local callback server, builds the authorize URL, and spawns a
@@ -5716,7 +5854,7 @@ def _start_xai_loopback_flow() -> Dict[str, Any]:
pass
raise
sid, sess = _new_oauth_session("xai-oauth", "loopback")
sid, sess = _new_oauth_session("xai-oauth", "loopback", profile=profile)
sess["server"] = server
sess["thread"] = thread
sess["callback_result"] = callback_result
@@ -5819,13 +5957,14 @@ def _xai_loopback_worker(session_id: str) -> None:
}
if _cancelled():
return
hauth._save_xai_oauth_tokens(
tokens,
discovery=sess.get("discovery"),
redirect_uri=sess["redirect_uri"],
last_refresh=last_refresh,
)
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
with _profile_scope(_oauth_session_profile(session_id)):
hauth._save_xai_oauth_tokens(
tokens,
discovery=sess.get("discovery"),
redirect_uri=sess["redirect_uri"],
last_refresh=last_refresh,
)
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
except Exception as exc:
_fail(f"xAI token exchange failed: {exc}")
return
@@ -5928,13 +6067,14 @@ def _nous_poller(session_id: str) -> None:
),
"expires_in": token_ttl,
}
full_state = refresh_nous_oauth_from_state(
auth_state,
timeout_seconds=15.0,
force_refresh=False,
)
from hermes_cli.auth import persist_nous_credentials
persist_nous_credentials(full_state)
with _profile_scope(_oauth_session_profile(session_id)):
full_state = refresh_nous_oauth_from_state(
auth_state,
timeout_seconds=15.0,
force_refresh=False,
)
from hermes_cli.auth import persist_nous_credentials
persist_nous_credentials(full_state)
with _oauth_sessions_lock:
sess["status"] = "approved"
_log.info("oauth/device: nous login completed (session=%s)", session_id)
@@ -6017,7 +6157,8 @@ def _minimax_poller(session_id: str) -> None:
).isoformat(),
"expires_in": expires_in_s,
}
_minimax_save_auth_state(auth_state)
with _profile_scope(_oauth_session_profile(session_id)):
_minimax_save_auth_state(auth_state)
with _oauth_sessions_lock:
sess["status"] = "approved"
_log.info("oauth/device: minimax login completed (session=%s)", session_id)
@@ -6130,10 +6271,11 @@ def _codex_full_login_worker(session_id: str) -> None:
from hermes_cli.auth import _save_codex_tokens
_save_codex_tokens({
"access_token": access_token,
"refresh_token": refresh_token,
})
with _profile_scope(_oauth_session_profile(session_id)):
_save_codex_tokens({
"access_token": access_token,
"refresh_token": refresh_token,
})
with _oauth_sessions_lock:
sess["status"] = "approved"
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
@@ -6147,10 +6289,15 @@ def _codex_full_login_worker(session_id: str) -> None:
@app.post("/api/providers/oauth/{provider_id}/start")
async def start_oauth_login(provider_id: str, request: Request):
async def start_oauth_login(
provider_id: str,
request: Request,
profile: Optional[str] = None,
):
"""Initiate an OAuth login flow. Token-protected."""
_require_token(request)
_gc_oauth_sessions()
_validate_oauth_profile(profile)
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
if provider_id not in valid:
raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}")
@@ -6168,12 +6315,12 @@ async def start_oauth_login(provider_id: str, request: Request):
# change for MiniMax). New PKCE providers must add their own
# start function and an explicit branch here.
if catalog_entry["flow"] == "pkce" and provider_id == "anthropic":
return _start_anthropic_pkce()
return _start_anthropic_pkce(profile=profile)
if catalog_entry["flow"] == "device_code":
return await _start_device_code_flow(provider_id)
return await _start_device_code_flow(provider_id, profile=profile)
if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth":
return await asyncio.get_running_loop().run_in_executor(
None, _start_xai_loopback_flow
None, _start_xai_loopback_flow, profile,
)
except HTTPException:
raise
@@ -6189,18 +6336,27 @@ class OAuthSubmitBody(BaseModel):
@app.post("/api/providers/oauth/{provider_id}/submit")
async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request):
async def submit_oauth_code(
provider_id: str,
body: OAuthSubmitBody,
request: Request,
profile: Optional[str] = None,
):
"""Submit the auth code for PKCE flows. Token-protected."""
_require_token(request)
if provider_id == "anthropic":
return await asyncio.get_running_loop().run_in_executor(
None, _submit_anthropic_pkce, body.session_id, body.code,
None, _submit_anthropic_pkce, body.session_id, body.code, profile,
)
raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}")
@app.get("/api/providers/oauth/{provider_id}/poll/{session_id}")
async def poll_oauth_session(provider_id: str, session_id: str):
async def poll_oauth_session(
provider_id: str,
session_id: str,
profile: Optional[str] = None,
):
"""Poll a session's status (no auth — read-only state).
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the
@@ -6223,7 +6379,11 @@ async def poll_oauth_session(provider_id: str, session_id: str):
@app.delete("/api/providers/oauth/sessions/{session_id}")
async def cancel_oauth_session(session_id: str, request: Request):
async def cancel_oauth_session(
session_id: str,
request: Request,
profile: Optional[str] = None,
):
"""Cancel a pending OAuth session. Token-protected."""
_require_token(request)
with _oauth_sessions_lock:
+124
View File
@@ -0,0 +1,124 @@
---
name: mpp-agent
description: Pay HTTP 402 APIs via Machine Payments Protocol (MPP).
version: 0.1.0
author: Teknium (teknium1), Hermes Agent
license: MIT
platforms: [linux, macos]
metadata:
hermes:
tags: [Payments, MPP, HTTP-402, Tempo, Stripe]
related_skills: [stripe-link-cli, stripe-projects]
---
# MPP Agent Skill
Wraps the Machine Payments Protocol (MPP, https://mpp.dev) clients so Hermes can pay for per-request API access against servers that respond with `HTTP 402 Payment Required`.
Three client options, all distributed via npm. Pick the lightest one that solves the user's need. Gated `[linux, macos]` while the broader payments tooling matures on Windows.
## When to Use
- A merchant API returns `HTTP 402` with a `www-authenticate` header — and the user wants to actually pay it, not just log the response.
- The user asks to "pay per request", "set up an agent wallet", "use Tempo / Privy / AgentCash", or wants to discover MPP-priced services.
- A Stripe Link spend has produced a Shared Payment Token (SPT) and the agent needs to attach it to the 402 challenge — in that flow, prefer `link-cli mpp pay` (see the `stripe-link-cli` skill).
## Choosing a client
| Tool | When | Setup |
|---|---|---|
| `link-cli` | User already has Stripe Link set up, or the 402 challenge advertises `method="stripe"` | see the `stripe-link-cli` skill |
| Tempo Wallet | MPP services with spend controls, service discovery | `tempo wallet login` |
| Privy Agent CLI | Multi-chain wallets, browser-based funding | `privy-agent-wallets login` |
| AgentCash | 300+ pre-priced APIs via one USDC.e balance | `npx agentcash onboard` |
| `mppx` | Dev + debugging, smallest dep surface | `npm install -g mppx` then `mppx account create` |
Default: if the user already has Stripe Link configured or the 402 challenge specifies `method="stripe"`, use `link-cli mpp pay` (the `stripe-link-cli` skill). Otherwise `mppx` for one-off paid calls and debugging, and Tempo Wallet when the user wants persistent spend controls.
## Prerequisites
- Node.js 20+ on `PATH`
- A funded wallet (Tempo / Privy / AgentCash) OR an `mppx` account
- For Tempo / Privy / AgentCash: follow their respective onboarding skills:
- `https://tempo.xyz/SKILL.md`
- `https://agents.privy.io/skill.md`
- `https://agentcash.dev/skill.md`
Use `web_extract` to fetch any of those SKILL.md files if the user picks one.
## Procedure (mppx, fastest path)
Run all commands through the `terminal` tool.
### 1. Install + create an account
```
npm install -g mppx
mppx account create
```
Store the resulting account credentials wherever the CLI tells you (the CLI writes them under its own config — do not paste them into the agent transcript).
### 2. Inspect the merchant's 402 challenge
If the user gives you a URL, probe it first to confirm it actually speaks MPP:
```
curl -i <url>
```
A real MPP 402 looks like:
```
HTTP/1.1 402 Payment Required
www-authenticate: tempo amount=0.1 currency=...
```
### 3. Pay the request
```
mppx <url>
```
For non-GET methods or request bodies:
```
mppx <url> --method POST --data '<json>'
```
`mppx` handles the 402 challenge/credential dance automatically and prints the merchant's actual response on success.
### 4. Verify the receipt
`mppx` attaches the receipt header automatically. To inspect:
```
mppx <url> -v
```
## Procedure (Tempo Wallet)
The Tempo Wallet skill at https://tempo.xyz/SKILL.md is the canonical reference; fetch it with `web_extract` and follow it. Headline:
```
tempo wallet login
tempo wallet pay <url>
```
Spend controls and service discovery live in the wallet UI at https://wallet.tempo.xyz.
## Pitfalls
- **`HTTP 402` without `method="stripe"` cannot be paid by Stripe Link.** If the challenge advertises only Tempo / other methods, use `mppx` (or whichever wallet matches) — Link will reject it. Conversely, if it advertises `method="stripe"`, prefer Link via the `stripe-link-cli` skill so the spend goes through the user's approved card.
- **Multiple challenges in one header.** `www-authenticate` may list several methods (e.g. `tempo, stripe`). The Link CLI's `mpp decode` will pick the Stripe one; `mppx` will pick Tempo. There's no single "right" client — pick by which wallet the user has funded.
- **Zero-amount challenges.** Some MPP endpoints charge `$0.00` and just want a proof credential. These work without a funded wallet. Don't refuse them as "broken."
- **Wallet keys never enter agent context.** All four clients store keys under their own config dirs (or generate per-session ephemeral keypairs, in Privy's case). Do not `cat`/`read_file` them.
- **Server-side MPP is a different skill.** If the user wants to ADD 402 to their own API, this skill is wrong — point them at https://mpp.dev/quickstart/server and the `mppx/nextjs` / `mppx/hono` / `mppx/express` / `mppx/elysia` middlewares. A dedicated `mpp-server` skill may land later.
## Verification
```
mppx --version && mppx account list
```
Exit code 0 means installed and an account exists.
@@ -0,0 +1,184 @@
---
name: stripe-link-cli
description: Agent payments via Stripe Link — cards, SPT, approvals.
version: 0.1.0
author: Teknium (teknium1), Hermes Agent
license: MIT
platforms: [linux, macos]
metadata:
hermes:
tags: [Payments, Stripe, Link, Checkout, MPP]
related_skills: [mpp-agent, stripe-projects]
---
# Stripe Link CLI Skill
Wraps [@stripe/link-cli](https://github.com/stripe/link-cli) so Hermes can complete purchases on the user's behalf using one-time-use virtual cards or Shared Payment Tokens (SPT). Every spend is gated by an in-app approval in the Link mobile/web app — Hermes cannot self-approve.
US-only at the moment (Link account requirement). Windows is not supported by the upstream CLI — this skill is gated `[linux, macos]`.
## When to Use
Trigger phrases:
- "buy X", "pay for X", "make a purchase", "complete checkout"
- "get me a card", "I need a payment method"
- "log in to Link", "connect my Link wallet"
- HTTP 402 response from a merchant API with `www-authenticate: ... method="stripe"`
If the user wants a paid API call (HTTP 402, no checkout form), the `card` path is wrong — use SPT via this same skill, or hand off to the `mpp-agent` skill.
## Prerequisites
- Node.js 20+ available on `PATH` (`node --version`)
- US-based (Link account requirement)
The Link account, payment method, and spend-approval app do NOT need to be set up before Hermes attempts to pay — the CLI walks the user through them on first run:
- A Link account at https://app.link.com — created/linked during first `link-cli` auth
- At least one payment method — added during first run at https://app.link.com/wallet
- The Link mobile/web app — opened to approve the first spend request when it's made
No env vars required — auth state is stored locally by the CLI under its own config directory.
## Install
Install once, globally:
```
npm install -g @stripe/link-cli
```
Or invoke ad-hoc via `npx @stripe/link-cli`. The skill below uses the installed `link-cli` form.
## How to Run
All commands run through the `terminal` tool. The CLI auto-detects non-TTY callers and emits compact `toon` output by default — fine for the model. Pass `--format json` if a step needs structured fields.
Discover commands: `link-cli --llms-full`.
Get a command's schema before invoking: `link-cli <command> --schema`.
## Procedure
### 1. Check / establish auth
```
link-cli auth status
```
If not authenticated, log in with a clear client name (this label shows in the user's Link app):
```
link-cli auth login --client-name "Hermes" --interval 5 --timeout 300
```
The `--interval`/`--timeout` form polls inline so the agent doesn't need to manage a `_next` step. Print the verification URL + phrase to the user and wait for the CLI to return.
**Do not proceed past this step until `auth status` confirms login.**
### 2. Evaluate the merchant before creating a spend request
Decide the credential type:
| Merchant surface | `--credential-type` |
|---|---|
| Standard web checkout form / Stripe Elements | `card` (default) |
| Returns HTTP 402 with `method="stripe"` in `www-authenticate` | `shared_payment_token` |
| Returns HTTP 402 without `method="stripe"` | unsupported — stop |
For 402 responses, do NOT decode the challenge manually. Pass the raw header:
```
link-cli mpp decode --challenge '<full WWW-Authenticate header>'
```
This validates the challenge and extracts the network ID + decoded request body.
### 3. List payment methods + shipping
```
link-cli payment-methods list
link-cli shipping-address list
```
Use the first entry unless the user specifies otherwise. The `id` from `payment-methods list` is the `--payment-method-id` in the next step.
### 4. Create the spend request
Confirm the final total with the user before issuing this command. Amounts are in cents.
```
link-cli spend-request create \
--payment-method-id <pm_id> \
--merchant-name "<name>" \
--merchant-url "<url>" \
--context "<one sentence: what is being purchased and why>" \
--amount <cents> \
--line-item "name:<item>,unit_amount:<cents>,quantity:1" \
--total "type:total,display_text:Total,amount:<cents>" \
--request-approval
```
For MPP merchants add `--credential-type shared_payment_token`.
`--request-approval` pings the user's Link app and polls until they approve or deny. The CLI exits non-zero on deny / timeout.
### 5. Retrieve the credential — SECURELY
**Do not print card details to stdout.** Use `--output-file` so the PAN never enters the agent's transcript or logs:
```
link-cli spend-request retrieve <lsrq_id> \
--include card \
--output-file /tmp/link-card.json \
--format json
```
The file is written with `0600` perms; stdout shows only redacted fields (brand, last4, expiry) plus a `card_output_file` path.
### 6. Use the credential
- For web checkout: hand the file path to the user, OR pass it to a browser-driving tool that fills the form directly from disk. Never `read_file` or `cat` the card file into the agent's reasoning context.
- For MPP merchants:
```
link-cli mpp pay <merchant-url> \
--spend-request-id <lsrq_id> \
--method POST \
--data '<json body>'
```
### 7. Clean up
Delete the card file as soon as the purchase is done:
```
rm -f /tmp/link-card.json
```
## Optional: run as an MCP server instead
`@stripe/link-cli --mcp` exposes the same commands as MCP tools over stdio. To register it with Hermes' native MCP:
```
hermes mcp add stripe-link --command "npx" --args "@stripe/link-cli --mcp"
```
Then `hermes mcp list` should show `stripe-link`. The same approval rules apply — MCP doesn't bypass the Link app approval step.
## Pitfalls
- **US-only.** Outside the US, `auth login` will fail. Tell the user, don't keep retrying.
- **Card PAN must never enter agent context.** Use `--output-file` every time. If you've already retrieved without it, immediately `link-cli auth logout` is not enough — the card is one-time-use but rotate hygiene matters.
- **`--request-approval` blocks until the user acts.** If the user is asleep, the CLI will hit its timeout. Set expectations.
- **Multi-step `_next` commands.** Some commands return `_next.command` that must be executed to continue. When in doubt, prefer the inline-polling flags (`--interval`/`--timeout`).
- **Output format defaults to `toon`** in non-TTY mode. Fine for prose, but if a downstream step needs to parse a specific field, pass `--format json`.
- **Don't default to `card`.** The merchant-evaluation step (Section 2) exists because picking the wrong credential type fails the purchase silently or leaks more data than needed.
## Verification
```
link-cli --version && link-cli auth status
```
Exit code 0 means installed and logged in.
@@ -0,0 +1,120 @@
---
name: stripe-projects
description: Provision SaaS services + sync creds via Stripe Projects.
version: 0.1.0
author: Teknium (teknium1), Hermes Agent
license: MIT
platforms: [linux, macos]
metadata:
hermes:
tags: [Payments, Stripe, Projects, Provisioning, Infrastructure]
related_skills: [stripe-link-cli, mpp-agent]
---
# Stripe Projects Skill
Wraps the [Stripe Projects](https://projects.dev) CLI plugin so Hermes can provision SaaS services (Neon, Twilio, Vercel, etc.), generate and sync credentials into the user's `.env`, and manage billing across providers from one place.
Gated `[linux, macos]` while the broader payments cluster matures on Windows. The Stripe CLI itself is cross-platform; this gate is a posture for the cluster, not a hard limit.
## When to Use
Trigger phrases:
- "set up <provider>", "provision <Neon|Twilio|Vercel|...>", "create a database"
- "give me a <Postgres|Redis|Twilio number|...> for this project"
- "manage my stack credentials", "rotate this key", "upgrade my plan"
- "what providers can I add?"
If the user already has the service set up manually and just wants to use it, this skill is not the right entry point.
## Prerequisites
- Stripe CLI installed (Homebrew on macOS, package manager on Linux, or download from https://docs.stripe.com/stripe-cli/install)
- Stripe Projects plugin installed
- A Stripe account, logged in via `stripe login`
## Install
macOS:
```
brew install stripe/stripe-cli/stripe
stripe plugin install projects
```
Linux: follow the platform-specific install at https://docs.stripe.com/stripe-cli/install, then:
```
stripe plugin install projects
```
## How to Run
All commands run through the `terminal` tool from inside the user's project directory (the CLI writes `.env` and `.projects/vault/vault.json` into the CWD).
## Procedure
### 1. Initialize the project
```
cd <project-root>
stripe projects init
```
This creates `.projects/vault/vault.json` (encrypted credential store) and prepares the project to receive providers.
### 2. Discover available providers
```
stripe projects catalog
```
Lists every provider Stripe Projects supports — databases, hosting, auth, AI, analytics, messaging, etc.
### 3. Add a service
```
stripe projects add <provider>/<service>
```
Examples:
- `stripe projects add neon/postgres`
- `stripe projects add twilio/sms`
- `stripe projects add runloop/sandbox`
The CLI provisions the service in the user's own account with the provider, generates credentials, syncs them into `.env`, and records the resource in the vault. The user may need to confirm a tier selection or pricing prompt.
### 4. Verify
```
stripe projects list
```
Should show the newly added provider and its `.env` keys.
### 5. Manage / upgrade / remove
```
stripe projects upgrade <provider> # tier change
stripe projects remove <provider> # deprovision
stripe projects rotate <provider> # rotate credentials
```
## Pitfalls
- **`.env` writes are real writes.** The CLI appends to whatever `.env` is in the project root. If the user's `.env` is gitignored (normal), the keys land safely; if not, this skill could be a credential-leak vector. Always check `.gitignore` first.
- **Per-project state.** `.projects/vault/vault.json` is per-project. Provisioning the same service in two different projects creates two separate resources — and two bills.
- **Billing happens on Stripe's side.** Tier prompts during `add`/`upgrade` are real charges; surface them to the user before confirming.
- **Provider availability changes.** The catalog grows; if a provider the user names isn't listed, `stripe projects catalog | grep <name>` first instead of failing the `add` call.
- **Credentials in vault are encrypted but `.env` is plaintext.** Standard `.env` hygiene applies — never commit it.
- **Removing a service does NOT always destroy the underlying resource.** Some providers leave a paused/dormant resource behind. Check the provider's own dashboard after `remove` for high-cost services (managed databases especially).
## Verification
```
stripe projects --version && stripe projects list
```
Exit code 0 inside an initialized project means the plugin is healthy.
+13 -10
View File
@@ -137,10 +137,11 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer. Also accepted as `pinPeerName` |
| `pinPeerName` | bool | `false` | Alias for `pinUserPeer`; same effect |
| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "eri"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer |
| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape |
| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer |
| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"7654321": "alice"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer |
| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_7654321`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape |
> **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it.
**Resolver ladder** (first match wins):
@@ -158,13 +159,15 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
**Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`.
**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these):
**Setup — gateway identity tree.** `hermes honcho setup` only asks about identity mapping when it detects a connected gateway platform (it inspects the gateway config; off-gateway the step is skipped because these keys do nothing without a runtime user ID). When it runs, it asks *who talks to this gateway?* and derives the keys:
- **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc.
- **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans.
- **Hybrid** — `pinUserPeer: false`, `userPeerAliases` mapping the operator's runtime IDs to `peerName`. Multi-user gateway where YOU are routed but others stay distinct.
- **just me** → `pinUserPeer: true`. Every non-agent gateway user collapses to `peerName`; the pin overrides all aliases, so pick this only when no user-side identity needs its own peer. Personal use where you connect Hermes to your own Telegram/Discord/etc. If separate agents reach the gateway and each needs a distinct peer, do **not** pin — leave `pinUserPeer: false` and map them via `userPeerAliases` (the `[e]` editor).
- **me + other people, pooled** → `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName`. You stay on the shared history; everyone else gets their own peer.
- **me + other people / only other people** → `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. For bots serving many humans.
**Migrating single → multi.** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, use the **hybrid** shape — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The setup wizard offers this path automatically when it detects a single → multi transition.
Pick **[e]** at the prompt to set the three keys directly instead of going through the tree.
**Un-pinning (single → per-user).** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, choose the **pooled** path — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The wizard offers this steer automatically when it detects you're un-pinning a previously pinned profile.
### Memory & Recall
@@ -205,7 +208,7 @@ The Honcho session name determines which conversation bucket memory lands in. Re
Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting only affects CLI sessions.
If `sessionPeerPrefix` is `true`, the peer name is prepended: `eri-hermes-agent`.
If `sessionPeerPrefix` is `true`, the peer name is prepended: `alice-hermes-agent`.
#### What each strategy produces
+231 -119
View File
@@ -41,22 +41,20 @@ def clone_honcho_for_profile(profile_name: str) -> bool:
return False # already exists
# Clone settings from default block, override identity fields.
# Identity-mapping keys (pinPeerName/pinUserPeer, userPeerAliases,
# runtimePeerPrefix) carry the operator's runtime-to-peer routing
# intent from #27371. Both pin keys are inherited because
# HonchoClientConfig prefers pinUserPeer over pinPeerName — leaving
# the canonical key off this allowlist silently drops the pin on
# cloned profiles when the default uses the newer name.
# Identity-mapping keys (pinUserPeer, userPeerAliases, runtimePeerPrefix)
# carry the operator's runtime-to-peer routing intent from #27371.
new_block = {}
for key in ("recallMode", "writeFrequency", "sessionStrategy",
"sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel",
"dialecticDynamic", "dialecticMaxChars", "messageMaxChars",
"dialecticMaxInputChars", "saveMessages", "observation",
"pinPeerName", "pinUserPeer", "userPeerAliases",
"runtimePeerPrefix"):
"pinUserPeer", "userPeerAliases", "runtimePeerPrefix"):
val = default_block.get(key)
if val is not None:
new_block[key] = val
# Carry a legacy default-block pinPeerName forward under the canonical key.
if "pinUserPeer" not in new_block and default_block.get("pinPeerName") is not None:
new_block["pinUserPeer"] = default_block["pinPeerName"]
# Inherit peer name from default
peer_name = default_block.get("peerName") or cfg.get("peerName")
@@ -371,15 +369,122 @@ def _resolve_effective_identity_mapping(
def _scrub_identity_mapping(hermes_host: dict) -> None:
"""Drop every peer-mapping key from the host block.
Called before the wizard writes a chosen shape so latent precedence
conflicts can't survive — e.g. a stray host ``pinUserPeer: false``
that would silently outrank a freshly written ``pinPeerName: true``
(host ``pinUserPeer`` is first in the resolver ladder).
Called before the wizard writes a chosen shape so a stale alias, prefix,
or pin from an earlier run can't bleed into the new mapping.
"""
for key in _IDENTITY_MAPPING_KEYS:
hermes_host.pop(key, None)
def _migrate_pin_key(block: dict) -> bool:
"""Rewrite a legacy ``pinPeerName`` to canonical ``pinUserPeer`` in place.
``pinUserPeer`` wins over ``pinPeerName`` in the resolver, so setup writes
only the canonical form and migrates on touch to stop configs carrying
both. Returns True if the block changed.
"""
if "pinPeerName" not in block:
return False
legacy = block.pop("pinPeerName")
if "pinUserPeer" not in block:
block["pinUserPeer"] = legacy
return True
def _gateway_platforms() -> list[str] | None:
"""Connected gateway platforms, or None if undetectable.
Identity mapping only affects gateway runtime users, so setup gates the
whole step on this. Best-effort and dependency-free: the memory plugin
must not hard-depend on the gateway package, so the import is lazy and
guarded (matching the idiom hermes_cli already uses for gateway refs).
"""
try:
from gateway.config import load_gateway_config
return [p.value for p in load_gateway_config().get_connected_platforms()]
except Exception:
return None
def _collect_operator_aliases(existing: dict, peer_target: str) -> dict:
"""Prompt for the operator's per-platform runtime IDs, aliasing each to
``peer_target``. Existing entries are preserved."""
aliases = dict(existing)
print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.")
print(" Leave blank to skip a platform. Existing aliases are preserved.")
for platform_label, alias_hint in (
("Telegram UID", "e.g. 7654321"),
("Discord snowflake", "e.g. 491827364"),
("Slack user ID", "e.g. U04ABCDEF"),
("Matrix MXID", "e.g. @you:matrix.org"),
):
entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip()
if entered:
aliases[entered] = peer_target
return aliases
def _apply_runtime_prefix(
hermes_host: dict, current_prefix: str, prefix_from_root: bool, label: str
) -> None:
"""Write a host-level runtimePeerPrefix only when it diverges from an
inherited root value; otherwise let the root cascade stand."""
new_prefix = _prompt(label, default=current_prefix or "").strip()
if new_prefix and not (prefix_from_root and new_prefix == current_prefix):
hermes_host["runtimePeerPrefix"] = new_prefix
def _echo_identity_mapping(hermes_host: dict) -> None:
"""Show the resulting keys so the operator can verify what was written."""
aliases = hermes_host.get("userPeerAliases")
prefix = hermes_host.get("runtimePeerPrefix")
print(" resolved →")
print(f" pinUserPeer = {bool(hermes_host.get('pinUserPeer'))}")
print(f" userPeerAliases = {aliases if aliases else '{}'}")
print(f" runtimePeerPrefix = {prefix if prefix else '(none)'}")
def _configure_raw_identity_mapping(
hermes_host: dict,
current_pin: bool,
current_aliases: dict,
current_prefix: str,
aliases_from_root: bool,
prefix_from_root: bool,
) -> None:
"""Power-user escape hatch: set the three resolver knobs directly."""
print("\n Raw identity-mapping keys (resolver tries them top-down):")
pin_in = _prompt(
"pinUserPeer — pin all gateway users to your peer? (true/false)",
default=str(bool(current_pin)).lower(),
).strip().lower()
pin = pin_in in {"true", "t", "yes", "y", "1"}
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = pin
if pin:
return
aliases = (
dict(current_aliases)
if isinstance(current_aliases, dict) and not aliases_from_root
else {}
)
print(" userPeerAliases — 'runtime_id=peer' pairs (blank line to finish):")
while True:
entry = _prompt(" alias", default="").strip()
if not entry:
break
if "=" in entry:
rid, peer = (p.strip() for p in entry.split("=", 1))
if rid and peer:
aliases[rid] = peer
if aliases:
hermes_host["userPeerAliases"] = aliases
_apply_runtime_prefix(
hermes_host, current_prefix, prefix_from_root,
"runtimePeerPrefix — namespace for unknown IDs (blank for none)",
)
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
suffix = f" [{default}]" if default else ""
sys.stdout.write(f" {label}{suffix}: ")
@@ -446,6 +551,10 @@ def cmd_setup(args) -> None:
hosts = cfg.setdefault("hosts", {})
hermes_host = hosts.setdefault(_host_key(), {})
# Canonicalize any legacy pinPeerName before detection/writes.
_migrate_pin_key(cfg)
_migrate_pin_key(hermes_host)
# --- 1. Cloud or local? ---
print(" Deployment:")
print(" cloud -- Honcho cloud (api.honcho.dev)")
@@ -545,18 +654,15 @@ def cmd_setup(args) -> None:
if new_workspace:
hermes_host["workspace"] = new_workspace
# --- 3b. Deployment shape ---
# Determines how runtime user identities (Telegram UIDs, Discord
# snowflakes, etc.) map to Honcho peers in gateway sessions. Three
# shapes cover the realistic deployments; each writes a different
# combination of pinPeerName / userPeerAliases / runtimePeerPrefix.
# See plugins/memory/honcho/README.md for the resolver ladder.
# --- 3b. Gateway identity mapping ---
# These keys only affect the Hermes GATEWAY (Telegram/Discord/Slack/...),
# the one entrypoint that supplies a runtime user ID. CLI/TUI/desktop/ACP
# sessions have no runtime ID and fall through to peerName, so the step is
# moot off-gateway — gate it behind detection.
#
# Detection must mirror the gateway resolver: root-level config and
# ``pinUserPeer`` (which outranks ``pinPeerName`` at the same level)
# both affect effective routing, so reading host-only fields would
# mis-classify a profile that inherits its mapping from root or uses
# the newer canonical key.
# Detection mirrors the gateway resolver: root-level config and the
# canonical ``pinUserPeer`` both affect routing, so host-only reads would
# mis-classify a profile that inherits its mapping from root.
(
current_pin,
current_aliases,
@@ -572,103 +678,109 @@ def cmd_setup(args) -> None:
else:
current_shape = "multi"
print("\n Deployment shape (how gateway users map to peers):")
print(" single -- all platforms route to your peer (recommended for personal use)")
print(" multi -- each platform user gets their own peer (multi-user bots)")
print(" hybrid -- multi-user, but YOUR runtime IDs alias to your peer")
print(" skip -- don't touch identity-mapping config")
new_shape = _prompt("Deployment shape", default=current_shape).strip().lower()
# Transitioning single → multi orphans the peerName pool for runtime users
# (their resolved peers go from peerName to runtime-derived IDs with empty
# history). Steer the operator toward hybrid so their own continuity is
# preserved via alias mappings.
if current_shape == "single" and new_shape == "multi":
peer_target = hermes_host.get("peerName") or current_peer or "user"
print(
f"\n ⚠ Switching from single to multi will orphan memory accumulated\n"
f" under peer '{peer_target}'. Existing runtime users (Telegram,\n"
f" Discord, etc.) will resolve to fresh, empty peers."
)
print(" To keep your own continuity, choose 'hybrid' and alias your\n"
" runtime IDs back to peerName.")
confirm = _prompt("Continue with multi anyway? (yes/hybrid/no)", default="hybrid").strip().lower()
if confirm in {"hybrid", "h"}:
new_shape = "hybrid"
elif confirm not in {"yes", "y"}:
new_shape = "skip"
# Each shape branch scrubs every peer-mapping key before writing its own,
# so a stale ``pinUserPeer`` left behind by an earlier setup run can't
# outrank the freshly written ``pinPeerName`` via host-level precedence.
if new_shape == "single":
_scrub_identity_mapping(hermes_host)
hermes_host["pinPeerName"] = True
print(f" pinPeerName=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.")
elif new_shape == "multi":
# Preserve operator-curated, host-level aliases so multi → multi
# re-runs don't drop them. Root-sourced aliases are left to
# cascade naturally and are NOT copied down into the host.
prior_aliases = (
dict(current_aliases)
if isinstance(current_aliases, dict) and not aliases_from_root
else {}
)
_scrub_identity_mapping(hermes_host)
hermes_host["pinPeerName"] = False
# Do NOT auto-write ``userPeerAliases: {}``: an empty host map
# would override any root-level ``userPeerAliases`` the operator
# set as a cross-host baseline, silently disabling those aliases.
# Absence is the right "no host opinion" signal.
if prior_aliases:
hermes_host["userPeerAliases"] = prior_aliases
_prefix_default = current_prefix or ""
_new_prefix = _prompt(
"Runtime peer prefix (e.g. 'telegram_', blank for none)",
default=_prefix_default,
).strip()
# Only write a host-level prefix when the operator typed one that
# diverges from the inherited root value; otherwise let the root
# cascade continue unmodified.
if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):
hermes_host["runtimePeerPrefix"] = _new_prefix
print(" Multi-user mode: each runtime ID → own peer. Use 'hermes honcho status' to inspect.")
elif new_shape == "hybrid":
# Hybrid encodes operator intent at the host level: collect existing
# entries (host or root) so the wizard never silently drops a known
# alias, then write the combined map. Materialising root entries
# into the host is the right move here — once the operator answers
# the alias prompts for a host, they're declaring "this host owns
# the mapping".
existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {}
_scrub_identity_mapping(hermes_host)
hermes_host["pinPeerName"] = False
peer_target = hermes_host.get("peerName") or current_peer or "user"
print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.")
print(" Leave blank to skip a platform. Existing aliases are preserved.")
for platform_label, alias_hint in (
("Telegram UID", "e.g. 86701400"),
("Discord snowflake", "e.g. 491827364"),
("Slack user ID", "e.g. U04ABCDEF"),
("Matrix MXID", "e.g. @you:matrix.org"),
):
entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip()
if entered:
existing_aliases[entered] = peer_target
if existing_aliases:
hermes_host["userPeerAliases"] = existing_aliases
_prefix_default = current_prefix or ""
_new_prefix = _prompt(
"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)",
default=_prefix_default,
).strip()
if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):
hermes_host["runtimePeerPrefix"] = _new_prefix
print(f" Hybrid mode: your runtime IDs → '{peer_target}', others → own peer.")
elif new_shape == "skip":
pass # leave config untouched
gw_platforms = _gateway_platforms()
if gw_platforms is None:
print("\n Gateway identity mapping routes platform users to memory peers.")
run_mapping = _prompt(
"Running the Hermes gateway (Telegram/Discord/etc.)? (y/N)",
default="n",
).strip().lower() in {"y", "yes"}
elif not gw_platforms:
print("\n No gateway platforms connected — identity mapping only affects")
print(" gateway users, so this step doesn't apply here.")
run_mapping = _prompt(
"Configure gateway mapping anyway? (y/N)", default="n",
).strip().lower() in {"y", "yes"}
else:
print(f" Unknown shape '{new_shape}' — leaving identity-mapping config untouched.")
print(f"\n Gateway platforms detected: {', '.join(gw_platforms)}")
run_mapping = True
if run_mapping:
peer_target = hermes_host.get("peerName") or current_peer or "user"
default_choice = {"single": "1", "hybrid": "2", "multi": "3"}.get(current_shape, "3")
print("\n How should gateway users map to memory peers?")
print(" [1] just me — every non-agent user collapses to your peer")
print(" [2] me + other people — keep mine pooled, others separate")
print(" [3] only other people — everyone gets their own peer")
print(" [s] skip (leave untouched) [e] edit raw keys")
choice = _prompt("Choice", default=default_choice).strip().lower()
if choice in {"2", "me+others", "both"}:
pooled = _prompt(
" Keep my own memory pooled across platforms? (Y/n)", default="y",
).strip().lower()
shape = "hybrid" if pooled in {"y", "yes", ""} else "multi"
elif choice in {"1", "me", "just-me"}:
shape = "single"
elif choice in {"3", "others"}:
shape = "multi"
elif choice in {"e", "edit", "raw"}:
shape = "raw"
else:
shape = "skip"
# Un-pinning a currently-pinned profile without aliasing strands the
# pooled peerName history; steer the operator toward pooling instead.
if current_pin and shape == "multi":
print(
f"\n ⚠ Un-pinning will orphan memory accumulated under peer\n"
f" '{peer_target}'. Existing gateway users resolve to fresh,\n"
f" empty peers."
)
confirm = _prompt(
" Pool my own memory instead (alias my IDs to peerName)? (Y/n)",
default="y",
).strip().lower()
if confirm in {"y", "yes", ""}:
shape = "hybrid"
# Each branch scrubs every peer-mapping key first so a stale alias,
# prefix, or pin from an earlier run starts clean.
if shape == "single":
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = True
print(f" All non-agent gateway users route to '{peer_target}' (pin overrides aliases).")
_echo_identity_mapping(hermes_host)
elif shape == "multi":
# Preserve operator-curated host-level aliases across multi → multi
# re-runs. Root-sourced aliases cascade naturally and are NOT
# copied down — an empty host map would mask a root baseline.
prior_aliases = (
dict(current_aliases)
if isinstance(current_aliases, dict) and not aliases_from_root
else {}
)
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = False
if prior_aliases:
hermes_host["userPeerAliases"] = prior_aliases
_apply_runtime_prefix(
hermes_host, current_prefix, prefix_from_root,
"Runtime peer prefix (e.g. 'telegram_', blank for none)",
)
print(" Each gateway user → own peer.")
_echo_identity_mapping(hermes_host)
elif shape == "hybrid":
existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {}
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = False
merged = _collect_operator_aliases(existing_aliases, peer_target)
if merged:
hermes_host["userPeerAliases"] = merged
_apply_runtime_prefix(
hermes_host, current_prefix, prefix_from_root,
"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)",
)
print(f" Your runtime IDs → '{peer_target}', others → own peer.")
_echo_identity_mapping(hermes_host)
elif shape == "raw":
_configure_raw_identity_mapping(
hermes_host, current_pin, current_aliases, current_prefix,
aliases_from_root, prefix_from_root,
)
_echo_identity_mapping(hermes_host)
else: # skip
print(" Identity mapping left untouched.")
# --- 4. Observation mode ---
current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional")
+31
View File
@@ -31,6 +31,12 @@ _DISCORD_COMMAND_SYNC_STATE_SUBDIR = "gateway"
_DISCORD_COMMAND_SYNC_STATE_FILENAME = "discord_command_sync_state.json"
_DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5
_DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0
# Discord enforces a hard cap of 100 global application (slash) commands per
# app. Registering more makes the ENTIRE sync fail with error 30032
# ("Maximum number of application commands reached"), which silently breaks
# every slash command — not just the overflow ones. We keep the desired set
# at or below this limit at registration time.
_DISCORD_MAX_APP_COMMANDS = 100
try:
import discord
@@ -3518,6 +3524,11 @@ class DiscordAdapter(BasePlatformAdapter):
)
already_registered: set[str] = set()
# Native commands above are registered first and are the highest
# priority, so they always survive the 100-command cap. Reserve one
# slot for the consolidated ``/skill`` group registered further below.
slot_cap = _DISCORD_MAX_APP_COMMANDS - 1
dropped_over_cap = 0
try:
from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates
@@ -3535,6 +3546,9 @@ class DiscordAdapter(BasePlatformAdapter):
discord_name = cmd_def.name.lower()[:32]
if discord_name in already_registered:
continue
if len(already_registered) >= slot_cap:
dropped_over_cap += 1
continue
auto_cmd = _build_auto_slash_command(
cmd_def.name,
cmd_def.description,
@@ -3567,6 +3581,9 @@ class DiscordAdapter(BasePlatformAdapter):
discord_name = plugin_name.lower()[:32]
if discord_name in already_registered:
continue
if len(already_registered) >= slot_cap:
dropped_over_cap += 1
continue
auto_cmd = _build_auto_slash_command(
plugin_name,
plugin_desc,
@@ -3589,6 +3606,20 @@ class DiscordAdapter(BasePlatformAdapter):
# supporting up to 25 categories × 25 skills = 625 skills.
self._register_skill_group(tree)
if dropped_over_cap:
# Staying under the cap keeps the whole sync succeeding; without
# this guard a single over-limit command makes Discord reject the
# entire batch (error 30032), breaking every slash command.
logger.warning(
"[%s] Reached Discord's limit of %d slash commands; skipped %d "
"lower-priority command(s) to keep the command sync working. "
"Disable slash commands you don't need or trim installed plugins "
"to surface them all.",
self.name,
_DISCORD_MAX_APP_COMMANDS,
dropped_over_cap,
)
# Optional defense-in-depth: hide every slash command from non-admin
# guild members in Discord's slash picker. Server-side authorization
# (``_check_slash_authorization``) is the actual gate; this is purely
+100 -24
View File
@@ -96,6 +96,9 @@ class MattermostAdapter(BasePlatformAdapter):
or os.getenv("MATTERMOST_REPLY_MODE", "off")
).lower()
self._last_post_status: Optional[int] = None
self._last_post_error: str = ""
# Dedup cache (prevent reprocessing)
self._dedup = MessageDeduplicator()
@@ -130,20 +133,79 @@ class MattermostAdapter(BasePlatformAdapter):
"""POST /api/v4/{path} with JSON body."""
import aiohttp
url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
self._last_post_status = None
self._last_post_error = ""
try:
async with self._session.post(
url, headers=self._headers(), json=payload,
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
self._last_post_status = resp.status
if resp.status >= 400:
body = await resp.text()
self._last_post_error = body or ""
logger.error("MM API POST %s → %s: %s", path, resp.status, body[:200])
return {}
return await resp.json()
except aiohttp.ClientError as exc:
self._last_post_error = str(exc)
logger.error("MM API POST %s network error: %s", path, exc)
return {}
async def _thread_root_for_send(
self,
reply_to: Optional[str],
metadata: Optional[Dict[str, Any]],
) -> Optional[str]:
"""Resolve the Mattermost root_id from reply_to or metadata."""
if self._reply_mode != "thread":
return None
candidate = reply_to
if not candidate and isinstance(metadata, dict):
candidate = metadata.get("thread_id") or metadata.get("root_id")
if not candidate:
return None
return await self._resolve_root_id(str(candidate))
def _last_post_failure_is_broken_thread_root(self) -> bool:
"""Return True only for clear invalid/missing Mattermost thread roots."""
if self._last_post_status not in {400, 404}:
return False
body = (self._last_post_error or "").lower()
if not body:
return False
rootish = any(marker in body for marker in ("root_id", "rootid", "root id", "thread", "post"))
broken = any(marker in body for marker in ("invalid", "not found", "does not exist", "missing"))
return rootish and broken
async def _post_preserving_thread(
self,
chat_id: str,
payload: Dict[str, Any],
metadata: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
"""Post once, optionally falling back flat for final notify content."""
data = await self._api_post("posts", payload)
if data or "root_id" not in payload:
return data
if not (isinstance(metadata, dict) and metadata.get("notify")):
return data
if not self._last_post_failure_is_broken_thread_root():
return data
flat_payload = dict(payload)
flat_payload.pop("root_id", None)
original = str(flat_payload.get("message") or "")
flat_payload["message"] = (
"⚠️ Mattermost thread delivery failed; posting final reply in channel.\n\n"
+ original
).strip()
logger.warning(
"Mattermost: falling back to flat channel delivery for notify-worthy post in %s",
chat_id,
)
return await self._api_post("posts", flat_payload)
async def _api_put(
self, path: str, payload: Dict[str, Any]
) -> Dict[str, Any]:
@@ -286,14 +348,12 @@ class MattermostAdapter(BasePlatformAdapter):
"channel_id": chat_id,
"message": chunk,
}
# Thread support: reply_to is the root post ID.
if reply_to and self._reply_mode == "thread":
# Ensure root_id points to the thread root, not a reply.
# Mattermost rejects non-root post IDs as root_id.
resolved_root = await self._resolve_root_id(reply_to)
# Thread support: reply_to or metadata["thread_id"] is the root post ID.
resolved_root = await self._thread_root_for_send(reply_to, metadata)
if resolved_root:
payload["root_id"] = resolved_root
data = await self._api_post("posts", payload)
data = await self._post_preserving_thread(chat_id, payload, metadata)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to create post")
last_id = data["id"]
@@ -346,7 +406,7 @@ class MattermostAdapter(BasePlatformAdapter):
) -> SendResult:
"""Download an image and upload it as a file attachment."""
return await self._send_url_as_file(
chat_id, image_url, caption, reply_to, "image"
chat_id, image_url, caption, reply_to, "image", metadata
)
async def send_image_file(
@@ -359,7 +419,7 @@ class MattermostAdapter(BasePlatformAdapter):
) -> SendResult:
"""Upload a local image file."""
return await self._send_local_file(
chat_id, image_path, caption, reply_to
chat_id, image_path, caption, reply_to, metadata=metadata
)
async def send_document(
@@ -373,7 +433,7 @@ class MattermostAdapter(BasePlatformAdapter):
) -> SendResult:
"""Upload a local file as a document."""
return await self._send_local_file(
chat_id, file_path, caption, reply_to, file_name
chat_id, file_path, caption, reply_to, file_name, metadata
)
async def send_voice(
@@ -386,7 +446,7 @@ class MattermostAdapter(BasePlatformAdapter):
) -> SendResult:
"""Upload an audio file."""
return await self._send_local_file(
chat_id, audio_path, caption, reply_to
chat_id, audio_path, caption, reply_to, metadata=metadata
)
async def send_video(
@@ -399,7 +459,7 @@ class MattermostAdapter(BasePlatformAdapter):
) -> SendResult:
"""Upload a video file."""
return await self._send_local_file(
chat_id, video_path, caption, reply_to
chat_id, video_path, caption, reply_to, metadata=metadata
)
def format_message(self, content: str) -> str:
@@ -423,12 +483,13 @@ class MattermostAdapter(BasePlatformAdapter):
caption: Optional[str],
reply_to: Optional[str],
kind: str = "file",
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Download a URL and upload it as a file attachment."""
from tools.url_safety import is_safe_url
if not is_safe_url(url):
logger.warning("Mattermost: blocked unsafe URL (SSRF protection)")
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
import aiohttp
@@ -446,7 +507,7 @@ class MattermostAdapter(BasePlatformAdapter):
await asyncio.sleep(1.5 * (attempt + 1))
continue
if resp.status >= 400:
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
file_data = await resp.read()
ct = resp.content_type or "application/octet-stream"
break
@@ -455,25 +516,26 @@ class MattermostAdapter(BasePlatformAdapter):
await asyncio.sleep(1.5 * (attempt + 1))
continue
logger.warning("Mattermost: failed to download %s after %d attempts: %s", url, attempt + 1, exc)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
if file_data is None:
logger.warning("Mattermost: download returned no data for %s", url)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
file_id = await self._upload_file(chat_id, file_data, fname, ct)
if not file_id:
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
payload: Dict[str, Any] = {
"channel_id": chat_id,
"message": caption or "",
"file_ids": [file_id],
}
if reply_to and self._reply_mode == "thread":
payload["root_id"] = await self._resolve_root_id(reply_to)
resolved_root = await self._thread_root_for_send(reply_to, metadata)
if resolved_root:
payload["root_id"] = resolved_root
data = await self._api_post("posts", payload)
data = await self._post_preserving_thread(chat_id, payload, metadata)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to post with file")
return SendResult(success=True, message_id=data["id"])
@@ -485,6 +547,7 @@ class MattermostAdapter(BasePlatformAdapter):
caption: Optional[str],
reply_to: Optional[str],
file_name: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload a local file and attach it to a post."""
import mimetypes
@@ -509,10 +572,11 @@ class MattermostAdapter(BasePlatformAdapter):
"message": caption or "",
"file_ids": [file_id],
}
if reply_to and self._reply_mode == "thread":
payload["root_id"] = await self._resolve_root_id(reply_to)
resolved_root = await self._thread_root_for_send(reply_to, metadata)
if resolved_root:
payload["root_id"] = resolved_root
data = await self._api_post("posts", payload)
data = await self._post_preserving_thread(chat_id, payload, metadata)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to post with file")
return SendResult(success=True, message_id=data["id"])
@@ -596,11 +660,14 @@ class MattermostAdapter(BasePlatformAdapter):
"message": "\n".join(caption_parts),
"file_ids": file_ids,
}
resolved_root = await self._thread_root_for_send(None, metadata)
if resolved_root:
payload["root_id"] = resolved_root
logger.info(
"Mattermost: sending %d image(s) as single post (chunk %d/%d)",
len(file_ids), chunk_idx + 1, len(chunks),
)
data = await self._api_post("posts", payload)
data = await self._post_preserving_thread(chat_id, payload, metadata)
if not data or "id" not in data:
logger.warning("Mattermost: multi-image post failed, falling back")
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
@@ -786,8 +853,16 @@ class MattermostAdapter(BasePlatformAdapter):
sender_id = post.get("user_id", "")
sender_name = data.get("sender_name", "").lstrip("@") or sender_id
# Thread support: if the post is in a thread, use root_id.
# Thread support: if the post is in a thread, use root_id. In
# thread mode, top-level channel posts are valid roots for progress.
thread_id = post.get("root_id") or None
if (
not thread_id
and self._reply_mode == "thread"
and channel_type_raw != "D"
and post_id
):
thread_id = post_id
# Determine message type.
file_ids = post.get("file_ids") or []
@@ -849,6 +924,7 @@ class MattermostAdapter(BasePlatformAdapter):
user_id=sender_id,
user_name=sender_name,
thread_id=thread_id,
message_id=post_id,
)
# Per-channel ephemeral prompt
+10 -8
View File
@@ -54,8 +54,10 @@ hermes gateway start
1. **Device login** (RFC 8628, `client_id=photon-cli`) — opens
`https://app.photon.codes/` for approval and stores the bearer token.
2. **Find or create** the `Hermes Agent` project on the Photon dashboard.
3. **Enable Spectrum**, read the project's `spectrumProjectId`, rotate the
project secret, and persist both.
3. **Provision the project secret** — mint a fresh project secret (the
dashboard reveals it only once) and persist it to `~/.hermes/.env` so the
sidecar can authenticate `spectrum-ts`. Spectrum is always on, so there's no
separate enable step.
4. **Register your phone number** as a Spectrum user (idempotent — skipped if
a user with that number already exists).
5. **Print the assigned iMessage line** — the number you text to reach your
@@ -75,7 +77,7 @@ Runtime SDK credentials live in `~/.hermes/.env` (the same place every other
channel keeps its token), and the adapter reads them from the environment:
```bash
PHOTON_PROJECT_ID=<spectrumProjectId> # the SDK's projectId
PHOTON_PROJECT_ID=<projectId> # the SDK's projectId (same as the dashboard project id)
PHOTON_PROJECT_SECRET=<projectSecret>
```
@@ -89,8 +91,8 @@ Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
],
"photon_project": [
{
"dashboard_project_id": "<dashboard id>",
"spectrum_project_id": "<spectrumProjectId>",
"dashboard_project_id": "<project id>",
"spectrum_project_id": "<project id>",
"project_secret": "<projectSecret>",
"name": "Hermes Agent"
}
@@ -99,9 +101,9 @@ Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
}
```
> **Note on ids.** A Photon project has two identifiers: the dashboard `id`
> (used for management API calls) and the `spectrumProjectId` (what the SDK
> authenticates with). `PHOTON_PROJECT_ID` is the **spectrum** id.
> **Note on ids.** A Photon project's dashboard id and its Spectrum project id
> are the same value, exposed as `PHOTON_PROJECT_ID`. The `dashboard_project_id`
> and `spectrum_project_id` keys in `auth.json` both hold that id.
## Configuration knobs
+40 -59
View File
@@ -3,29 +3,29 @@ Photon Dashboard API client + device-code login flow.
This module is pure Python — it intentionally does not depend on
``spectrum-ts``. Every management-plane operation (login, find/create
project, enable Spectrum, rotate the project secret, register a user,
list the assigned iMessage line) talks to Photon's **Dashboard API** on a
single host, exactly like the official Photon CLI (``photon-hq/cli``):
project, rotate the project secret, register a user, list the assigned
iMessage line) talks to Photon's **Dashboard API** on a single host,
exactly like the official Photon CLI (``photon-hq/cli``):
Dashboard API https://app.photon.codes/api/...
OAuth 2.0 device flow, Bearer access token
A Photon project carries two distinct identifiers:
* ``id`` — the Dashboard project id (used in API paths)
* ``spectrumProjectId`` — the Spectrum Cloud project id, populated when
Spectrum is enabled on the project
A Photon project has a single identifier: the dashboard ``id`` *is* the
Spectrum Cloud project id. They used to diverge (a separate
``spectrumProjectId`` field), but the dashboard unified them — every
project is created with matching ids and the pre-existing diverged rows
were backfilled so ``project.id == spectrumProjectId`` everywhere
(dashboard ENG-1582). Spectrum is always enabled and provisioned at
create-time, so there is no enable/toggle step anymore.
The ``spectrum-ts`` SDK (run by the Node sidecar) authenticates to Spectrum
Cloud with ``(spectrumProjectId, projectSecret)`` — so the value we persist
as ``PHOTON_PROJECT_ID`` for the runtime is the **spectrumProjectId**, not
the Dashboard ``id``. The Dashboard ``id`` is kept only for management
calls.
Cloud with ``(id, projectSecret)`` — the same ``id`` used in Dashboard API
paths — which we persist as ``PHOTON_PROJECT_ID`` for the runtime.
Credential storage mirrors every other Hermes channel:
* runtime SDK creds -> ``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` =
spectrumProjectId, ``PHOTON_PROJECT_SECRET``) via ``save_env_value``
project id, ``PHOTON_PROJECT_SECRET``) via ``save_env_value``
* management metadata -> ``~/.hermes/auth.json`` under
``credential_pool.photon`` (device token),
``credential_pool.photon_project`` (dashboard id, spectrum id, name), and
@@ -148,8 +148,8 @@ def load_project_credentials() -> Tuple[Optional[str], Optional[str]]:
Precedence: process env (``~/.hermes/.env`` is loaded into the gateway's
environment at startup) wins, then ``auth.json`` for offline / status
use. This is the pair the Node sidecar feeds to ``spectrum-ts`` — the id
is the **spectrumProjectId**, not the Dashboard id.
use. This is the pair the Node sidecar feeds to ``spectrum-ts``; the id
is the unified project id (dashboard id == spectrumProjectId).
"""
env_id = os.getenv("PHOTON_PROJECT_ID")
env_sec = os.getenv("PHOTON_PROJECT_SECRET")
@@ -166,14 +166,26 @@ def load_project_credentials() -> Tuple[Optional[str], Optional[str]]:
def load_dashboard_project_id() -> Optional[str]:
"""Return the Dashboard project id (for management API calls)."""
"""Return the project id used for management API calls.
Post-unification the dashboard id and the Spectrum id are the same value,
so we prefer the stored ``spectrum_project_id``: for pre-backfill installs
the old ``dashboard_project_id`` is the diverged id that the unification
rewrote (it now 404s), while the Spectrum id always matches the live row.
Falls back to the legacy keys for older records.
"""
env_id = os.getenv("PHOTON_DASHBOARD_PROJECT_ID")
if env_id:
return env_id
auth = _load_auth()
proj = auth.get("credential_pool", {}).get("photon_project") or []
if isinstance(proj, list) and proj:
return proj[0].get("dashboard_project_id") or proj[0].get("project_id")
entry = proj[0]
return (
entry.get("spectrum_project_id")
or entry.get("dashboard_project_id")
or entry.get("project_id")
)
return None
@@ -646,30 +658,23 @@ def find_project_by_name(token: str, name: str) -> Optional[Dict[str, Any]]:
return None
def get_project(token: str, project_id: str) -> Dict[str, Any]:
"""GET ``/api/projects/{id}`` — includes ``spectrum`` + ``spectrumProjectId``."""
if httpx is None:
raise RuntimeError("httpx is required for Photon")
url = f"{_dashboard_host()}/api/projects/{project_id}"
resp = httpx.get(url, headers=_bearer(token), timeout=30.0)
resp.raise_for_status()
return resp.json() or {}
def create_project(
token: str,
*,
name: str = DEFAULT_PROJECT_NAME,
location: str = "United States",
) -> Dict[str, Any]:
"""POST ``/api/projects`` with ``spectrum: true`` and return ``{success, id}``."""
"""POST ``/api/projects`` and return ``{success, id}``.
Spectrum is always provisioned at create-time, so the request body no
longer carries a ``spectrum`` flag (the field was dropped from the API).
"""
if httpx is None:
raise RuntimeError("httpx is required for Photon project creation")
url = f"{_dashboard_host()}/api/projects"
body: Dict[str, Any] = {
"name": name,
"location": location,
"spectrum": True,
"template": False,
"observability": False,
}
@@ -683,29 +688,6 @@ def create_project(
return data
def ensure_spectrum_enabled(token: str, project_id: str) -> Dict[str, Any]:
"""Enable Spectrum on the project if needed; return the project dict.
The dashboard exposes Spectrum as a toggle, so we only flip it when
``spectrum`` is currently false, then re-fetch to pick up the freshly
populated ``spectrumProjectId``.
"""
if httpx is None:
raise RuntimeError("httpx is required for Photon")
proj = get_project(token, project_id)
if not proj.get("spectrum"):
url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/toggle"
resp = httpx.post(url, json={}, headers=_bearer(token), timeout=30.0)
resp.raise_for_status()
proj = get_project(token, project_id)
if not proj.get("spectrumProjectId"):
raise RuntimeError(
"Spectrum is enabled but the project has no spectrumProjectId yet — "
"retry in a moment, or enable Spectrum from the dashboard."
)
return proj
def regenerate_project_secret(token: str, project_id: str) -> str:
"""POST ``/api/projects/{id}/regenerate-secret`` → the new project secret.
@@ -1007,8 +989,9 @@ def print_credential_summary(emit: Any = print) -> None:
else "✗ missing (run `hermes photon setup`)"
)
sid, sec = load_project_credentials()
labels["spectrum_project_id"] = sid if sid else "✗ missing"
labels["dashboard_project_id"] = load_dashboard_project_id() or "—"
# Dashboard id and Spectrum id are the same value now (ids unified), so
# there's a single project id to show.
labels["project_id"] = sid if sid else "✗ missing"
labels["project_key"] = "✓ stored" if sec else "✗ missing"
phone, assigned = load_user_numbers()
labels["phone_number"] = (
@@ -1022,8 +1005,7 @@ def print_credential_summary(emit: Any = print) -> None:
"Photon iMessage status",
"──────────────────────",
" device token : " + labels["device_token"],
" dashboard project : " + labels["dashboard_project_id"],
" spectrum project id : " + labels["spectrum_project_id"],
" project id : " + labels["project_id"],
" project secret : " + labels["project_key"],
" my number : " + labels["phone_number"],
" assigned number : " + labels["assigned_phone_number"],
@@ -1039,7 +1021,7 @@ def credential_summary() -> Dict[str, str]:
else "✗ missing (run `hermes photon setup`)"
)
def _present_spectrum_id() -> str:
def _present_project_id() -> str:
sid, _sec = load_project_credentials()
return sid or "✗ missing"
@@ -1057,8 +1039,7 @@ def credential_summary() -> Dict[str, str]:
return {
"device_token": _present_token(),
"dashboard_project_id": load_dashboard_project_id() or "—",
"spectrum_project_id": _present_spectrum_id(),
"project_id": _present_project_id(),
"project_key": _present_secret(),
"phone_number": _present_phone(),
"assigned_phone_number": _present_assigned_phone(),
+8 -10
View File
@@ -164,16 +164,14 @@ def _cmd_setup(args: argparse.Namespace) -> int:
print("could not resolve a Photon project id", file=sys.stderr)
return 1
# 3. Enable Spectrum, fetch the spectrum project id, rotate the secret,
# and persist both (runtime creds -> ~/.hermes/.env, ids -> auth.json).
# 3. Rotate the project secret and persist creds (runtime -> ~/.hermes/.env,
# ids -> auth.json). Spectrum is always enabled and provisioned at
# create-time, and the dashboard project id *is* the Spectrum project id
# (ids unified), so there's nothing to enable — the id we already have is
# the Spectrum id.
try:
print("[3/5] Enabling Spectrum and provisioning credentials...")
proj = photon_auth.ensure_spectrum_enabled(token, dashboard_id)
spectrum_id = proj.get("spectrumProjectId")
if not spectrum_id:
print("spectrum provisioning failed: no spectrum project id", file=sys.stderr)
return 1
spectrum_id = str(spectrum_id)
print("[3/5] Provisioning Spectrum credentials...")
spectrum_id = dashboard_id
secret = photon_auth.regenerate_project_secret(token, dashboard_id)
photon_auth.store_project_credentials(
spectrum_project_id=spectrum_id,
@@ -182,7 +180,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
name=name,
)
# spectrum_id is an opaque non-secret id; safe to show.
print(f" ✓ Spectrum enabled (project id {spectrum_id}) — secret saved")
print(f" ✓ Spectrum ready (project id {spectrum_id}) — secret saved")
except Exception as e:
print(f"spectrum provisioning failed: {e}", file=sys.stderr)
return 1
+72 -2
View File
@@ -617,7 +617,74 @@ async def _standalone_send(
# Keep the old name as an alias so existing test imports don't break.
check_teams_requirements = check_requirements
# NOTE: ``check_requirements`` is the PASSIVE probe (used as the registry
# ``check_fn`` and by ``gateway status``) — it must never trigger a pip
# install. ``check_teams_requirements`` is the ACTIVE lazy-installer called
# from ``connect()``; it installs ``platform.teams`` on demand and rebinds the
# SDK globals, mirroring ``check_slack_requirements`` in gateway/platforms/slack.py.
def check_teams_requirements() -> bool:
"""Ensure the Teams SDK is importable, lazy-installing it on first use.
Lazy-installs ``microsoft-teams-apps`` via
``tools.lazy_deps.ensure("platform.teams")`` if not present, then rebinds
all module-level SDK globals on success. Returns True once the SDK (and
aiohttp) are importable, False if they couldn't be installed/imported.
"""
if TEAMS_SDK_AVAILABLE and AIOHTTP_AVAILABLE:
return True
def _import() -> dict:
from aiohttp import web as _web
from microsoft_teams.apps import App, ActivityContext
from microsoft_teams.common.http.client import ClientOptions
from microsoft_teams.api import MessageActivity, ConversationReference
from microsoft_teams.api.activities.typing import TypingActivityInput
from microsoft_teams.api.activities.invoke.adaptive_card import (
AdaptiveCardInvokeActivity,
)
from microsoft_teams.api.models.adaptive_card import (
AdaptiveCardActionCardResponse,
AdaptiveCardActionMessageResponse,
)
from microsoft_teams.api.models.invoke_response import (
InvokeResponse,
AdaptiveCardInvokeResponse,
)
from microsoft_teams.apps.http.adapter import (
HttpMethod,
HttpRequest,
HttpResponse,
HttpRouteHandler,
)
from microsoft_teams.cards import AdaptiveCard, ExecuteAction, TextBlock
return {
"web": _web,
"AIOHTTP_AVAILABLE": True,
"App": App,
"ActivityContext": ActivityContext,
"ClientOptions": ClientOptions,
"MessageActivity": MessageActivity,
"ConversationReference": ConversationReference,
"TypingActivityInput": TypingActivityInput,
"AdaptiveCardInvokeActivity": AdaptiveCardInvokeActivity,
"AdaptiveCardActionCardResponse": AdaptiveCardActionCardResponse,
"AdaptiveCardActionMessageResponse": AdaptiveCardActionMessageResponse,
"InvokeResponse": InvokeResponse,
"AdaptiveCardInvokeResponse": AdaptiveCardInvokeResponse,
"HttpMethod": HttpMethod,
"HttpRequest": HttpRequest,
"HttpResponse": HttpResponse,
"HttpRouteHandler": HttpRouteHandler,
"AdaptiveCard": AdaptiveCard,
"ExecuteAction": ExecuteAction,
"TextBlock": TextBlock,
"TEAMS_SDK_AVAILABLE": True,
}
from tools.lazy_deps import ensure_and_bind
return ensure_and_bind("platform.teams", _import, globals(), prompt=False)
class TeamsAdapter(BasePlatformAdapter):
@@ -642,10 +709,13 @@ class TeamsAdapter(BasePlatformAdapter):
self._conv_refs: Dict[str, Any] = {}
async def connect(self) -> bool:
# Lazy-install the Teams SDK on demand (parity with Slack/Discord/etc.),
# then re-check the module globals it rebinds.
check_teams_requirements()
if not TEAMS_SDK_AVAILABLE:
self._set_fatal_error(
"MISSING_SDK",
"microsoft-teams-apps not installed. Run: pip install microsoft-teams-apps",
"microsoft-teams-apps could not be installed. Run: pip install microsoft-teams-apps",
retryable=False,
)
return False
+1
View File
@@ -179,6 +179,7 @@ mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
nemo-relay = ["nemo-relay==0.3"]
homeassistant = ["aiohttp==3.13.4"]
sms = ["aiohttp==3.13.4"]
teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.13.4"]
# Computer use — macOS background desktop control via cua-driver (MCP stdio).
# The cua-driver binary itself is installed via `hermes tools` post-setup
# (curl install script); this extra just pins the MCP client used to talk
+7 -1
View File
@@ -1411,10 +1411,15 @@ class AIAgent:
def _summarize_background_review_actions(
review_messages: List[Dict],
prior_snapshot: List[Dict],
notification_mode: str = "on",
) -> List[str]:
"""Forwarder — see ``agent.background_review.summarize_background_review_actions``."""
from agent.background_review import summarize_background_review_actions
return summarize_background_review_actions(review_messages, prior_snapshot)
return summarize_background_review_actions(
review_messages,
prior_snapshot,
notification_mode=notification_mode,
)
def _spawn_background_review(
self,
@@ -5140,6 +5145,7 @@ class AIAgent:
acp_command=function_args.get("acp_command"),
acp_args=function_args.get("acp_args"),
role=function_args.get("role"),
background=function_args.get("background"),
parent_agent=self,
)
+26
View File
@@ -413,6 +413,25 @@ get_command_link_display_dir() {
fi
}
# Point a Hermes-managed Node's `npm install -g` at a directory that is on
# PATH. npm's default global prefix for a bundled Node is the Node dir itself,
# so global package binaries land in $HERMES_HOME/node/bin — which is NOT on
# PATH (only the command link dir is) and is wiped on every Node upgrade.
# Redirecting the prefix to the link dir's parent makes global bins resolve to
# the command link dir (node/npm/npx live there too, already on PATH) and
# survive upgrades. Scoped to the managed Node via its prefix-local global
# npmrc, so the user's other Node installs and their ~/.npmrc are untouched.
# Hermes's own global installs pass an explicit --prefix and are unaffected.
# Idempotent and a no-op when there is no Hermes-managed npm, so calling it on
# every install run repairs pre-existing installs, not just fresh ones.
configure_managed_node_npm_prefix() {
[ -x "$HERMES_HOME/node/bin/npm" ] || return 0
local link_dir
link_dir="$(get_command_link_dir)"
mkdir -p "$HERMES_HOME/node/etc"
printf 'prefix=%s\n' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc"
}
get_hermes_command_path() {
local link_dir
link_dir="$(get_command_link_dir)"
@@ -722,6 +741,11 @@ node_satisfies_build() {
check_node() {
log_info "Checking Node.js (for browser tools)..."
# Repair pre-existing Hermes-managed installs where `npm install -g` lands
# off PATH. No-op when there's no managed Node, so this is safe to run on
# every install — including re-runs that skip the Node (re)install below.
configure_managed_node_npm_prefix
if command -v node &> /dev/null && node_satisfies_build "$(node --version)"; then
log_success "Node.js $(node --version) found"
HAS_NODE=true
@@ -851,6 +875,8 @@ install_node() {
ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx"
configure_managed_node_npm_prefix
export PATH="$HERMES_HOME/node/bin:$PATH"
local installed_ver
+20
View File
@@ -57,6 +57,19 @@ _nb_get_link_dir() {
fi
}
# Redirect a Hermes-managed Node's `npm install -g` to the command link dir
# (already on PATH) instead of the default $HERMES_HOME/node/bin, which is off
# PATH and wiped on every Node upgrade. Scoped to the managed Node via its
# prefix-local global npmrc; the user's other Node installs / ~/.npmrc are
# untouched. Idempotent no-op when there's no managed npm.
_nb_configure_npm_prefix() {
[ -x "$HERMES_HOME/node/bin/npm" ] || return 0
local _link_dir
_link_dir="$(_nb_get_link_dir)"
mkdir -p "$HERMES_HOME/node/etc"
printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"
}
_nb_node_major() {
local v
v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)
@@ -206,6 +219,9 @@ _nb_install_bundled_node() {
ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node"
ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx"
_nb_configure_npm_prefix
export PATH="$HERMES_HOME/node/bin:$PATH"
_nb_have_modern_node || return 1
@@ -220,6 +236,10 @@ _nb_install_bundled_node() {
ensure_node() {
HERMES_NODE_AVAILABLE=false
# Repair pre-existing managed installs where `npm install -g` lands off
# PATH. No-op when there's no managed Node, so it's safe to run first.
_nb_configure_npm_prefix
if _nb_have_modern_node; then
_nb_ok "Node $(node --version) found"
HERMES_NODE_AVAILABLE=true
+3
View File
@@ -45,8 +45,11 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"chaslui@outlook.com": "ChasLui",
"rio.jeong@thebytesize.ai": "rio-jeong",
"yehaotian@xuanshudeMac-mini.local": "ArcanePivot",
"dbeyer7@gmail.com": "benegessarit",
"264773240+MrDiamondBallz@users.noreply.github.com": "MrDiamondBallz",
"kenmege@yahoo.com": "Kenmege",
"tianying.x@eukarya.io": "xtymac",
"dkobi16@gmail.com": "Diyoncrz18",
+30 -46
View File
@@ -118,17 +118,9 @@ class TestBuildChildProgressCallback:
assert "💭" in output
assert "search for papers" in output
def test_gateway_progress_streams_per_tool(self):
"""Gateway path: each tool.started relays a subagent.tool event AND a
live subagent.progress summary, so a subagent window streams in step
with the child's work instead of only flushing at end-of-run.
Invariant under test: every tool start produces a paired
(subagent.tool, subagent.progress) relay — no batching delay. This is
the regression guard for "subagent output just appears all at once",
which happened when the progress summary was buffered 5-deep and short
subagents (0-4 tools) never reached the flush threshold mid-run.
"""
def test_gateway_batched_progress(self):
"""Gateway path: each tool.started relays a subagent.tool event, and a
subagent.progress summary fires once BATCH_SIZE tools accumulate."""
parent = MagicMock()
parent._delegate_spinner = None
parent_cb = MagicMock()
@@ -136,19 +128,21 @@ class TestBuildChildProgressCallback:
cb = _build_child_progress_callback(0, "test goal", parent)
# Each tool.started relays subagent.tool immediately, then a live
# subagent.progress summary right behind it (no accumulation delay).
for i in range(3):
# Each tool.started relays a subagent.tool event immediately (per-tool relay).
for i in range(4):
cb("tool.started", f"tool_{i}", f"arg_{i}", {})
# 4 per-tool relays so far, no batch summary yet (BATCH_SIZE=5)
events = [c.args[0] for c in parent_cb.call_args_list]
# Live streaming: tool/progress pairs in order, one pair per tool.
assert events == ["subagent.tool", "subagent.progress"] * 3
assert events == ["subagent.tool"] * 4
# The most recent progress summary reflects the tool that just ran.
# 5th call triggers another per-tool relay PLUS the batch-size summary
cb("tool.started", "tool_4", "arg_4", {})
events = [c.args[0] for c in parent_cb.call_args_list]
assert events == ["subagent.tool"] * 5 + ["subagent.progress"]
summary_call = parent_cb.call_args_list[-1]
summary_text = summary_call.kwargs.get("preview") or summary_call.args[2]
assert "tool_2" in summary_text
assert "tool_0" in summary_text
assert "tool_4" in summary_text
def test_thinking_relayed_to_gateway(self):
"""Thinking events are relayed as subagent.thinking events."""
@@ -165,8 +159,7 @@ class TestBuildChildProgressCallback:
assert parent_cb.call_args.args[2] == "some reasoning text"
def test_parallel_callbacks_independent(self):
"""Each child's callback streams its own tool/progress relays
independently — no cross-child interleaving in the per-child batch."""
"""Each child's callback batches tool names independently."""
parent = MagicMock()
parent._delegate_spinner = None
parent_cb = MagicMock()
@@ -175,23 +168,15 @@ class TestBuildChildProgressCallback:
cb0 = _build_child_progress_callback(0, "goal a", parent)
cb1 = _build_child_progress_callback(1, "goal b", parent)
# 3 tool.started per child. Each tool relays a subagent.tool plus a
# live subagent.progress summary (batch size = 1, counted per-child).
# 3 tool.started per child = 6 per-tool relays; neither should hit
# the batch-size summary (batch size = 5, counted per-child).
for i in range(3):
cb0("tool.started", f"tool_{i}", f"a_{i}", {})
cb1("tool.started", f"other_{i}", f"b_{i}", {})
events = [c.args[0] for c in parent_cb.call_args_list]
assert events.count("subagent.tool") == 6
# Each child's progress summary stays scoped to its own tools — child 0
# never names child 1's tools and vice versa.
progress_calls = [
(c.kwargs.get("preview") or c.args[2])
for c in parent_cb.call_args_list
if c.args[0] == "subagent.progress"
]
assert progress_calls, "expected live progress summaries to stream"
assert all("other_" not in p for p in progress_calls if "tool_" in p)
assert "subagent.progress" not in events
def test_task_index_prefix_in_batch_mode(self):
"""Batch mode (task_count > 1) should show 1-indexed prefix for all tasks."""
@@ -343,14 +328,9 @@ class TestThinkingCallback:
class TestBatchFlush:
"""Tests for gateway batch flush on subagent completion."""
def test_progress_flushes_live_per_tool(self):
"""With per-tool streaming, each tool.started immediately emits its own
subagent.progress summary — nothing is left buffered for end-of-run.
This is the inverse of the old buffer-until-flush behavior: a live
subagent window must see progress as each tool runs, not in one burst
when the child finishes.
"""
def test_flush_sends_remaining_batch(self):
"""_flush should send a final subagent.progress summary of any unsent
tool names in the batch (less than BATCH_SIZE)."""
parent = MagicMock()
parent._delegate_spinner = None
parent_cb = MagicMock()
@@ -358,18 +338,22 @@ class TestBatchFlush:
cb = _build_child_progress_callback(0, "test goal", parent)
# Each tool relays a paired subagent.tool + subagent.progress, live.
# Send 3 tools (below batch size of 5) — each relays subagent.tool
cb("tool.started", "web_search", "query1", {})
cb("tool.started", "read_file", "file.txt", {})
cb("tool.started", "write_file", "out.txt", {})
events = [c.args[0] for c in parent_cb.call_args_list]
assert events == ["subagent.tool", "subagent.progress"] * 3
assert events == ["subagent.tool"] * 3 # per-tool relays so far
assert "subagent.progress" not in events # no batch-size summary yet
# Nothing left to flush — the batch drained on each tool, so _flush is
# a no-op (no trailing end-of-run summary).
before = len(parent_cb.call_args_list)
# Flush should send the remaining 3 as a summary
cb._flush()
assert len(parent_cb.call_args_list) == before
events = [c.args[0] for c in parent_cb.call_args_list]
assert events[-1] == "subagent.progress"
summary_call = parent_cb.call_args_list[-1]
summary_text = summary_call.kwargs.get("preview") or summary_call.args[2]
assert "web_search" in summary_text
assert "write_file" in summary_text
def test_flush_noop_when_batch_empty(self):
"""_flush should not send anything when batch is empty."""
+5 -5
View File
@@ -1498,7 +1498,7 @@ class TestAgentConfigSignatureUserId:
from gateway.run import GatewayRunner
runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"}
sig_a = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400"
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321"
)
sig_b = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="491827364"
@@ -1509,10 +1509,10 @@ class TestAgentConfigSignatureUserId:
from gateway.run import GatewayRunner
runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"}
sig_1 = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400"
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321"
)
sig_2 = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400"
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321"
)
assert sig_1 == sig_2
@@ -1521,11 +1521,11 @@ class TestAgentConfigSignatureUserId:
runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"}
sig_a = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "",
user_id="86701400", user_id_alt="@igor_tg",
user_id="7654321", user_id_alt="@igor_tg",
)
sig_b = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "",
user_id="86701400", user_id_alt="@erosika_tg",
user_id="7654321", user_id_alt="@erosika_tg",
)
assert sig_a != sig_b
+2 -2
View File
@@ -832,7 +832,7 @@ class TestLoadGatewayConfig:
assert config.platforms[Platform.TELEGRAM].extra["rich_messages"] is False
def test_load_config_default_disables_telegram_rich_messages(self, tmp_path, monkeypatch):
def test_load_config_default_enables_telegram_rich_messages(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
@@ -842,7 +842,7 @@ class TestLoadGatewayConfig:
config = load_config()
assert config["telegram"]["extra"]["rich_messages"] is False
assert config["telegram"]["extra"]["rich_messages"] is True
def test_bridges_telegram_extra_base_url_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
@@ -292,6 +292,58 @@ async def test_plugin_command_name_conflict_skipped(adapter):
)
# ------------------------------------------------------------------
# 100-command cap (Discord error 30032 guard)
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_slash_command_registration_stays_under_discord_limit(adapter):
"""Registering far more commands than Discord allows must NOT push the
tree over the 100-command hard cap.
Discord rejects the ENTIRE command sync with error 30032 once the
desired set exceeds 100 global application commands, silently breaking
every slash command. The adapter must bound the desired set instead.
Regression guard for samuraiheart's recurring
"Maximum number of application commands reached (100)" sync failures.
"""
from plugins.platforms.discord.adapter import _DISCORD_MAX_APP_COMMANDS
adapter._run_simple_slash = AsyncMock()
# 200 plugin commands — way past Discord's limit on their own.
many_plugins = {
f"plug{i:03d}": {
"handler": lambda _a: "ok",
"description": f"Plugin command {i}",
"args_hint": "",
"plugin": "stress-plugin",
}
for i in range(200)
}
with patch("hermes_cli.plugins.get_plugin_commands", return_value=many_plugins):
adapter._register_slash_commands()
tree_names = set(adapter._client.tree.commands.keys())
# Contract: never exceed Discord's hard cap.
assert len(tree_names) <= _DISCORD_MAX_APP_COMMANDS, (
f"registered {len(tree_names)} commands — exceeds Discord's "
f"{_DISCORD_MAX_APP_COMMANDS} limit and would fail sync with 30032"
)
# Native, high-priority commands are registered first and must survive
# the cap — they are the core UX, not droppable overflow.
for native in ("status", "stop", "new", "model", "help"):
assert native in tree_names, f"/{native} (native) was dropped by the cap"
# The cap must actually have dropped overflow — not every plugin fit.
registered_plugins = [n for n in tree_names if n.startswith("plug")]
assert len(registered_plugins) < 200, "cap did not drop any overflow commands"
# ------------------------------------------------------------------
# _handle_thread_create_slash — success, session dispatch, failure
# ------------------------------------------------------------------
+60
View File
@@ -450,3 +450,63 @@ class TestCleanupProgress:
}
}
assert resolve_display_setting(config, "telegram", "cleanup_progress") is True, val
class TestToolProgressGrouping:
"""resolve_display_setting() for the tool_progress_grouping knob."""
def test_default_is_accumulate(self):
"""No config anywhere → global default 'accumulate'."""
from gateway.display_config import resolve_display_setting
assert (
resolve_display_setting({}, "telegram", "tool_progress_grouping")
== "accumulate"
)
def test_global_separate(self):
from gateway.display_config import resolve_display_setting
config = {"display": {"tool_progress_grouping": "separate"}}
assert (
resolve_display_setting(config, "discord", "tool_progress_grouping")
== "separate"
)
def test_platform_override_wins(self):
from gateway.display_config import resolve_display_setting
config = {
"display": {
"tool_progress_grouping": "accumulate",
"platforms": {"discord": {"tool_progress_grouping": "separate"}},
}
}
assert (
resolve_display_setting(config, "discord", "tool_progress_grouping")
== "separate"
)
# Other platforms still get the global value.
assert (
resolve_display_setting(config, "telegram", "tool_progress_grouping")
== "accumulate"
)
def test_invalid_value_falls_back_to_accumulate(self):
"""_normalise rejects anything outside accumulate|separate."""
from gateway.display_config import resolve_display_setting
config = {"display": {"tool_progress_grouping": "bogus"}}
assert (
resolve_display_setting(config, "telegram", "tool_progress_grouping")
== "accumulate"
)
def test_case_insensitive(self):
from gateway.display_config import resolve_display_setting
config = {"display": {"tool_progress_grouping": "SEPARATE"}}
assert (
resolve_display_setting(config, "telegram", "tool_progress_grouping")
== "separate"
)
+172
View File
@@ -6,6 +6,30 @@ import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from gateway.config import Platform, PlatformConfig
from gateway.run import _resolve_progress_thread_id
class TestMattermostProgressThreadRouting:
def test_top_level_mattermost_progress_uses_event_message_id(self):
assert _resolve_progress_thread_id(
Platform.MATTERMOST,
source_thread_id=None,
event_message_id="top_post_123",
) == "top_post_123"
def test_threaded_mattermost_progress_prefers_existing_thread_root(self):
assert _resolve_progress_thread_id(
Platform.MATTERMOST,
source_thread_id="root_post_123",
event_message_id="reply_post_456",
) == "root_post_123"
def test_telegram_progress_does_not_use_message_id_as_thread_id(self):
assert _resolve_progress_thread_id(
Platform.TELEGRAM,
source_thread_id=None,
event_message_id="12345",
) is None
# ---------------------------------------------------------------------------
@@ -237,6 +261,92 @@ class TestMattermostSend:
payload = self.adapter._session.post.call_args[1]["json"]
assert "root_id" not in payload
@pytest.mark.asyncio
async def test_send_uses_metadata_thread_id_for_progress_messages(self):
"""Progress/status messages pass Mattermost thread context via metadata."""
self.adapter._reply_mode = "thread"
self.adapter._api_get = AsyncMock(return_value={"id": "root_post_123", "root_id": ""})
self.adapter._api_post = AsyncMock(return_value={"id": "progress_post"})
result = await self.adapter.send(
"channel_1",
"⚡ terminal...",
metadata={"thread_id": "root_post_123"},
)
assert result.success is True
payload = self.adapter._api_post.call_args_list[0][0][1]
assert payload["root_id"] == "root_post_123"
@pytest.mark.asyncio
async def test_progress_send_with_invalid_thread_root_never_falls_back_flat(self):
"""Tool/status/progress bubbles must stay quiet when the thread is broken."""
self.adapter._reply_mode = "thread"
self.adapter._api_get = AsyncMock(return_value={"id": "bad_root", "root_id": ""})
self.adapter._last_post_status = 400
self.adapter._last_post_error = "api.context.invalid_param.app_error: invalid root_id"
self.adapter._api_post = AsyncMock(return_value={})
result = await self.adapter.send(
"channel_1",
"⚙️ terminal...",
metadata={"thread_id": "bad_root"},
)
assert result.success is False
assert self.adapter._api_post.call_count == 1
payload = self.adapter._api_post.call_args_list[0][0][1]
assert payload["root_id"] == "bad_root"
@pytest.mark.asyncio
async def test_notify_send_with_invalid_thread_root_falls_back_flat_with_warning(self):
"""Notify-worthy replies may fall back flat so the answer is not lost."""
self.adapter._reply_mode = "thread"
self.adapter._api_get = AsyncMock(return_value={"id": "bad_root", "root_id": ""})
self.adapter._last_post_status = 400
self.adapter._last_post_error = "api.context.invalid_param.app_error: invalid root_id"
self.adapter._api_post = AsyncMock(side_effect=[{}, {"id": "flat_final"}])
result = await self.adapter.send(
"channel_1",
"Final answer body",
reply_to="bad_root",
metadata={"notify": True},
)
assert result.success is True
assert result.message_id == "flat_final"
assert self.adapter._api_post.call_count == 2
threaded_payload = self.adapter._api_post.call_args_list[0][0][1]
flat_payload = self.adapter._api_post.call_args_list[1][0][1]
assert threaded_payload["root_id"] == "bad_root"
assert "root_id" not in flat_payload
assert flat_payload["channel_id"] == "channel_1"
assert "Mattermost thread delivery failed" in flat_payload["message"]
assert "Final answer body" in flat_payload["message"]
@pytest.mark.asyncio
async def test_notify_send_with_server_error_does_not_fall_back_flat(self):
"""Notify fallback is only for broken thread roots, not generic API failures."""
self.adapter._reply_mode = "thread"
self.adapter._api_get = AsyncMock(return_value={"id": "root_post", "root_id": ""})
self.adapter._last_post_status = 500
self.adapter._last_post_error = "Internal Server Error"
self.adapter._api_post = AsyncMock(return_value={})
result = await self.adapter.send(
"channel_1",
"Final answer body",
reply_to="root_post",
metadata={"notify": True},
)
assert result.success is False
assert self.adapter._api_post.call_count == 1
payload = self.adapter._api_post.call_args_list[0][0][1]
assert payload["root_id"] == "root_post"
@pytest.mark.asyncio
async def test_send_api_failure(self):
"""When API returns error, send should return failure."""
@@ -750,3 +860,65 @@ class TestMattermostMediaTypes:
assert msg.media_types == ["application/pdf"]
assert not msg.media_types[0].startswith("image/")
assert not msg.media_types[0].startswith("audio/")
@pytest.mark.asyncio
async def test_mattermost_top_level_channel_post_is_thread_root():
adapter = _make_adapter()
adapter._reply_mode = "thread"
adapter._bot_user_id = "bot_user_id"
adapter._bot_username = "hermes-bot"
adapter.handle_message = AsyncMock()
post_data = {
"id": "top_post_123",
"user_id": "user_123",
"channel_id": "chan_456",
"message": "@hermes-bot start work",
"root_id": "",
}
event = {
"event": "posted",
"data": {
"post": json.dumps(post_data),
"channel_type": "O",
"sender_name": "@alice",
},
}
await adapter._handle_ws_event(event)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.source.thread_id == "top_post_123"
assert msg_event.source.message_id == "top_post_123"
assert msg_event.message_id == "top_post_123"
@pytest.mark.asyncio
async def test_mattermost_dm_post_does_not_seed_thread_root():
adapter = _make_adapter()
adapter._reply_mode = "thread"
adapter._bot_user_id = "bot_user_id"
adapter._bot_username = "hermes-bot"
adapter.handle_message = AsyncMock()
post_data = {
"id": "dm_post_123",
"user_id": "user_123",
"channel_id": "dm_chan",
"message": "hello",
"root_id": "",
}
event = {
"event": "posted",
"data": {
"post": json.dumps(post_data),
"channel_type": "D",
"sender_name": "@alice",
},
}
await adapter._handle_ws_event(event)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.source.thread_id is None
assert msg_event.source.message_id == "dm_post_123"
+37 -1
View File
@@ -211,10 +211,39 @@ class TestTeamsRequirements:
monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True)
assert check_requirements() is True
def test_alias_matches(self, monkeypatch):
def test_check_teams_requirements_shortcircuits_when_present(self, monkeypatch):
# When the SDK + aiohttp are already importable, the active lazy-
# installer returns True immediately without attempting an install.
monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", True)
monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True)
called = {"ensure_and_bind": 0}
def _fake_ensure_and_bind(*_args, **_kwargs):
called["ensure_and_bind"] += 1
return True
monkeypatch.setattr(
"tools.lazy_deps.ensure_and_bind", _fake_ensure_and_bind
)
assert check_teams_requirements() is True
assert called["ensure_and_bind"] == 0
def test_check_teams_requirements_lazy_installs_when_missing(self, monkeypatch):
# When deps are missing, the active installer delegates to
# ensure_and_bind("platform.teams", ...) — parity with Slack/Discord.
monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False)
monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", False)
seen = {}
def _fake_ensure_and_bind(feature, importer, target_globals, **kwargs):
seen["feature"] = feature
return True
monkeypatch.setattr(
"tools.lazy_deps.ensure_and_bind", _fake_ensure_and_bind
)
assert check_teams_requirements() is True
assert seen["feature"] == "platform.teams"
def test_validate_config_with_env(self, monkeypatch):
monkeypatch.setenv("TEAMS_CLIENT_ID", "test-id")
@@ -371,6 +400,13 @@ class TestTeamsConnect:
@pytest.mark.anyio
async def test_connect_fails_without_sdk(self, monkeypatch):
monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False)
# Simulate the SDK being unavailable AND not installable (offline /
# locked-down env): the lazy-installer can't rebind the globals, so
# TEAMS_SDK_AVAILABLE stays False and connect() must fail.
monkeypatch.setattr(
"tools.lazy_deps.ensure_and_bind",
lambda *_a, **_k: False,
)
adapter = TeamsAdapter(_make_config(
client_id="id", client_secret="secret", tenant_id="tenant",
))
+184 -3
View File
@@ -61,6 +61,8 @@ def _make_adapter(extra=None):
bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
bot.send_chat_action = AsyncMock() # keeps the post-send typing re-trigger quiet
bot.send_message_draft = AsyncMock(return_value=True) # legacy draft fallback
bot.edit_message_text = AsyncMock(return_value=MagicMock(message_id=1)) # legacy edit path
bot.delete_message = AsyncMock(return_value=True)
adapter._bot = bot
return adapter
@@ -184,7 +186,10 @@ async def test_rich_messages_opt_out_accepts_string_false():
@pytest.mark.asyncio
async def test_rich_messages_default_is_disabled():
async def test_rich_messages_default_is_enabled():
"""Rich messages are on by default (Bot API 10.1); rich-eligible content
(tables/task lists/details/math) goes through sendRichMessage without the
user having to opt in."""
config = PlatformConfig(enabled=True, token="fake-token")
adapter = TelegramAdapter(config)
bot = MagicMock()
@@ -195,6 +200,42 @@ async def test_rich_messages_default_is_disabled():
result = await adapter.send("12345", RICH_CONTENT)
assert result.success is True
bot = adapter._bot
assert bot is not None
bot.do_api_request.assert_awaited_once()
bot.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_rich_messages_can_be_opted_out():
"""Setting platforms.telegram.extra.rich_messages: false keeps every reply
on the legacy MarkdownV2 path even for rich-eligible content."""
config = PlatformConfig(
enabled=True, token="fake-token", extra={"rich_messages": False}
)
adapter = TelegramAdapter(config)
bot = MagicMock()
bot.do_api_request = AsyncMock(return_value=SimpleNamespace(message_id=123))
bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
bot.send_chat_action = AsyncMock()
adapter._bot = bot
result = await adapter.send("12345", RICH_CONTENT)
assert result.success is True
bot.do_api_request.assert_not_called()
bot.send_message.assert_awaited()
@pytest.mark.asyncio
async def test_plain_markdown_stays_on_legacy_path():
"""Ordinary replies (no table/task-list/details/math) stay on the legacy
MarkdownV2 path for consistent client rendering, even with rich enabled."""
adapter = _make_adapter()
result = await adapter.send("12345", "Hello **there**\n\nA normal reply.")
assert result.success is True
bot = adapter._bot
assert bot is not None
@@ -240,7 +281,9 @@ async def test_oversized_content_skips_rich_and_chunks():
async def test_rich_limit_is_characters_not_bytes():
"""Telegram's rich limit is UTF-8 characters, not encoded bytes."""
adapter = _make_adapter()
cjk = "测" * 20000 # 20k chars, 60k UTF-8 bytes
# Rich-eligible (table) so the content takes the rich path; the CJK body
# is 20k chars / 60k UTF-8 bytes — over the byte count, under the char cap.
cjk = "| a | b |\n|---|---|\n" + "测" * 20000 # 20k chars, ~60k UTF-8 bytes
assert len(cjk.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES
assert len(cjk) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS
@@ -324,7 +367,9 @@ async def test_real_ptb_endpoint_missing_falls_back_and_latches_off(exc):
async def test_rich_payload_preserves_link_preview_disable():
adapter = _make_adapter(extra={"disable_link_previews": True})
result = await adapter.send("12345", "See https://example.com")
result = await adapter.send(
"12345", "| Link | Note |\n|---|---|\n| See https://example.com | x |"
)
assert result.success is True
api_kwargs = _rich_api_kwargs(adapter)
@@ -575,3 +620,139 @@ async def test_rich_draft_opt_out_uses_legacy():
assert bot is not None
bot.do_api_request.assert_not_called()
bot.send_message_draft.assert_awaited_once()
# ----------------------------------------------------------------------------
# Rich finalize via editMessageText (Bot API 10.1 rich_message edit param).
# Streamed previews finalize by editing the existing message IN PLACE as rich,
# so tables/task lists survive without a fresh send + delete (no duplicate).
# ----------------------------------------------------------------------------
def _rich_edit_kwargs(adapter):
"""Return the api_kwargs dict from the single editMessageText rich call."""
call = adapter._bot.do_api_request.call_args
assert call.args[0] == "editMessageText"
return call.kwargs["api_kwargs"]
@pytest.mark.asyncio
async def test_finalize_edit_uses_rich_for_table_content():
"""Finalizing a streamed preview whose content is a table edits the
existing message IN PLACE via editMessageText's rich_message param —
no fresh send, no delete, no duplicate."""
adapter = _make_adapter()
result = await adapter.edit_message(
"12345", "555", RICH_CONTENT, finalize=True,
)
assert result.success is True
assert result.message_id == "555" # same message, edited in place
api_kwargs = _rich_edit_kwargs(adapter)
assert api_kwargs["message_id"] == 555
# RAW markdown is passed through so table pipes survive.
assert api_kwargs["rich_message"]["markdown"] == RICH_CONTENT
# No fresh send / delete — the whole point of the in-place rich edit.
adapter._bot.edit_message_text.assert_not_called()
adapter._bot.delete_message.assert_not_called()
@pytest.mark.asyncio
async def test_finalize_edit_plain_content_stays_legacy():
"""Finalizing plain content (no table/task-list/details/math) uses the
legacy MarkdownV2 edit_message_text path, not the rich edit endpoint."""
adapter = _make_adapter()
result = await adapter.edit_message(
"12345", "555", "Just a normal answer, no rich constructs.", finalize=True,
)
assert result.success is True
adapter._bot.do_api_request.assert_not_called()
adapter._bot.edit_message_text.assert_awaited()
@pytest.mark.asyncio
async def test_finalize_edit_rich_capability_error_falls_back_to_legacy():
"""A capability error on the rich edit latches rich off and falls back to
the legacy MarkdownV2 edit so the user still gets the final answer."""
adapter = _make_adapter()
adapter._bot.do_api_request = AsyncMock(side_effect=PTB_ENDPOINT_NOT_FOUND)
result = await adapter.edit_message(
"12345", "555", RICH_CONTENT, finalize=True,
)
assert result.success is True
assert adapter._rich_send_disabled is True
adapter._bot.edit_message_text.assert_awaited()
@pytest.mark.asyncio
async def test_finalize_edit_rich_not_modified_is_success_noop():
"""'Message is not modified' on a rich edit is a no-op success — must NOT
fall through to a redundant legacy edit."""
adapter = _make_adapter()
adapter._bot.do_api_request = AsyncMock(
side_effect=BadRequest("Message is not modified")
)
result = await adapter.edit_message(
"12345", "555", RICH_CONTENT, finalize=True,
)
assert result.success is True
adapter._bot.edit_message_text.assert_not_called()
@pytest.mark.asyncio
async def test_non_finalize_edit_never_uses_rich():
"""Intermediate (non-finalize) stream edits stay on the plain edit path;
rich is only applied on the final edit."""
adapter = _make_adapter()
result = await adapter.edit_message(
"12345", "555", RICH_CONTENT, finalize=False,
)
assert result.success is True
adapter._bot.do_api_request.assert_not_called()
adapter._bot.edit_message_text.assert_awaited()
@pytest.mark.asyncio
async def test_finalize_edit_opt_out_uses_legacy():
"""With rich_messages: false, even a table finalizes via the legacy
MarkdownV2 edit path."""
adapter = _make_adapter(extra={"rich_messages": False})
result = await adapter.edit_message(
"12345", "555", RICH_CONTENT, finalize=True,
)
assert result.success is True
adapter._bot.do_api_request.assert_not_called()
adapter._bot.edit_message_text.assert_awaited()
@pytest.mark.asyncio
async def test_finalize_edit_rich_over_markdownv2_limit_not_split():
"""A rich table that exceeds the 4,096 MarkdownV2 limit but fits the 32,768
rich cap is edited in place as one rich message, NOT split into legacy
chunks."""
adapter = _make_adapter()
big_table = "| a | b |\n|---|---|\n" + "\n".join(
f"| {'x' * 50} | {'y' * 50} |" for _ in range(40)
)
assert len(big_table) > TelegramAdapter.MAX_MESSAGE_LENGTH
assert len(big_table) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS
result = await adapter.edit_message(
"12345", "555", big_table, finalize=True,
)
assert result.success is True
api_kwargs = _rich_edit_kwargs(adapter)
assert api_kwargs["rich_message"]["markdown"] == big_table
adapter._bot.edit_message_text.assert_not_called()
+70
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import base64
import json
import time
from datetime import datetime, timezone
from unittest.mock import patch
@@ -25,6 +26,37 @@ def _jwt_with_email(email: str) -> str:
return f"{header}.{payload}.signature"
def _codex_pool_only_store(*, exhausted: bool = False) -> dict:
entry = {
"id": "codex-1",
"label": "codex@example.com",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": _jwt_with_email("codex@example.com"),
"refresh_token": "refresh-token",
"base_url": "https://chatgpt.com/backend-api/codex",
"last_refresh": "2026-06-15T10:00:00Z",
}
if exhausted:
entry.update(
{
"last_status": "exhausted",
"last_status_at": time.time(),
"last_error_code": 429,
"last_error_reason": "usage_limit_reached",
"last_error_message": "The usage limit has been reached",
"last_error_reset_at": time.time() + 3600,
}
)
return {
"version": 1,
"active_provider": "openai-codex",
"providers": {},
"credential_pool": {"openai-codex": [entry]},
}
@pytest.fixture(autouse=True)
def _clear_provider_env(monkeypatch):
for key in (
@@ -483,6 +515,44 @@ def test_auth_add_codex_oauth_keeps_distinct_pool_accounts(tmp_path, monkeypatch
assert payload["active_provider"] == "openai-codex"
def test_codex_auth_status_reports_pool_only_credential(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, _codex_pool_only_store())
from hermes_cli.auth import get_codex_auth_status
status = get_codex_auth_status()
assert status["logged_in"] is True
assert status["source"] == "pool:codex@example.com"
def test_codex_auth_status_reports_pool_only_rate_limit(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, _codex_pool_only_store(exhausted=True))
from hermes_cli.auth import get_codex_auth_status
status = get_codex_auth_status()
assert status["logged_in"] is True
assert status["rate_limited"] is True
assert status["error_code"] == "codex_rate_limited"
def test_codex_runtime_pool_only_rate_limit_is_not_missing_auth(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, _codex_pool_only_store(exhausted=True))
from hermes_cli.auth import AuthError, CODEX_RATE_LIMITED_CODE, resolve_codex_runtime_credentials
with pytest.raises(AuthError) as exc_info:
resolve_codex_runtime_credentials()
assert exc_info.value.code == CODEX_RATE_LIMITED_CODE
assert exc_info.value.relogin_required is False
def test_auth_add_xai_oauth_sets_active_provider(tmp_path, monkeypatch):
"""hermes auth add xai-oauth must write providers singleton and set active_provider.
@@ -772,6 +772,25 @@ class TestUpdateCheckEndpoint:
assert body["message"]
assert body["behind"] is None
def test_managed_runtime_dashboard_is_not_applyable(self, monkeypatch):
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "_dashboard_local_update_managed_externally", lambda: True)
monkeypatch.setattr(
ws,
"detect_install_method",
lambda *a, **k: pytest.fail(
"managed runtime update check should not probe install method"
),
)
body = self.client.get("/api/hermes/update/check").json()
assert body["install_method"] == "managed-runtime"
assert body["can_apply"] is False
assert body["update_available"] is False
assert body["behind"] is None
assert "managed outside this dashboard" in body["message"]
def test_check_failure_is_soft(self, monkeypatch):
import hermes_cli.web_server as ws
import hermes_cli.banner as banner
+2 -1
View File
@@ -493,6 +493,7 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon
("opencode-zen", "anthropic/claude-sonnet-4.6"),
("kilocode", "anthropic/claude-sonnet-4.6"),
("kimi-coding", "kimi-k2"),
("nvidia", "qwen/qwen3.5-122b-a10b"),
],
)
def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
@@ -533,7 +534,7 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
out = buf.getvalue()
assert f"model.provider '{provider}' is not a recognised provider" not in out
assert f"model.provider '{provider}' is unknown" not in out
if provider in {"opencode-zen", "kilocode"}:
if provider in {"opencode-zen", "kilocode", "nvidia"}:
assert (
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
not in out
@@ -0,0 +1,80 @@
"""`hermes debug` must report the EFFECTIVE terminal backend.
``terminal.backend`` in config.yaml is bridged to the ``TERMINAL_ENV`` env var,
but a ``TERMINAL_ENV`` set in .env / the shell overrides config and is what
``terminal_tool`` actually uses. The dump used to print only the config value,
which hid the override and made users believe the agent was running ``local``
while it was really jailed in a docker/podman sandbox (and vice-versa).
"""
from pathlib import Path
from types import SimpleNamespace
def _terminal_line(out: str) -> str:
for line in out.splitlines():
if line.startswith("terminal:"):
return line
raise AssertionError(f"no 'terminal:' line in dump output:\n{out}")
def _seed(home: Path, *, config_yaml: str, env_text: str) -> None:
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(config_yaml)
(home / ".env").write_text(env_text)
def test_dump_surfaces_terminal_env_override(monkeypatch, capsys, tmp_path):
from hermes_cli import dump
from hermes_cli.config import get_hermes_home
monkeypatch.delenv("TERMINAL_ENV", raising=False)
# Keep run_dump's project-.env fallback from touching the real repo.
monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject")
home = get_hermes_home()
_seed(home, config_yaml="terminal:\n backend: local\n", env_text="TERMINAL_ENV=docker\n")
dump.run_dump(SimpleNamespace(show_keys=False))
line = _terminal_line(capsys.readouterr().out)
# Effective backend (docker) is what actually runs, not the config 'local'.
assert "docker" in line
assert "overrides config.yaml" in line
# The shadowed config value is still shown so the mismatch is obvious.
assert "terminal.backend=local" in line
def test_dump_reports_config_backend_when_no_override(monkeypatch, capsys, tmp_path):
from hermes_cli import dump
from hermes_cli.config import get_hermes_home
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject")
home = get_hermes_home()
_seed(home, config_yaml="terminal:\n backend: docker\n", env_text="")
dump.run_dump(SimpleNamespace(show_keys=False))
line = _terminal_line(capsys.readouterr().out)
assert "docker" in line
assert "overrides" not in line
def test_dump_no_override_when_env_matches_config(monkeypatch, capsys, tmp_path):
from hermes_cli import dump
from hermes_cli.config import get_hermes_home
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject")
home = get_hermes_home()
# TERMINAL_ENV agrees with config — no spurious "override" note.
_seed(home, config_yaml="terminal:\n backend: docker\n", env_text="TERMINAL_ENV=docker\n")
dump.run_dump(SimpleNamespace(show_keys=False))
line = _terminal_line(capsys.readouterr().out)
assert "docker" in line
assert "overrides" not in line
+124
View File
@@ -482,3 +482,127 @@ def test_payload_shape_compatible_with_modelpickerdialog_frontend():
for row in payload["providers"]:
missing = required_keys - row.keys()
assert not missing, f"row {row['slug']} missing keys: {missing}"
# ─── Aggregator dedup (issue #45954) ───────────────────────────────────
def _user_provider_row(slug: str, models: list[str]) -> dict:
return {
"slug": slug,
"name": slug.title(),
"models": models,
"total_models": len(models),
"is_current": False,
"is_user_defined": True,
"source": "user-config",
}
def _aggregator_row(slug: str, models: list[str]) -> dict:
return {
"slug": slug,
"name": slug.title(),
"models": models,
"total_models": len(models),
"is_current": False,
"is_user_defined": False,
"source": "built-in",
}
def test_aggregator_dedup_removes_overlapping_models():
"""Models served by a user-defined provider are removed from
aggregator rows so the picker doesn't show them under the wrong
provider. (#45954)"""
rows = [
_user_provider_row("litellm-proxy", [
"nvidia/nim/minimax-m3",
"nvidia/nim/kimi-k2.6",
]),
_aggregator_row("openrouter", [
"minimax/minimax-m3",
"nvidia/nim/minimax-m3", # overlaps with litellm-proxy
"anthropic/claude-sonnet-4.6",
]),
]
ctx = _empty_ctx()
with _list_auth_returning(rows):
payload = build_models_payload(ctx)
or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter")
proxy_row = next(r for r in payload["providers"] if r["slug"] == "litellm-proxy")
# User-defined provider keeps all its models
assert proxy_row["models"] == ["nvidia/nim/minimax-m3", "nvidia/nim/kimi-k2.6"]
# Aggregator lost the overlapping model but kept the rest
assert "nvidia/nim/minimax-m3" not in or_row["models"]
assert "minimax/minimax-m3" in or_row["models"]
assert "anthropic/claude-sonnet-4.6" in or_row["models"]
assert or_row["total_models"] == 2
def test_aggregator_dedup_case_insensitive():
"""Dedup uses case-insensitive matching. (#45954)"""
rows = [
_user_provider_row("my-proxy", ["NVIDIA/NIM/MiniMax-M3"]),
_aggregator_row("openrouter", ["nvidia/nim/minimax-m3", "other/model"]),
]
ctx = _empty_ctx()
with _list_auth_returning(rows):
payload = build_models_payload(ctx)
or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter")
assert "nvidia/nim/minimax-m3" not in or_row["models"]
assert or_row["total_models"] == 1
def test_aggregator_dedup_no_overlap_unchanged():
"""When there's no overlap, aggregator models are untouched. (#45954)"""
rows = [
_user_provider_row("litellm-proxy", ["custom/model-a"]),
_aggregator_row("openrouter", ["anthropic/claude-sonnet-4.6"]),
]
ctx = _empty_ctx()
with _list_auth_returning(rows):
payload = build_models_payload(ctx)
or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter")
assert or_row["models"] == ["anthropic/claude-sonnet-4.6"]
assert or_row["total_models"] == 1
def test_aggregator_dedup_no_user_providers_unchanged():
"""When there are no user-defined providers, nothing is filtered.
(#45954)"""
rows = [
_aggregator_row("openrouter", [
"nvidia/nim/minimax-m3",
"anthropic/claude-sonnet-4.6",
]),
]
ctx = _empty_ctx()
with _list_auth_returning(rows):
payload = build_models_payload(ctx)
or_row = payload["providers"][0]
assert len(or_row["models"]) == 2
def test_aggregator_dedup_multiple_user_providers():
"""Models from all user-defined providers are excluded from aggregators.
(#45954)"""
rows = [
_user_provider_row("proxy-a", ["model-x"]),
_user_provider_row("proxy-b", ["model-y"]),
_aggregator_row("openrouter", ["model-x", "model-y", "model-z"]),
]
ctx = _empty_ctx()
with _list_auth_returning(rows):
payload = build_models_payload(ctx)
or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter")
assert or_row["models"] == ["model-z"]
assert or_row["total_models"] == 1
@@ -3,8 +3,9 @@
The cache avoids re-validating Nous credentials on every menu paint —
`hermes tools` → "All Platforms" used to fire ~31 OAuth refresh POSTs
against portal.nousresearch.com during one render. The cache is keyed
on auth.json mtime so login/logout flows invalidate naturally; tests
and other writers can also call invalidate_nous_auth_status_cache().
on auth.json path + mtime so profile switches stay isolated while
login/logout flows invalidate naturally; tests and other writers can
also call invalidate_nous_auth_status_cache().
"""
from __future__ import annotations
@@ -88,6 +89,42 @@ def test_get_nous_auth_status_invalidates_on_auth_file_mtime(tmp_path, monkeypat
auth_mod.invalidate_nous_auth_status_cache()
def test_get_nous_auth_status_cache_is_scoped_by_auth_file_path(tmp_path, monkeypatch):
"""Two profile homes with missing auth.json must not share cached status."""
profile_a = tmp_path / "profiles" / "a"
profile_b = tmp_path / "profiles" / "b"
profile_a.mkdir(parents=True)
profile_b.mkdir(parents=True)
from hermes_cli import auth as auth_mod
auth_mod.invalidate_nous_auth_status_cache()
call_count = {"n": 0}
seen_auth_files = []
def fake_compute():
call_count["n"] += 1
seen_auth_files.append(auth_mod._auth_file_path())
return {"logged_in": False, "call": call_count["n"]}
with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute):
monkeypatch.setenv("HERMES_HOME", str(profile_a))
first = auth_mod.get_nous_auth_status()
monkeypatch.setenv("HERMES_HOME", str(profile_b))
second = auth_mod.get_nous_auth_status()
assert call_count["n"] == 2
assert first["call"] == 1
assert second["call"] == 2
assert seen_auth_files == [
profile_a / "auth.json",
profile_b / "auth.json",
]
auth_mod.invalidate_nous_auth_status_cache()
def test_invalidate_nous_auth_status_cache_forces_recompute(tmp_path, monkeypatch):
"""Explicit invalidate forces the next call to re-compute."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+133
View File
@@ -34,6 +34,13 @@ client = TestClient(app)
HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
def _make_profile_home(tmp_path, monkeypatch, profile="coder"):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
profile_home = tmp_path / "profiles" / profile
profile_home.mkdir(parents=True)
return profile_home
def _fake_nous_device_data():
return {
"device_code": "device-code",
@@ -127,6 +134,67 @@ def test_nous_dashboard_device_flow_ignores_legacy_scope_override(monkeypatch):
ws._oauth_sessions.pop(result["session_id"], None)
def test_oauth_provider_status_uses_profile_query(tmp_path, monkeypatch):
from hermes_cli import web_server as ws
from hermes_constants import get_hermes_home
profile_home = _make_profile_home(tmp_path, monkeypatch)
observed_homes = []
def fake_status():
observed_homes.append(get_hermes_home())
return {"logged_in": False, "source": None}
fake_catalog = ({
"id": "fake-oauth",
"name": "Fake OAuth",
"flow": "pkce",
"cli_command": "hermes auth add fake-oauth",
"docs_url": "https://example.com",
"status_fn": fake_status,
},)
monkeypatch.setattr(ws, "_OAUTH_PROVIDER_CATALOG", fake_catalog)
resp = client.get("/api/providers/oauth?profile=coder", headers=HEADERS)
assert resp.status_code == 200, resp.text
assert observed_homes == [profile_home]
def test_oauth_start_stores_profile_for_background_completion(tmp_path, monkeypatch):
from hermes_cli import web_server as ws
_make_profile_home(tmp_path, monkeypatch)
fake_user_code_resp = {
"user_code": "ABCD-1234",
"verification_uri": "https://api.minimax.io/oauth/verify",
"expired_in": 600,
"interval": 2000,
"state": "stub-state",
}
with patch(
"hermes_cli.auth._minimax_request_user_code",
return_value=fake_user_code_resp,
), patch(
"hermes_cli.auth._minimax_pkce_pair",
return_value=("verifier-stub", "challenge-stub", "stub-state"),
), patch(
"hermes_cli.web_server._minimax_poller",
return_value=None,
):
resp = client.post(
"/api/providers/oauth/minimax-oauth/start?profile=coder",
headers=HEADERS,
)
assert resp.status_code == 200, resp.text
session_id = resp.json()["session_id"]
try:
assert ws._oauth_sessions[session_id]["profile"] == "coder"
finally:
ws._oauth_sessions.pop(session_id, None)
def test_nous_dashboard_device_flow_does_not_retry_legacy_scope_on_invoke_refusal(monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
@@ -207,6 +275,71 @@ def test_codex_dashboard_worker_persists_runtime_provider(tmp_path, monkeypatch)
ws._oauth_sessions.pop(sid, None)
def test_codex_dashboard_worker_persists_inside_session_profile(tmp_path, monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
from hermes_constants import get_hermes_home
profile_home = _make_profile_home(tmp_path, monkeypatch)
class _Resp:
def __init__(self, status_code, payload):
self.status_code = status_code
self._payload = payload
def json(self):
return self._payload
class _Client:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def post(self, url, **kwargs):
if url.endswith("/deviceauth/usercode"):
return _Resp(200, {
"device_auth_id": "device-auth-id",
"interval": 3,
"user_code": "CODEX-1234",
})
if url.endswith("/deviceauth/token"):
return _Resp(200, {
"authorization_code": "authorization-code",
"code_verifier": "code-verifier",
})
return _Resp(200, {
"access_token": "codex-access",
"refresh_token": "codex-refresh",
})
saved_homes = []
monkeypatch.setattr(httpx, "Client", _Client)
monkeypatch.setattr(ws.time, "sleep", lambda _: None)
monkeypatch.setattr(
auth_mod,
"_save_codex_tokens",
lambda tokens: saved_homes.append(get_hermes_home()),
)
sid, _ = ws._new_oauth_session(
"openai-codex",
"device_code",
profile="coder",
)
try:
ws._codex_full_login_worker(sid)
assert ws._oauth_sessions[sid]["status"] == "approved"
assert saved_homes == [profile_home]
finally:
ws._oauth_sessions.pop(sid, None)
def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
+60
View File
@@ -245,6 +245,24 @@ class TestWebServerEndpoints:
assert "version" in data
assert "hermes_home" in data
assert "active_sessions" in data
assert data["can_update_hermes"] is True
def test_get_status_hides_update_capability_in_managed_runtime(self, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: True)
resp = self.client.get("/api/status")
assert resp.status_code == 200
assert resp.json()["can_update_hermes"] is False
def test_dashboard_update_capability_detects_generic_container(self, monkeypatch):
import hermes_constants
import hermes_cli.web_server as web_server
monkeypatch.setattr(hermes_constants, "is_container", lambda: True)
assert web_server._dashboard_local_update_managed_externally() is True
# ── GET /api/media (remote image display) ───────────────────────────
@@ -912,6 +930,48 @@ class TestWebServerEndpoints:
assert status_data["pid"] is None
assert any("docker pull nousresearch/hermes-agent:latest" in line for line in status_data["lines"])
def test_update_hermes_returns_managed_runtime_guidance_without_spawning(self, monkeypatch):
import hermes_cli.web_server as web_server
spawned = False
detected = False
def fail_spawn(*_args, **_kwargs):
nonlocal spawned
spawned = True
raise AssertionError("managed runtime update guard should not spawn hermes update")
def fail_detect(*_args, **_kwargs):
nonlocal detected
detected = True
raise AssertionError("managed runtime update guard should not detect install method")
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: True)
monkeypatch.setattr(web_server, "detect_install_method", fail_detect)
monkeypatch.setattr(web_server, "_spawn_hermes_action", fail_spawn)
web_server._ACTION_PROCS.pop("hermes-update", None)
web_server._ACTION_RESULTS.pop("hermes-update", None)
resp = self.client.post("/api/hermes/update")
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is False
assert data["name"] == "hermes-update"
assert data["pid"] is None
assert data["error"] == "dashboard_update_managed_externally"
assert "managed outside this dashboard" in data["message"]
assert spawned is False
assert detected is False
status = self.client.get("/api/actions/hermes-update/status")
assert status.status_code == 200
status_data = status.json()
assert status_data["running"] is False
assert status_data["exit_code"] == 1
assert status_data["pid"] is None
assert any("managed outside this dashboard" in line for line in status_data["lines"])
def test_update_hermes_spawns_on_non_docker_install(self, monkeypatch):
import hermes_cli.web_server as web_server
+60
View File
@@ -254,6 +254,66 @@ def test_local_mode_upload_read_mkdir_delete_roundtrip(local_files_client):
assert not folder.exists()
def _seed_file(client, root, name="out/hello.txt"):
file_path = root / name
created = client.post(
"/api/files/upload",
json={"path": str(file_path), "data_url": "data:text/plain;base64,aGVsbG8="},
)
assert created.status_code == 200
return file_path
def test_download_returns_file_as_attachment(forced_files_client):
client, root = forced_files_client
file_path = _seed_file(client, root)
resp = client.get("/api/files/download", params={"path": str(file_path)})
assert resp.status_code == 200
assert resp.content == b"hello"
disposition = resp.headers["content-disposition"]
assert "attachment" in disposition
assert "hello.txt" in disposition
def test_download_authenticates_via_query_token(forced_files_client):
client, root = forced_files_client
file_path = _seed_file(client, root)
# Drop the session header so only the ?token= query param authenticates —
# mirrors a browser/shell-opened download that can't set the session header.
del client.headers[web_server._SESSION_HEADER_NAME]
ok = client.get(
"/api/files/download",
params={"path": str(file_path), "token": web_server._SESSION_TOKEN},
)
assert ok.status_code == 200
assert ok.content == b"hello"
assert client.get(
"/api/files/download", params={"path": str(file_path), "token": "nope"}
).status_code == 401
assert client.get(
"/api/files/download", params={"path": str(file_path)}
).status_code == 401
def test_query_token_does_not_authenticate_other_endpoints(forced_files_client):
client, root = forced_files_client
file_path = _seed_file(client, root)
del client.headers[web_server._SESSION_HEADER_NAME]
# The query-token escape hatch is scoped to /api/files/download only; it must
# not unlock the rest of the API surface.
leaked = client.get(
"/api/files/read",
params={"path": str(file_path), "token": web_server._SESSION_TOKEN},
)
assert leaked.status_code == 401
def test_hosted_policy_locks_to_opt_data(monkeypatch):
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
monkeypatch.setenv("HERMES_HOME", "/opt/data")
+153 -78
View File
@@ -239,7 +239,7 @@ class TestCloneHonchoForProfile:
"""Identity-key carryover during profile cloning.
The host-scoped identity-mapping keys (``userPeerAliases``,
``runtimePeerPrefix``, ``pinPeerName``) must survive a clone; otherwise
``runtimePeerPrefix``, ``pinUserPeer``) must survive a clone; otherwise
the new profile silently fragments memory by resolving gateway users to
raw runtime IDs instead of operator-declared peers.
"""
@@ -263,7 +263,7 @@ class TestCloneHonchoForProfile:
"apiKey": "***",
"hosts": {
"hermes": {
"userPeerAliases": {"86701400": "eri", "discord-491827364": "eri"},
"userPeerAliases": {"7654321": "eri", "discord-491827364": "eri"},
"peerName": "eri",
},
},
@@ -272,7 +272,7 @@ class TestCloneHonchoForProfile:
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"}
assert new_block["userPeerAliases"] == {"7654321": "eri", "discord-491827364": "eri"}
def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path):
cfg = {
@@ -290,7 +290,7 @@ class TestCloneHonchoForProfile:
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["runtimePeerPrefix"] == "telegram_"
def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path):
def test_legacy_pin_peer_name_migrates_to_canonical_on_clone(self, monkeypatch, tmp_path):
cfg = {
"apiKey": "***",
"hosts": {
@@ -304,7 +304,8 @@ class TestCloneHonchoForProfile:
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["pinPeerName"] is True
assert new_block["pinUserPeer"] is True
assert "pinPeerName" not in new_block
def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path):
cfg = {
@@ -317,23 +318,25 @@ class TestCloneHonchoForProfile:
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert "userPeerAliases" not in new_block
assert "runtimePeerPrefix" not in new_block
assert "pinUserPeer" not in new_block
assert "pinPeerName" not in new_block
class TestSetupWizardDeploymentShape:
"""The deployment-shape step writes pinPeerName / userPeerAliases /
runtimePeerPrefix based on the operator's chosen shape.
"""The gateway identity-mapping tree writes pinUserPeer / userPeerAliases /
runtimePeerPrefix based on the operator's intent.
Single-operator deployments collapse all platforms to peerName.
Multi-user gateways leave the resolver to route per-runtime.
Hybrid deployments alias the operator's own runtime IDs only.
Choice [1] (just me) collapses all platforms to peerName.
Choice [3] (only other people) leaves the resolver to route per-runtime.
Choice [2] (me + others, pooled) aliases the operator's own runtime IDs.
These tests script the interactive _prompt calls and assert the
resulting hermes_host block, so the wizard's deployment-shape
These tests mock gateway detection and script the interactive _prompt
calls, asserting the resulting hermes_host block so the tree's routing
semantics stay locked even as adjacent prompts are added.
"""
def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None):
def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None,
gateway_platforms=("telegram",)):
import plugins.memory.honcho.cli as honcho_cli
cfg_path = tmp_path / "config.json"
@@ -346,6 +349,10 @@ class TestSetupWizardDeploymentShape:
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True)
monkeypatch.setattr(honcho_cli, "_write_config", lambda *a, **k: None)
# Gate detection is mocked so tests control whether the tree runs.
# None → undetectable; list (possibly empty) → connected platforms.
gw = None if gateway_platforms is None else list(gateway_platforms)
monkeypatch.setattr(honcho_cli, "_gateway_platforms", lambda: gw)
# Bypass config.yaml + connection test side effects.
monkeypatch.setattr(
@@ -391,14 +398,14 @@ class TestSetupWizardDeploymentShape:
honcho_cli.cmd_setup(SimpleNamespace())
return cfg["hosts"]["hermes"]
def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, tmp_path):
def test_just_me_pins_and_clears_aliases(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"single", # deployment shape ← key answer
"1", # tree: just me ← key answer
# remaining prompts fall through to defaults
]
initial_cfg = {
@@ -409,51 +416,54 @@ class TestSetupWizardDeploymentShape:
}},
}
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is True
assert host["pinUserPeer"] is True
assert "userPeerAliases" not in host
assert "runtimePeerPrefix" not in host
def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path):
def test_only_others_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"multi", # deployment shape
"3", # tree: only other people
"telegram_", # runtime peer prefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
assert host["pinPeerName"] is False
assert host["pinUserPeer"] is False
# Multi must NOT auto-write ``userPeerAliases: {}``: an empty host
# map would silently override a root-level baseline. Absence is
# the correct "no host opinion" signal.
assert "userPeerAliases" not in host
assert host["runtimePeerPrefix"] == "telegram_"
def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path):
def test_pooled_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"hybrid", # deployment shape
"86701400", # telegram uid
"2", # tree: me + other people
"y", # keep my memory pooled? → hybrid
"7654321", # telegram uid
"491827364", # discord snowflake
"", # slack (skip)
"", # matrix (skip)
"", # runtime peer prefix (skip)
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
assert host["pinPeerName"] is False
assert host["pinUserPeer"] is False
assert host["userPeerAliases"] == {
"86701400": "eri",
"7654321": "eri",
"491827364": "eri",
}
assert "runtimePeerPrefix" not in host
def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_path):
# Seeds the legacy ``pinPeerName``: skip must leave the mapping intact
# except for the on-load migration onto the canonical key.
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {
@@ -463,17 +473,18 @@ class TestSetupWizardDeploymentShape:
}},
}
answers = [
"cloud", "", "eri", "hermetika", "hermes", "skip",
"cloud", "", "eri", "hermetika", "hermes", "s",
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is True
assert host["pinUserPeer"] is True
assert "pinPeerName" not in host
assert host["userPeerAliases"] == {"keep": "me"}
assert host["runtimePeerPrefix"] == "keep_"
def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path):
"""Flipping single → multi triggers a warning that auto-steers the
operator to ``hybrid`` (default), so their own runtime IDs keep
landing on peerName instead of orphaning the pinned-pool history.
def test_unpin_steers_to_pooled_by_default(self, monkeypatch, tmp_path):
"""Choosing 'only other people' on a currently-pinned profile triggers
the orphan warning, which auto-steers to pooled (hybrid) so the
operator's own runtime IDs keep landing on peerName.
"""
initial_cfg = {
"apiKey": "***",
@@ -485,60 +496,57 @@ class TestSetupWizardDeploymentShape:
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"multi", # deployment shape — triggers the guard
"hybrid", # guard response: accept the steer
"86701400", # telegram uid
"3", # tree: only others — triggers the orphan guard
"y", # pool my own memory instead? → hybrid
"7654321", # telegram uid
"", # discord (skip)
"", # slack (skip)
"", # matrix (skip)
"", # runtime prefix (skip)
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert host["userPeerAliases"] == {"86701400": "eri"}
assert host["pinUserPeer"] is False
assert host["userPeerAliases"] == {"7654321": "eri"}
def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path):
"""Operator can override the steer by answering ``yes`` and accept
the orphaning consequences. This is the explicit undo-the-pin path.
"""
def test_unpin_decline_steer_keeps_per_user(self, monkeypatch, tmp_path):
"""Operator can decline the steer ('n') and accept orphaning, ending
up with per-user peers (no aliases)."""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}},
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"multi", # deployment shape — triggers the guard
"yes", # guard response: confirm multi
"3", # tree: only others — triggers the orphan guard
"n", # decline pooling, accept orphaning
"telegram_", # runtime peer prefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
# See test_multi_shape_leaves_pin_false_and_accepts_prefix.
assert host["pinUserPeer"] is False
assert "userPeerAliases" not in host
assert host["runtimePeerPrefix"] == "telegram_"
def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_path):
"""Host-level ``pinUserPeer: true`` must classify as ``single``.
Pressing Enter at the shape prompt then preserves the pin instead
of falling through to ``multi`` and orphaning the user's memory
pool — the bug the wizard regressed when ``pinUserPeer`` landed
as a higher-precedence alias.
Pressing Enter at the choice prompt then preserves the pin instead
of falling through to per-user routing and orphaning the user's
memory pool — the bug the wizard regressed when ``pinUserPeer``
landed as a higher-precedence alias.
"""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}},
}
# Exhaust the iterator before the shape prompt so the scripted
# mock falls through to the prompt's default (which is the
# wizard-detected shape). Scripting an explicit "" would NOT
# exercise that fallthrough — the mock returns it literally.
# Exhaust the iterator before the choice prompt so the scripted
# mock falls through to the prompt's default (the detected shape →
# choice "1"). Scripting an explicit "" would NOT exercise that
# fallthrough — the mock returns it literally.
answers = ["cloud", "", "eri", "hermetika", "hermes"]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
# Scrub-then-write normalises onto pinPeerName and drops the alias
# so resolver precedence can't reintroduce ambiguity.
assert host["pinPeerName"] is True
assert "pinUserPeer" not in host
# Scrub-then-write normalises onto the canonical pinUserPeer.
assert host["pinUserPeer"] is True
assert "pinPeerName" not in host
def test_host_pin_user_peer_false_overrides_root_pin_peer_name(
self, monkeypatch, tmp_path
@@ -558,8 +566,8 @@ class TestSetupWizardDeploymentShape:
}
answers = ["cloud", "", "eri", "hermetika", "hermes"]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert "pinUserPeer" not in host
assert host["pinUserPeer"] is False
assert "pinPeerName" not in host
def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path):
"""Root-level ``userPeerAliases`` must classify as ``hybrid`` even
@@ -567,26 +575,26 @@ class TestSetupWizardDeploymentShape:
"""
initial_cfg = {
"apiKey": "***",
"userPeerAliases": {"86701400": "eri"},
"userPeerAliases": {"7654321": "eri"},
"hosts": {"hermes": {"peerName": "eri"}},
}
answers = ["cloud", "", "eri", "hermetika", "hermes"]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert host["pinUserPeer"] is False
# Hybrid materialises the root aliases into the host so subsequent
# operator edits live on the host block they're inspecting.
assert host["userPeerAliases"] == {"86701400": "eri"}
assert host["userPeerAliases"] == {"7654321": "eri"}
def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path):
"""Explicit ``multi`` must leave the host ``userPeerAliases`` key
absent, preserving any root-level aliases as a cross-host baseline.
def test_only_others_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path):
"""Explicitly choosing 'only other people' must leave the host
``userPeerAliases`` key absent, preserving any root-level aliases as a
cross-host baseline.
Picking ``multi`` here is an active choice — detection would have
defaulted to ``hybrid`` because root aliases exist — so the
operator's intent is to drop the alias mapping for this host.
We honor that by writing ``pinPeerName: false`` only, and rely
on the host's absence of ``userPeerAliases`` to inherit root.
That inheritance is intentional: a true wipe would require the
Picking [3] here is an active choice — detection would have defaulted
to [2]/hybrid because root aliases exist — so the operator's intent is
to drop the alias mapping for this host. We honor that by writing
``pinUserPeer: false`` only, relying on the host's absence of
``userPeerAliases`` to inherit root. A true wipe would require the
operator to delete the root key explicitly.
"""
initial_cfg = {
@@ -596,17 +604,15 @@ class TestSetupWizardDeploymentShape:
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"multi", # explicit multi override of detected hybrid
"3", # explicit per-user override of detected hybrid
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert host["pinUserPeer"] is False
assert "userPeerAliases" not in host
def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path):
"""Choosing ``single`` must drop any host-level ``pinUserPeer``,
otherwise an existing ``pinUserPeer: false`` would outrank the
freshly written ``pinPeerName: true`` and leave the profile
effectively unpinned (the P1 latent-precedence regression).
def test_just_me_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path):
"""Choosing 'just me' must overwrite a stale ``pinUserPeer: false``
with ``pinUserPeer: true`` so the profile ends up genuinely pinned.
"""
initial_cfg = {
"apiKey": "***",
@@ -617,11 +623,56 @@ class TestSetupWizardDeploymentShape:
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"single",
"1",
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is True
assert host["pinUserPeer"] is True
def test_no_gateway_connected_skips_mapping_when_declined(self, monkeypatch, tmp_path):
"""With no gateway platforms connected, the tree is gated off; declining
the 'configure anyway?' prompt leaves identity mapping untouched."""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"peerName": "eri"}},
}
answers = ["cloud", "", "eri", "hermetika", "hermes", "n"]
host = self._run_setup(
monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg,
gateway_platforms=[],
)
assert "pinUserPeer" not in host
assert "userPeerAliases" not in host
assert "runtimePeerPrefix" not in host
def test_undetectable_gateway_skips_mapping_when_declined(self, monkeypatch, tmp_path):
"""When the gateway package can't be inspected (None), the wizard asks
whether the gateway is running; 'no' skips the mapping step."""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"peerName": "eri"}},
}
answers = ["cloud", "", "eri", "hermetika", "hermes", "n"]
host = self._run_setup(
monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg,
gateway_platforms=None,
)
assert "pinUserPeer" not in host
def test_raw_edit_sets_resolver_knobs_directly(self, monkeypatch, tmp_path):
"""The [e] escape hatch lets a power user set pinUserPeer + an alias +
prefix directly, bypassing the intent tree."""
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"e", # tree: edit raw keys
"false", # pinUserPeer
"99887766=eri", # one alias pair
"", # finish aliases
"discord_", # runtimePeerPrefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
assert host["pinUserPeer"] is False
assert host["userPeerAliases"] == {"99887766": "eri"}
assert host["runtimePeerPrefix"] == "discord_"
class TestCloneCarriesPinUserPeer:
@@ -653,3 +704,27 @@ class TestCloneCarriesPinUserPeer:
assert ok is True
new_block = written["cfg"]["hosts"]["hermes_partner"]
assert new_block["pinUserPeer"] is True
class TestMigratePinKey:
"""``_migrate_pin_key`` rewrites the legacy ``pinPeerName`` onto the
canonical ``pinUserPeer`` in place, without clobbering an existing
canonical value."""
def test_legacy_key_renamed_to_canonical(self):
import plugins.memory.honcho.cli as honcho_cli
block = {"pinPeerName": True}
assert honcho_cli._migrate_pin_key(block) is True
assert block == {"pinUserPeer": True}
def test_canonical_key_wins_when_both_present(self):
import plugins.memory.honcho.cli as honcho_cli
block = {"pinPeerName": True, "pinUserPeer": False}
assert honcho_cli._migrate_pin_key(block) is True
assert block == {"pinUserPeer": False}
def test_noop_when_no_legacy_key(self):
import plugins.memory.honcho.cli as honcho_cli
block = {"pinUserPeer": True}
assert honcho_cli._migrate_pin_key(block) is False
assert block == {"pinUserPeer": True}
+60 -60
View File
@@ -105,7 +105,7 @@ class TestRuntimePeerMappingConfigParsing:
config_file.write_text(json.dumps({
"apiKey": "k",
"userPeerAliases": {
" 86701400 ": " Igor ",
" 7654321 ": " Igor ",
"": "ignored",
"empty-value": " ",
"null-value": None,
@@ -115,7 +115,7 @@ class TestRuntimePeerMappingConfigParsing:
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.user_peer_aliases == {"86701400": "Igor"}
assert config.user_peer_aliases == {"7654321": "Igor"}
assert config.runtime_peer_prefix == "telegram_"
def test_host_aliases_override_root_aliases_as_whole_map(self, tmp_path):
@@ -226,12 +226,12 @@ class TestPeerResolutionOrder:
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._config(peer_name="Igor", pin_peer_name=False),
runtime_user_peer_name="86701400", # e.g. Telegram UID
runtime_user_peer_name="7654321", # e.g. Telegram UID
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == "86701400", (
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "7654321", (
"pin_peer_name=False is the multi-user default — the gateway's "
"platform-native user ID must win so each user gets their own "
"peer scope. If this regresses, every Telegram/Discord/Slack "
@@ -245,14 +245,14 @@ class TestPeerResolutionOrder:
config=self._config(
peer_name="Igor",
pin_peer_name=False,
user_peer_aliases={"86701400": "Igor"},
user_peer_aliases={"7654321": "Igor"},
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "Igor"
def test_unknown_runtime_id_uses_prefix(self):
@@ -264,12 +264,12 @@ class TestPeerResolutionOrder:
pin_peer_name=False,
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == "telegram_86701400"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "telegram_7654321"
def test_prefixed_runtime_id_hashes_when_sanitization_is_lossy(self):
"""Generated prefixed IDs avoid merges caused by lossy sanitization."""
@@ -291,43 +291,43 @@ class TestPeerResolutionOrder:
def test_prefixed_runtime_id_hashes_when_it_collides_with_peer_name(self):
"""Unknown generated peers should not silently merge into peerName."""
raw_peer_id = "telegram_86701400"
raw_peer_id = "telegram_7654321"
expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8]
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._config(
peer_name="telegram_86701400",
peer_name="telegram_7654321",
pin_peer_name=False,
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == f"telegram_86701400-{expected_hash}"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == f"telegram_7654321-{expected_hash}"
def test_prefixed_runtime_id_hashes_when_it_collides_with_alias_target(self):
"""Unknown generated peers should not silently merge into alias targets."""
raw_peer_id = "telegram_86701400"
raw_peer_id = "telegram_7654321"
expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8]
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._config(
peer_name=None,
pin_peer_name=False,
user_peer_aliases={"known-user": "telegram_86701400"},
user_peer_aliases={"known-user": "telegram_7654321"},
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == f"telegram_86701400-{expected_hash}"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == f"telegram_7654321-{expected_hash}"
def test_prefixed_runtime_id_extends_hash_when_short_hash_collides(self):
raw_peer_id = "telegram_86701400"
raw_peer_id = "telegram_7654321"
digest = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()
mgr = HonchoSessionManager(
honcho=MagicMock(),
@@ -335,17 +335,17 @@ class TestPeerResolutionOrder:
peer_name=None,
pin_peer_name=False,
user_peer_aliases={
"known-user": "telegram_86701400",
"reserved-user": f"telegram_86701400-{digest[:8]}",
"known-user": "telegram_7654321",
"reserved-user": f"telegram_7654321-{digest[:8]}",
},
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == f"telegram_86701400-{digest[:12]}"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == f"telegram_7654321-{digest[:12]}"
def test_alias_value_is_sanitized_after_selection(self):
mgr = HonchoSessionManager(
@@ -353,13 +353,13 @@ class TestPeerResolutionOrder:
config=self._config(
peer_name=None,
pin_peer_name=False,
user_peer_aliases={"86701400": "Alice Smith!"},
user_peer_aliases={"7654321": "Alice Smith!"},
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "Alice-Smith-"
def test_alias_keys_match_raw_runtime_id_before_sanitization(self):
@@ -391,13 +391,13 @@ class TestPeerResolutionOrder:
runtime_peer_prefix="telegram_",
session_peer_prefix=True,
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == "telegram_86701400"
assert session.honcho_session_id == "telegram-86701400"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "telegram_7654321"
assert session.honcho_session_id == "telegram-7654321"
def test_config_wins_when_pin_is_true(self):
"""With pin enabled, configured peer_name beats runtime ID."""
@@ -406,14 +406,14 @@ class TestPeerResolutionOrder:
config=self._config(
peer_name="Igor",
pin_peer_name=True,
user_peer_aliases={"86701400": "Alias"},
user_peer_aliases={"7654321": "Alias"},
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400", # Telegram pushes this in
runtime_user_peer_name="7654321", # Telegram pushes this in
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "Igor", (
"With pinPeerName=true the user's configured peer_name must "
"beat the platform-native runtime ID so memory stays unified "
@@ -429,26 +429,26 @@ class TestPeerResolutionOrder:
config=self._config(
peer_name=None,
pin_peer_name=True,
user_peer_aliases={"86701400": "Igor"},
user_peer_aliases={"7654321": "Igor"},
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "Igor"
def test_pin_noop_without_peer_name_or_mapping_preserves_runtime(self):
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._config(peer_name=None, pin_peer_name=True),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == "86701400"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "7654321"
def test_alt_runtime_id_can_match_alias_without_changing_raw_fallback(self):
"""Stable alternate IDs can map known users while primary ID fallback stays unchanged."""
@@ -526,11 +526,11 @@ class TestPeerResolutionOrder:
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=cfg,
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "Igor"
assert session.assistant_peer_id == "hermes-assistant"
@@ -556,10 +556,10 @@ class TestCrossPlatformMemoryUnification:
mgr_telegram = HonchoSessionManager(
honcho=MagicMock(),
config=self._config_pinned(),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr_telegram)
telegram_session = mgr_telegram.get_or_create("telegram:86701400")
telegram_session = mgr_telegram.get_or_create("telegram:7654321")
# Discord turn (separate manager instance — simulates a fresh
# platform-adapter invocation)
@@ -701,20 +701,20 @@ class TestPinTransition:
pinned_mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned(),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(pinned_mgr)
before = pinned_mgr.get_or_create("telegram:86701400")
before = pinned_mgr.get_or_create("telegram:7654321")
assert before.user_peer_id == "Igor"
unpinned_mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._unpinned(),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(unpinned_mgr)
after = unpinned_mgr.get_or_create("telegram:86701400")
assert after.user_peer_id == "86701400", (
after = unpinned_mgr.get_or_create("telegram:7654321")
assert after.user_peer_id == "7654321", (
"After flipping pinPeerName off, the same runtime ID must resolve "
"to its own peer — otherwise multi-user mode silently merges users."
)
@@ -723,14 +723,14 @@ class TestPinTransition:
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned(),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
first = mgr.get_or_create("telegram:86701400")
first = mgr.get_or_create("telegram:7654321")
assert first.user_peer_id == "Igor"
mgr._config = self._unpinned()
second = mgr.get_or_create("telegram:86701400")
second = mgr.get_or_create("telegram:7654321")
assert second.user_peer_id == "Igor", (
"The per-key session cache is keyed by session-key, not by "
"resolved peer. In-process flips don't invalidate it — the "
@@ -764,7 +764,7 @@ class TestPinTransition:
cfg_path.write_text(json.dumps({
"apiKey": "k",
"peerName": "Igor",
"userPeerAliases": {"86701400": "Igor"},
"userPeerAliases": {"7654321": "Igor"},
}))
sig_with_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
@@ -839,18 +839,18 @@ class TestProfilePeerUniqueness:
mgr_a = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned_to("alice"),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr_a)
sess_a = mgr_a.get_or_create("telegram:86701400")
sess_a = mgr_a.get_or_create("telegram:7654321")
mgr_b = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned_to("bob"),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr_b)
sess_b = mgr_b.get_or_create("telegram:86701400")
sess_b = mgr_b.get_or_create("telegram:7654321")
assert sess_a.user_peer_id == "alice"
assert sess_b.user_peer_id == "bob"
+10 -45
View File
@@ -88,7 +88,10 @@ def test_store_project_credentials_round_trip(
sid, secret = photon_auth.load_project_credentials()
assert sid == "sp-123"
assert secret == "secret-key"
assert photon_auth.load_dashboard_project_id() == "dash-456"
# Post-unification the management id resolves to the Spectrum id, not the
# stored dashboard id — so a pre-backfill diverged install (whose old
# dashboard id was rewritten and now 404s) still reaches the live row.
assert photon_auth.load_dashboard_project_id() == "sp-123"
def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None:
@@ -284,7 +287,7 @@ def test_find_project_by_name_case_insensitive(monkeypatch: pytest.MonkeyPatch)
assert proj is not None and proj["id"] == "p2"
def test_create_project_sends_spectrum_true(monkeypatch: pytest.MonkeyPatch) -> None:
def test_create_project_omits_spectrum_flag(monkeypatch: pytest.MonkeyPatch) -> None:
captured: Dict[str, Any] = {}
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
@@ -296,7 +299,9 @@ def test_create_project_sends_spectrum_true(monkeypatch: pytest.MonkeyPatch) ->
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
data = photon_auth.create_project("tok", name="Hermes Agent")
assert data["id"] == "new-proj"
assert captured["body"]["spectrum"] is True
# Spectrum is always provisioned at create-time; the field was dropped
# from the API schema, so we must not send it.
assert "spectrum" not in captured["body"]
assert captured["body"]["name"] == "Hermes Agent"
assert captured["headers"]["Authorization"] == "Bearer tok"
assert captured["url"].endswith("/api/projects")
@@ -311,46 +316,6 @@ def test_create_project_raises_without_id(monkeypatch: pytest.MonkeyPatch) -> No
photon_auth.create_project("tok")
def test_ensure_spectrum_enabled_toggles_when_off(monkeypatch: pytest.MonkeyPatch) -> None:
get_calls = {"n": 0}
posted = {"toggle": False}
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
get_calls["n"] += 1
if get_calls["n"] == 1:
return _FakeResponse(json_body={"id": "p", "spectrum": False, "spectrumProjectId": None})
return _FakeResponse(json_body={"id": "p", "spectrum": True, "spectrumProjectId": "sp-1"})
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
if url.endswith("/spectrum/toggle"):
posted["toggle"] = True
return _FakeResponse(json_body={"success": True})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
proj = photon_auth.ensure_spectrum_enabled("tok", "p")
assert posted["toggle"] is True
assert proj["spectrumProjectId"] == "sp-1"
def test_ensure_spectrum_enabled_skips_toggle_when_on(monkeypatch: pytest.MonkeyPatch) -> None:
posted = {"toggle": False}
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"id": "p", "spectrum": True, "spectrumProjectId": "sp-1"})
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
if url.endswith("/spectrum/toggle"):
posted["toggle"] = True
return _FakeResponse(json_body={"success": True})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
proj = photon_auth.ensure_spectrum_enabled("tok", "p")
assert posted["toggle"] is False
assert proj["spectrumProjectId"] == "sp-1"
def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
assert url.endswith("/regenerate-secret")
@@ -498,8 +463,8 @@ def test_credential_summary_no_secret_leak(
assert "secret-bbbb" not in blob
assert summary["device_token"].startswith("✓")
assert summary["project_key"].startswith("✓")
assert summary["spectrum_project_id"] == "sp-uuid"
assert summary["dashboard_project_id"] == "dash-uuid"
# Unified id: dashboard id == Spectrum id, surfaced as one project id.
assert summary["project_id"] == "sp-uuid"
assert summary["phone_number"].startswith("✗ missing")
assert summary["assigned_phone_number"].startswith("✗ missing")
@@ -73,12 +73,10 @@ def test_env_enablement_home_channel_defaults_name(monkeypatch: pytest.MonkeyPat
def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
# The dashboard id *is* the Spectrum project id (ids unified), so setup no
# longer enables Spectrum or fetches a separate spectrumProjectId — it
# reuses this id directly.
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
monkeypatch.setattr(
cli.photon_auth,
"ensure_spectrum_enabled",
lambda token, dashboard_id: {"spectrumProjectId": "project_123"},
)
monkeypatch.setattr(
cli.photon_auth,
"regenerate_project_secret",
+117 -1
View File
@@ -115,10 +115,11 @@ def test_background_review_summarizer_receives_captured_messages_after_close(mon
# must have snapshot them before this runs.
self._session_messages = []
def fake_summarize(review_messages, prior_snapshot):
def fake_summarize(review_messages, prior_snapshot, notification_mode="on"):
events.append("summarize")
captured["review_messages"] = list(review_messages)
captured["prior_snapshot"] = list(prior_snapshot)
captured["notification_mode"] = notification_mode
return []
monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent)
@@ -146,6 +147,7 @@ def test_background_review_summarizer_receives_captured_messages_after_close(mon
]
assert captured["review_messages"] == [review_tool_message]
assert captured["prior_snapshot"] == messages_snapshot
assert captured["notification_mode"] == "on"
def test_background_review_installs_auto_deny_approval_callback(monkeypatch):
@@ -313,3 +315,117 @@ def test_background_review_fork_skips_external_memory_plugins(monkeypatch):
"the fork leaks harness prompts into the user's real memory "
"namespace via on_turn_start / prefetch_all / sync_all."
)
# ---------------------------------------------------------------------------
# memory_notifications mode: off | on | verbose
# ---------------------------------------------------------------------------
import json as _json
from agent.background_review import summarize_background_review_actions
def _memory_add_review():
"""A minimal review transcript: one memory add (assistant call + tool result)."""
return [
{
"role": "assistant",
"tool_calls": [
{
"id": "call_mem1",
"function": {
"name": "memory",
"arguments": _json.dumps(
{
"action": "add",
"target": "memory",
"content": "User prefers terse replies",
}
),
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_mem1",
"content": _json.dumps(
{"success": True, "message": "Entry added.", "target": "memory"}
),
},
]
def _skill_patch_review():
return [
{
"role": "assistant",
"tool_calls": [
{
"id": "call_skill1",
"function": {
"name": "skill_manage",
"arguments": _json.dumps(
{"action": "patch", "name": "demo", "old_string": "a", "new_string": "b"}
),
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_skill1",
"content": _json.dumps(
{
"success": True,
"message": "Patched SKILL.md in skill 'demo' (1 replacement).",
"_change": {"old": "a", "new": "b"},
}
),
},
]
def test_memory_notifications_off_returns_nothing():
actions = summarize_background_review_actions(
_memory_add_review(), [], notification_mode="off"
)
assert actions == []
def test_memory_notifications_on_returns_generic_line():
actions = summarize_background_review_actions(
_memory_add_review(), [], notification_mode="on"
)
assert actions == ["Memory updated"]
def test_memory_notifications_verbose_includes_content_preview():
actions = summarize_background_review_actions(
_memory_add_review(), [], notification_mode="verbose"
)
assert len(actions) == 1
# Verbose surfaces the actual content that was saved.
assert "User prefers terse replies" in actions[0]
assert actions[0] != "Memory updated"
def test_memory_notifications_default_is_on():
"""No mode passed → behaves like 'on' (generic line, not empty/verbose)."""
actions = summarize_background_review_actions(_memory_add_review(), [])
assert actions == ["Memory updated"]
def test_skill_patch_off_silent_verbose_shows_diff():
assert (
summarize_background_review_actions(
_skill_patch_review(), [], notification_mode="off"
)
== []
)
verbose = summarize_background_review_actions(
_skill_patch_review(), [], notification_mode="verbose"
)
assert len(verbose) == 1
assert "demo" in verbose[0] and "→" in verbose[0]
@@ -0,0 +1,55 @@
"""Regression tests for the Hermes-managed Node's npm global prefix.
When the installer falls back to a bundled Node under ``$HERMES_HOME/node``,
npm's default global prefix is that Node dir, so ``npm install -g <pkg>``
drops the package binary in ``$HERMES_HOME/node/bin`` — which is NOT on PATH
(only the command link dir is) and is wiped on every Node upgrade. Users then
report "I can ``npm i -g`` but the package isn't usable on the command line".
The fix redirects the bundled Node's global prefix to the command link dir's
parent (so global bins land in the already-on-PATH link dir alongside
node/npm/npx), scoped to the bundled Node via its prefix-local global npmrc.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
NODE_BOOTSTRAP = REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh"
def test_install_sh_redirects_bundled_npm_global_prefix_to_link_dir() -> None:
text = INSTALL_SH.read_text()
# The redirect must target the link dir's PARENT so global bins resolve to
# <parent>/bin == the command link dir (node/npm/npx live there and it is
# guaranteed on PATH by the installer's PATH setup).
assert "configure_managed_node_npm_prefix()" in text
assert 'printf \'prefix=%s\\n\' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text
def test_install_sh_repairs_existing_managed_node_on_rerun() -> None:
"""The redirect must run on every install (not just fresh Node installs),
so re-running the installer repairs pre-existing managed installs whose
Node is already up to date and would otherwise skip install_node."""
text = INSTALL_SH.read_text()
check_node_body = text.split("check_node()", 1)[1].split("\ninstall_node()", 1)[0]
assert "configure_managed_node_npm_prefix" in check_node_body
# No-op guard so it's safe to call when there is no managed Node.
assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text
def test_node_bootstrap_redirects_bundled_npm_global_prefix_to_link_dir() -> None:
text = NODE_BOOTSTRAP.read_text()
assert "_nb_configure_npm_prefix()" in text
assert 'printf \'prefix=%s\\n\' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text
# Runs at the top of ensure_node so existing managed installs are repaired
# even when a modern Node is already present (early return path).
ensure_node_body = text.split("ensure_node()", 1)[1]
assert "_nb_configure_npm_prefix" in ensure_node_body
assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text
+130 -28
View File
@@ -3036,6 +3036,94 @@ def test_config_set_model_global_persists(monkeypatch):
assert saved["model"]["base_url"] == "https://api.anthropic.com"
def test_config_set_model_explicit_provider_skips_broken_default_init(monkeypatch):
seen = {"build": 0, "wait": 0, "requested": []}
session = _session()
session["agent"] = None
server._sessions["sid"] = session
monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"default": "broken/model", "provider": "openrouter"}})
monkeypatch.setattr(server, "_start_agent_build", lambda *_args: seen.__setitem__("build", seen["build"] + 1))
monkeypatch.setattr(server, "_wait_agent", lambda *_args: seen.__setitem__("wait", seen["wait"] + 1))
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
monkeypatch.setattr(server, "_restart_slash_worker", lambda *args, **kwargs: None)
def fake_runtime_provider(*, requested=None, target_model=None, **_kwargs):
seen["requested"].append((requested, target_model))
if requested is None:
raise RuntimeError("broken default provider should not be initialized")
if requested == "anthropic":
return {
"api_key": "sk-anthropic",
"api_mode": "anthropic_messages",
"base_url": "https://api.anthropic.com",
}
raise RuntimeError(f"unexpected provider {requested}")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", fake_runtime_provider)
try:
resp = server.handle_request(
{
"id": "1",
"method": "config.set",
"params": {
"session_id": "sid",
"key": "model",
"value": "claude-sonnet-4.6 --provider anthropic",
},
}
)
assert resp["result"]["value"] == "claude-sonnet-4-6"
assert seen["build"] == 0
assert seen["wait"] == 0
assert seen["requested"] == [("anthropic", "claude-sonnet-4.6")]
assert session["model_override"]["provider"] == "anthropic"
assert session["model_override"]["model"] == "claude-sonnet-4-6"
finally:
server._sessions.pop("sid", None)
def test_config_set_model_explicit_provider_surfaces_selected_provider_errors(monkeypatch):
seen = {"build": 0, "wait": 0}
session = _session()
session["agent"] = None
server._sessions["sid"] = session
monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"default": "broken/model", "provider": "openrouter"}})
monkeypatch.setattr(server, "_start_agent_build", lambda *_args: seen.__setitem__("build", seen["build"] + 1))
monkeypatch.setattr(server, "_wait_agent", lambda *_args: seen.__setitem__("wait", seen["wait"] + 1))
def fake_runtime_provider(*, requested=None, **_kwargs):
if requested is None:
raise RuntimeError("broken default provider should not be initialized")
if requested == "anthropic":
raise RuntimeError("missing anthropic API key")
raise RuntimeError(f"unexpected provider {requested}")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", fake_runtime_provider)
try:
resp = server.handle_request(
{
"id": "1",
"method": "config.set",
"params": {
"session_id": "sid",
"key": "model",
"value": "claude-sonnet-4.6 --provider anthropic",
},
}
)
assert resp["error"]["code"] == 5001
assert "anthropic" in resp["error"]["message"].lower()
assert "missing anthropic api key" in resp["error"]["message"].lower()
assert seen["build"] == 0
assert seen["wait"] == 0
finally:
server._sessions.pop("sid", None)
def test_config_set_model_does_not_leak_inference_provider_env(monkeypatch):
"""A /model switch must NOT mutate process-global env vars. The desktop /
dashboard tui_gateway backend hosts every same-profile session in one
@@ -4813,33 +4901,45 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch):
)
monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)
resp = server.handle_request(
{
"id": "1",
"method": "session.create",
"params": {"cols": 80},
}
)
sid = resp["result"]["session_id"]
# Isolate from sibling-test leakage: daemon build threads from prior
# session.create tests in the same shard process mutate the shared
# ``server._sessions`` dict under ``_sessions_lock`` and can replace/pop
# entries mid-run, which would flip this build thread's ``replaced`` check
# to True and trigger a spurious unregister. Snapshot, clear, and restore
# so this test sees only its own session regardless of shard composition.
_saved_sessions = dict(server._sessions)
server._sessions.clear()
# Wait for the build to finish (ready event inside session dict).
session = server._sessions[sid]
session["agent_ready"].wait(timeout=2.0)
try:
resp = server.handle_request(
{
"id": "1",
"method": "session.create",
"params": {"cols": 80},
}
)
sid = resp["result"]["session_id"]
# Build finished without a close race — nothing should have been
# cleaned up by the orphan check.
assert (
closed_workers == []
), f"build thread closed its own worker despite no race: {closed_workers}"
assert (
unregistered_keys == []
), f"build thread unregistered its own notify despite no race: {unregistered_keys}"
# Wait for the build to finish (ready event inside session dict).
session = server._sessions[sid]
built = session["agent_ready"].wait(timeout=10.0)
assert built, "agent build did not complete within timeout"
# Session should have the live worker installed.
assert session.get("slash_worker") is not None
# Build finished without a close race — nothing should have been
# cleaned up by the orphan check.
assert (
closed_workers == []
), f"build thread closed its own worker despite no race: {closed_workers}"
assert (
unregistered_keys == []
), f"build thread unregistered its own notify despite no race: {unregistered_keys}"
# Cleanup
server._sessions.pop(sid, None)
# Session should have the live worker installed.
assert session.get("slash_worker") is not None
finally:
# Cleanup + restore sibling sessions we snapshotted.
server._sessions.clear()
server._sessions.update(_saved_sessions)
def test_get_db_degrades_cleanly_when_sessiondb_init_fails(monkeypatch):
@@ -6840,6 +6940,8 @@ def test_notification_event_dedup_key_preserves_distinct_watch_matches():
def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch):
"""Distinct watch matches from one process emit; exact replay is deduped."""
import queue as _queue_mod
from tools.process_registry import process_registry
turns = []
@@ -6855,8 +6957,8 @@ def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch):
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
monkeypatch.setattr(server, "_run_prompt_submit", _fake_run_prompt_submit)
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
base = {
"type": "watch_match",
@@ -6866,9 +6968,9 @@ def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch):
"output": "READY on port 8000",
"suppressed": 0,
}
process_registry.completion_queue.put(base)
process_registry.completion_queue.put({**base, "output": "READY on port 9000"})
process_registry.completion_queue.put(dict(base))
isolated_queue.put(base)
isolated_queue.put({**base, "output": "READY on port 9000"})
isolated_queue.put(dict(base))
stop = threading.Event()
stop.set()

Some files were not shown because too many files have changed in this diff Show More