Compare commits

..
Author SHA1 Message Date
kshitijk4poorandWesley Simplicio 72154ad879 perf(ci): cache uv + use uv sync in tests workflow
Both jobs in tests.yml (`test` matrix and `e2e`) start from a cold uv
cache on every run and install deps with `uv pip install -e ".[all,dev]"`,
which re-resolves pyproject.toml ranges and rebuilds the editable install
each time.

Two changes:

1. Enable uv's official CI caching via setup-uv's `enable-cache: true`,
   keyed on pyproject.toml + uv.lock, plus `uv cache prune --ci` to keep
   the persisted cache small. Warm runs install from cache instead of
   re-downloading/building wheels.

2. Replace the manual `uv venv` + `uv pip install -e` with
   `uv sync --locked --python 3.11 --extra all --extra dev`. sync installs
   the exact pinned set from uv.lock (and fails if the lock is stale vs
   pyproject.toml), creating .venv itself. This is reproducible and, with a
   warm cache, measurably faster than the editable pip install (~3-4x on the
   steady-state install step locally). Downstream steps keep using
   `source .venv/bin/activate`; sync writes .venv to the same path.

Follows the Astral-recommended pattern for uv in GitHub Actions:
https://docs.astral.sh/uv/guides/integration/github/

Co-authored-by: Wesley Simplicio <wesleysimplicio@live.com>
2026-06-09 18:30:44 -04:00
Teknium 57c6714995 fix(models): keep curated Anthropic aliases in /model picker (#43103)
The Anthropic picker returned the live /v1/models dump verbatim whenever
credentials were configured. Anthropic's API lags newly-routed curated
aliases (e.g. claude-fable-5, reachable on Anthropic before the models
endpoint enumerates it), so the curated entry vanished from the picker.

Merge curated _PROVIDER_MODELS["anthropic"] with the live catalog —
curated first, live-only appended, deduped — mirroring the OpenAI
curated-merge path. Live failure / no creds falls back to curated verbatim.
2026-06-09 14:45:19 -07:00
ethernet a5d05cf30e fix(nix); don't run .#fix-lockfiles
its so slow
2026-06-09 16:55:33 -04:00
ethernet 68a997fed4 add website links to readme for seo 2026-06-09 16:35:34 -04:00
Jeffrey Quesnelle 49dd776d8b Merge pull request #43041 from NousResearch/fix/fable-anthropic
add Fable 5 to model list for Anthropic provider
2026-06-09 15:38:51 -04:00
emozilla d7886da08c add Fable 5 to model list for Anthropic provider 2026-06-09 15:33:42 -04:00
xxxigm 02f878ec5a docs(windows): correct native data dir to %LOCALAPPDATA%\hermes (#42856)
* docs(windows): correct native data dir to %LOCALAPPDATA%\hermes

The Windows-native guide claimed a deliberate split where config, auth,
skills, and sessions live under %USERPROFILE%\.hermes. That is not what
the installer does: scripts/install.ps1 sets HERMES_HOME=%LOCALAPPDATA%\hermes,
so data actually lives in %LOCALAPPDATA%\hermes alongside the disposable
install (the hermes-agent\, git\, node\, bin\ subdirectories) — `hermes
config` confirms config.yaml/.env resolve there, not under %USERPROFILE%.

Update the data-layout table, the "split is deliberate" note, the env-var
and uninstall sections to describe the real layout: data and install share
the %LOCALAPPDATA%\hermes root, reinstall only replaces hermes-agent\, and
a full wipe targets %LOCALAPPDATA%\hermes (with %USERPROFILE%\.hermes kept
only as a legacy/WSL cleanup). Mention HERMES_HOME as the override knob.

* docs(windows): fix PATH + bin layout to match installer

The installer adds hermes-agent\venv\Scripts (where hermes.exe lives) to
User PATH and sets HERMES_HOME — not %LOCALAPPDATA%\hermes\bin. The \bin
dir holds Hermes's managed uv.exe, not a hermes.cmd shim. Correct the
install-step list and the data-layout table accordingly.

* fix(install): show real HERMES_HOME path in setup messages

The native Windows installer wrote config/env/skills under $HermesHome
(%LOCALAPPDATA%\hermes) but its success messages claimed ~/.hermes,
which doesn't exist on native Windows. Print the actual paths so a new
user can find their config, .env, and skills.
2026-06-09 14:11:20 -05:00
brooklyn!andJosh Dow 8d71c38919 fix(desktop): rebind sessions after websocket reconnect (salvage of #41740) (#43004)
* fix(desktop): rebind sessions after websocket reconnect

* docs(desktop): explain the reconnect-resume guard in use-route-resume

The reconnect fix turns on two subtle conditions with no inline rationale:
`seenGatewayStateRef` suppresses a spurious "became open" on the first effect
run (so a session mounting with the gateway already open doesn't double-resume),
and the `gatewayBecameOpen ||` arm forces a re-resume even when the route looks
`alreadyActive` because the cached runtime id can be stale after the gateway
rebinds/reaps the session. Comment both so the next reader doesn't "simplify"
them back into the original bug. No behavior change.

---------

Co-authored-by: Josh Dow <josh.dow@prepad.io>
2026-06-09 19:01:00 +00:00
Siddharth Balyan 46fedef07f fix(openrouter): never send reasoning field for adaptive Anthropic models (#43012)
The previous fix (#42991) only omitted reasoning when it was being disabled.
But reasoning-mandatory Anthropic models (Claude 4.6+, fable) 400 with
thinking.type.disabled on EVERY tool-continuation turn even when reasoning is
enabled: chat_completions never replays signed thinking blocks, so the prior
assistant tool_call has no thinking, and OpenRouter resolves "reasoning
requested but history has none" by emitting thinking.type.disabled — which
these models reject. Result: first turn works, every turn after the first tool
call dies (HTTP 400, non-retryable).

OpenRouter ignores reasoning.effort for adaptive Anthropic models anyway (the
model self-decides), so the reasoning field is pointless for them on every turn
and harmful on tool-replay turns. Omit it entirely → adaptive default.

- openrouter profile: drop the reasoning field for reasoning-mandatory Anthropic
  models regardless of enabled/disabled; legacy Anthropic + non-Anthropic models
  unchanged.
- tests: assert omission across enabled/disabled/effort variants; parity tests
  switched to a non-Anthropic reasoning model (deepseek) since Anthropic 4.6+ no
  longer carries a reasoning field.

Verified live end-to-end: a tool-replay turn on anthropic/claude-fable-5 with
reasoning enabled now builds extra_body=None and returns HTTP 200 (was 400).
2026-06-10 00:18:23 +05:30
brooklyn!andxxxigm ba44de06da fix(install): self-heal a stuck Electron download (salvage of #42894) (#42998)
* fix(install): self-heal a stuck Electron download on the desktop build

The desktop build downloads Electron (~114MB) from GitHub. A corrupt cached
zip, or a blocked/throttled GitHub release host (the repeating "retrying" log),
hard-failed the install — and install.sh had no recovery at all while
install.ps1 / `hermes desktop` only purged the cache.

All three build paths now escalate on a failed `npm run pack`:
GitHub → purge corrupt electron-*.zip + stale *-unpacked and retry → one retry
via a public Electron mirror (npmmirror.com). @electron/get SHASUM-verifies the
download, and a user-pinned ELECTRON_MIRROR is always respected (never
overridden). Adds a bash clear_electron_build_cache()/_desktop_pack() to mirror
the existing PowerShell/Python helpers.

* test(install): cover the Electron mirror fallback

Verify `hermes desktop` falls back to a mirror when the cache purge finds
nothing, and that a user-pinned ELECTRON_MIRROR is respected (no extra attempt,
not overridden).

* docs(desktop): troubleshoot a stuck Electron download

Document the automatic cache-purge + mirror fallback, how to pin your own
ELECTRON_MIRROR, and how to clear a corrupt cached zip by hand.

* docs(install): correct the Electron mirror trust framing

The mirror-fallback comments and the desktop troubleshooting doc implied
`@electron/get`'s SHASUM check makes the npmmirror.com download safe against
tampering. It doesn't: the SHASUMS256.txt is fetched from the same mirror, so
the check guards against a corrupt/partial download, not a compromised mirror.

Reframe all four surfaces (install.sh, install.ps1, `hermes desktop`, and the
docs) to state the trust trade-off honestly — npmmirror.com is the de-facto
Electron community mirror, we only fall back to it after the canonical GitHub
download fails, and a user-pinned ELECTRON_MIRROR is never overridden. No
behavior change.

---------

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
2026-06-09 18:19:14 +00:00
Rod Boev 5750d058fa fix(tests): use cross-platform pytest-timeout method (#39881) 2026-06-09 14:17:59 -04:00
Siddharth Balyan 1febb08240 fix(anthropic): default new Claude models to the modern thinking contract (#42991)
New Anthropic models without a recognized version substring (claude-fable-5
and future named/numbered releases) were classified as legacy and routed down
the manual-thinking path, which made OpenRouter emit thinking.type.disabled —
a form reasoning-mandatory Claude models reject with a non-retryable HTTP 400.

Invert the brittle version-substring allowlists to default-to-modern (mirroring
_get_anthropic_max_output): unknown Claude models get the adaptive/xhigh/
no-sampling contract, with an explicit legacy list for older families. Non-Claude
Anthropic-Messages models (minimax, qwen3, …) keep the manual path.

- anthropic_adapter: _supports_adaptive_thinking / _supports_xhigh_effort /
  _forbids_sampling_params now default unknown Claude models to modern; legacy
  families enumerated in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS.
- openrouter profile: omit reasoning entirely (→ adaptive default) instead of
  forwarding {enabled:false} for reasoning-mandatory Anthropic models; legacy
  Anthropic + all non-Anthropic models still pass the disable form through.
- model_metadata + output-limit table: register claude-fable-5 (1M ctx, 128K out).

Tests assert the invariant ("unknown Claude model -> modern contract; legacy
stays manual; non-Claude unaffected"), not specific model names.
2026-06-09 23:37:23 +05:30
Frowte3k 39b76d9013 fix(packaging): ship optional-mcps catalog in wheel and sdist (#39859)
The shipped MCP catalog (optional-mcps/) wasn't packaged, so `hermes mcp catalog` and the dashboard catalog screen come up empty on pip/Homebrew/Nix installs even though the manifests exist in the repo. The runtime expects a packaged catalog (get_optional_mcps_dir() -> _get_packaged_data_dir("optional-mcps"); list_catalog() returns [] when it's absent).

Ship it like locales: pyproject [tool.setuptools.data-files] for the wheel + a MANIFEST.in graft for the sdist. optional-mcps/ is nested (optional-mcps/<name>/manifest.yaml) and data-files flattens each glob into its target dir, so each catalog entry gets its own target to preserve the per-entry directory the catalog iterates over.
2026-06-09 14:03:20 -04:00
Austin Pickett 52f7e24a74 feat(tui): interactive Plugins Hub overlay for enable/disable
The TUI had no way to toggle plugins — `/plugins` only printed a static
list, and the classic `hermes plugins` picker is curses-based and can't
run inside the Ink UI. Users had to drop to a separate shell and run
`hermes plugins enable/disable`.

Add a PluginsHub overlay modeled on the existing SkillsHub:

- New gateway RPC `plugins.manage` (list + toggle) backed by the same
  disk-discovery + dashboard_set_agent_plugin_enabled primitives the CLI
  and dashboard already use, so all three surfaces agree on state. The
  toggle path also wires the plugin's toolset into platform_toolsets.
- `/plugins` with no arg opens the hub; any subcommand still falls
  through to the text slash worker for CLI parity.
- pluginsHub overlay state threaded through overlayStore / interfaces /
  useInputHandlers (Esc closes) / appOverlays (renders the FloatBox);
  preserved across turn teardown like other user-toggled overlays.
- Hub UI: arrow/number select, Enter/Space toggles live, Tab switches
  user-only vs all (bundled) scope, shows ✓/✗/○ activation glyphs.

plugins.manage added to _LONG_HANDLERS (disk + config I/O).
2026-06-09 10:50:13 -07:00
Austin Pickett b8eede7bda fix(cli): /plugins shows installed-but-not-enabled plugins
The /plugins slash command read from the live PluginManager, which only
knows about *loaded* plugins. A freshly-installed plugin that hadn't been
enabled yet showed 'No plugins installed. Drop plugin directories into
~/.hermes/plugins/' — even though it was on disk and a valid plugin.

Switch to the same disk-discovery path as 'hermes plugins list'
(_discover_all_plugins + enabled/disabled sets + _plugin_status), so an
installed plugin now appears with its activation state ([not enabled],
enabled, or disabled) plus the exact enable command.

Default the quick /plugins view to user-installed plugins and summarize
bundled providers/platforms on one line (the full catalog stays behind
'hermes plugins list') so the output isn't drowned by 60+ bundled
provider plugins.
2026-06-09 10:49:43 -07:00
Teknium 967c325da8 fix(models): read OpenRouter live context_length before hardcoded catch-all (#42986)
OpenRouter-routed slugs that are absent from models.dev (e.g. a freshly
shipped anthropic/claude-fable-5) fell through to the generic
DEFAULT_CONTEXT_LENGTHS["claude"]=200K entry and under-reported their real
1M window. The step-6 OpenRouter live-metadata fallback was gated on
`not effective_provider`, but an OpenRouter selection sets
effective_provider="openrouter" (inferred from the base URL), so that
branch was dead code for every OR model.

Add a dedicated step-5 OpenRouter branch that consults the live /models
catalog (authoritative, refreshes as new slugs ship) before models.dev and
the hardcoded family defaults — mirroring the existing Nous/Copilot/GMI
branches. Keeps the Kimi-family 32k underreport guard. Per-model values are
respected (claude-haiku-4.5 stays 200K), so it does not blanket-bump to 1M.

Regression tests cover the fable-5 case, the genuinely-200k case, and the
Kimi guard.
2026-06-09 10:49:32 -07:00
TekniumandAustin Pickett f6f573ebaa feat(plugins): install from a subdirectory within a repo (#42963)
Support installing a plugin that lives in a subdirectory of a larger
repo (docs/tests at root, plugin in a subdir) without forcing a
dedicated single-plugin repo.

Identifier syntax:
  owner/repo/path/to/plugin        (shorthand + subpath)
  <url>.git/path/to/plugin         (.git boundary on GitHub-style URLs)
  <url>#path/to/plugin             (explicit fragment, any scheme)

_resolve_git_url now returns (git_url, subdir); _install_plugin_core
reads the manifest from and moves only the subdir, so root-level docs
and tests no longer leak into ~/.hermes/plugins. _resolve_subdir_within
guards against path traversal, missing dirs, and non-directories.

Both the CLI (hermes plugins install) and the dashboard install endpoint
inherit this for free since they share _install_plugin_core. Dashboard
install hint + placeholder updated to advertise the subdir syntax.

Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
2026-06-09 13:42:51 -04:00
Teknium ff9c110d5a feat(models): add anthropic/claude-fable-5 to openrouter + nous curated lists (#42979)
Adds the model above claude-opus-4.8 in both the OpenROUTER_MODELS and
_PROVIDER_MODELS['nous'] curated picker lists used by /model and
`hermes model`. Regenerated website/static/api/model-catalog.json to match.
2026-06-09 10:20:37 -07:00
brooklyn! c4811c382f fix(desktop): pad app icon to Apple grid so dock size matches peers (#42946)
* fix(desktop): pad app icon to Apple grid so dock size matches peers

The icon body filled ~92% of the canvas; macOS adds no padding, so it
rendered larger than other dock icons. Normalize to Apple's grid (~824px
body on a 1024px canvas) and ship a reproducible generator.

- regenerate icon.png/.icns/.ico with ~80% body + transparent margins
- keep original art as icon-source.png (master)
- add scripts/gen-app-icon.cjs + `npm run icons` (idempotent)

* chore(desktop): drop one-shot icon generator, ship only the assets

The regenerated icon.png/.icns/.ico are the deliverable; the padding
rationale lives in the PR. No build infra needed for a one-off.

* fix(desktop): pad apple-touch-icon — the actual runtime dock icon

app.dock.setIcon() overrides the bundle .icns at runtime with
public/apple-touch-icon.png, so the dock icon users see while the app
runs came from that (1254px canvas, ~91% full-bleed body). Normalize it
to the same Apple grid (824px body on 1024px canvas). Also covers the
web favicon + onboarding logo that reference the same file.
2026-06-09 11:48:26 -05:00
Gille c6dc2fcd21 fix(desktop): release profile backends before delete (#42613) 2026-06-09 10:52:02 -05:00
liuhao1024 f6416f50fc fix(deps): bump urllib3 and PyJWT to clear CVEs (#40179)
* fix(deps): bump urllib3 and PyJWT to clear CVEs

urllib3 2.6.3 → 2.7.0: fixes GHSA-mf9v-mfxr-j63j (decompression-bomb
bypass in streaming API) and GHSA-qccp-gfcp-xxvc (sensitive headers
forwarded across origins in proxied redirects).

PyJWT 2.12.1 → 2.13.0: fixes PYSEC-2026-175/177/178/179.

Note: python-multipart and idna are already at patched versions in
uv.lock (0.0.27 and 3.15 respectively).

Fixes #40176

* fix(deps): add upper bound for urllib3 dependency spec

Add '<3' ceiling to urllib3 specifier to satisfy the PyPI dependency
upper bounds CI check. Per CONTRIBUTING.md policy, all PyPI deps must
use '>=floor,<next_major' pinning.
2026-06-09 11:19:05 -04:00
Philip D'SouzaandPhilipAD 92dfd70d6a fix(photon): production hardening for the gRPC-native iMessage channel (#42732)
* fix(photon): override transitive CVEs in the sidecar deps

`npm audit` flagged 7 high-severity transitive CVEs (protobufjs code injection
GHSA-66ff-xgx4-vchm + outdated @opentelemetry OTLP exporters) pulled in via
spectrum-ts -> @photon-ai/otel. npm's suggested fix downgrades spectrum-ts to a
version that targets the decommissioned spectrum host, so instead pin patched
versions via `overrides` (protobufjs 8.6.1, @opentelemetry/* 0.218.0) without
touching spectrum-ts. `npm audit` -> 0; spectrum-ts + provider still import.

* fix(photon): harden the sidecar bridge + bound the dedup cache

- constant-time sidecar control-token comparison (was `!==`, timing-attackable).
- cap the control-channel request body (2 MiB) so a compromised local peer can't
  OOM the sidecar.
- wrap the inbound gRPC stream consumer in a re-subscribe loop with capped
  exponential backoff + jitter — if the async iterator throws/ends it would
  otherwise stop inbound forever (the adapter dedupes any replay).
- add an unhandledRejection handler so a stray rejection logs instead of killing
  the process.
- dedup cache (adapter) was a true bounded LRU only for expired entries; a burst
  of unique ids within the window grew it without limit. Evict oldest at the cap.

* chore: add AUTHOR_MAP entry for PhilipAD

---------

Co-authored-by: PhilipAD <philipadsouza@gmail.com>
2026-06-09 11:12:58 -04:00
Brian D. Evansandbriandevans b5421f4ba6 fix(deps): declare packaging as a core dependency so it ships everywhere (#40522)
* fix(deps): declare packaging as a core dependency so it ships everywhere

packaging is imported directly on three production paths but was never
declared in [project.dependencies], so it only reached users transitively
(pip/uv pull it for other tools). The slim official Docker image ships
without it, where each try/except-ImportError fallback silently degrades:

- plugins/memory/hindsight/__init__.py (_meets_minimum_version) returns
  False when packaging is absent, disabling update_mode='append' so every
  session leaks separate Hindsight documents (the reported #40503 symptom).
- tools/lazy_deps.py (_is_satisfied) falls back to "installed counts as
  satisfied", defeating every version-constraint check on lazy extras.
- hermes_cli/main.py drops to naive name==version requirement parsing.

Promote it to a declared core dep pinned to packaging==26.0 — the exact
version already resolved in uv.lock, so there is zero resolution churn (the
lock change is two edge annotations marking it transitive->direct). It is a
pure-Python py3-none-any wheel with no compiled extensions, safe to ship on
every platform. Declaring it also wires it into the
_verify_core_dependencies_installed() update-repair guard, which reinstalls
missing [project.dependencies] on hermes update.

Adds a hermetic tomllib-parse regression test that fails before the
declaration and passes after.

Fixes #40503

* test(deps): make packaging dep-name extraction PEP 508-robust

Address Copilot review on #40522: the inline name-extraction only handled
==, >=, [ and ; and could mis-parse valid requirement strings using <=, ~=,
!=, <, > or a direct reference (name @ url). Factor a _distribution_name
helper that drops markers, direct-reference URLs and extras, then strips any
version operator via regex, so a future dep declared with any PEP 508
specifier shape is matched correctly.

---------

Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
2026-06-09 11:11:48 -04:00
brooklyn! d046169646 fix(desktop): local-only recents, per-platform sidebar sections, and Ctrl+N regressions (#42537)
* fix(desktop): keep chat recents focused and reset hotkey target

Exclude messaging platform threads from chat recents pagination so Load More returns chat sessions, and clear stale quick-create profile state before Ctrl+N starts a new session.

* fix(desktop): surface new sessions in sidebar + unstick new-chat Thinking

Two renderer regressions in the desktop chat app:

- Sidebar ordering: orderByIds/reconcileOrderIds appended ids missing from
  the persisted order to the BOTTOM. Callers pass recency-sorted lists
  (newest first), so a brand-new Ctrl+N session sank below the saved order
  and read as "my latest session never showed up". Prepend fresh ids so new
  activity surfaces at the top.

- New-chat stuck on "Thinking": terminal/attention state transitions
  (turn finished, error, or agent now waiting on user) were RAF-batched.
  Electron throttles requestAnimationFrame to ~0 while the window is
  backgrounded, occluded, or unfocused, stranding the deferred flush. Flush
  critical transitions (!busy || needsInput) synchronously; keep the busy
  heartbeat RAF-batched to avoid scroll churn.

Does not touch the messaging-source exclusion in chat recents queries.

* fix(desktop): stop excluding messaging platforms from chat recents

The "keep chat recents focused" change excluded every messaging-platform
source (telegram, discord, slack, …) from the recents query. That silently
undid the messaging-source-folder feature already on main (ede4f5a4a): the
sidebar builds those folders purely from the loaded recents page, so once the
sources were filtered out the folders never rendered — telegram and friends
vanished from the left sidebar.

Only cron stays excluded (it has its own dedicated section). Messaging
sessions belong in the sidebar and render with their platform folder/icon.
Removes the now-unused MESSAGING_SESSION_SOURCE_IDS export.

* fix(desktop): give each messaging platform its own self-managed sidebar section

Recents are local-only again: cron and every messaging platform are excluded
from the chat-recents query, so "Load more" pages through interactive local
chats instead of interleaving gateway threads that bury them.

Each messaging platform (telegram, discord, ...) is now fetched as its own
slice (refreshMessagingSessions) and rendered as a self-managed sidebar
section with its platform icon, count, and per-platform "load more" — no
source-grouping magic inside recents.

Handed-off sessions (live source becomes local after a handoff) keep their
origin-platform badge on the row via handoff_platform, so a Telegram thread
continued in the desktop still reads as Telegram.

* fix(desktop): self-heal a stranded routed session in route-resume

An intermittent create/stream race can leave selected/active session ids
null while the route stays on /:sid — the transcript then sticks empty
even though the turn completed and persisted (the "second Ctrl+N shows no
response" symptom). The pathname didn't change, so route-resume's normal
gate skipped and the view stayed stuck.

Resume whenever the routed session isn't the loaded one, gated on
freshDraftReady so the /:sid -> /new transition (which also momentarily
nulls selected/active a render before the pathname flips) is NOT treated
as stranded. selectedStoredSessionIdRef is set synchronously at resume
entry, so this can't loop, and the resume cached fast-path restores the
already-streamed messages without a refetch.

* fix(desktop): bypass smooth reveal on primary markdown stream

Render main assistant text through deferred markdown directly instead of the smooth-reveal wrapper. This isolates the wrapper to reasoning surfaces and avoids the intermittent blank-response regression after consecutive new-session flows.
2026-06-09 14:24:25 +00:00
xxxigm 57775e9e16 test(agent): cover char-based output-cap overflow parsing (#42741)
Add TestParseCharBasedOutputCap for the LM Studio / llama.cpp phrasing
(context in tokens, prompt in characters): the reported error resolves to
the available output budget, the retried cap plus the estimated input
stays inside the window, and a prompt larger than the window falls through
to None so the prompt-too-long/compression path still owns that case.
2026-06-09 03:17:12 -07:00
xxxigm 3a74b75217 fix(agent): recover from char-based output-cap overflow (#42741)
LM Studio / llama.cpp-style servers report the context window in tokens
but the prompt size in characters, e.g. "maximum context length is 65536
tokens. However, you requested 65536 output tokens and your prompt
contains 77409 characters". When a provider profile's default_max_tokens
equals the model's context window, the very first request asks for the
whole window as output and the server returns a hard HTTP 400 — even on a
trivial "hi".

parse_available_output_tokens_from_error did not recognise this phrasing,
so the overflow was misrouted to the prompt-too-long/compression path
(which can't help when the input already fits) instead of the output-cap
reduction + retry path. Detect the "requested N output tokens" form,
estimate the input from the character count (~3 chars/token, conservative
so the retried cap stays inside the window), and return the available
output budget so the existing retry logic shrinks max_tokens and succeeds.
2026-06-09 03:17:12 -07:00
teknium1 24a934295f test(yuanbao): add missing patch import to pipeline tests
The salvaged refactor's new tests use unittest.mock.patch (25 call sites)
but the import line only brought in AsyncMock and MagicMock, so 10 of the
new tests failed with NameError. Add patch to the import.
2026-06-09 03:17:00 -07:00
loongzhao ffcd9d7ac7 refactor(yuanbao): consolidate media resolution into dedicated pipeline middlewares 2026-06-09 03:17:00 -07:00
teknium1 be2f739e9a test(desktop): cover sleep/wake session recovery in use-prompt-actions
Adds three vitest cases for the recovery path: resume+retry on
"session not found", no-resume passthrough on other errors, and
no-resume when there is no stored session id. Also maps the
contributor's commit email in release.py AUTHOR_MAP.
2026-06-09 03:16:59 -07:00
Brian Pasquini 72f522d464 fix(desktop): recover session after sleep/wake gateway restart
When the laptop sleeps and wakes, the WebSocket reconnects but the
gateway's in-memory session table is cleared. The desktop app still
holds the old activeSessionId, so the next prompt.submit call returns
error 4001 ('session not found'), surfaced to the user as:
  'Prompt failed: session not found'

Fix: wrap prompt.submit in a try/catch. On 'session not found', call
session.resume with the durable SQLite session ID (selectedStoredSessionIdRef)
to re-register the session in the gateway, update activeSessionIdRef to
the fresh live session_id, then retry prompt.submit once.

If recovery fails or the error is unrelated, the original error is
re-thrown and surfaces normally.
2026-06-09 03:16:59 -07:00
JP Lew cb4cc08b0a fix(codex): record app-server token usage in session accounting 2026-06-09 02:46:04 -07:00
kshitijandmnajafian-nv 85852b71d8 fix(nemo-relay): preserve downstream errors in adaptive execution (#42691)
Based on #42658 by @mnajafian-nv.

Preserves the real downstream provider/tool exception when NeMo Relay's
managed adaptive execution wraps a failing callback as an internal runtime
error. Without this, the original exception (and its retry-classification
signal, e.g. status_code) is lost behind Relay's wrapper.

Salvage changes on top of the original PR:

- Tolerant Relay-wrapper match: _is_relay_wrapped_callback_error now uses
  str.startswith on the "internal error: <cls>: <msg>" prefix instead of
  exact equality, so a future Relay version appending a traceback/suffix
  doesn't silently defeat the unwrap. On a total format change it returns
  False and falls back to the pre-fix behavior (surfacing Relay's error)
  rather than masking it.
- Deduplicated the LLM and tool execute paths into a shared
  _run_managed_with_downstream_preservation helper, removing ~20 lines of
  copy-pasted nonlocal/try-except scaffolding that could drift out of sync.
- Added a real-middleware regression guard
  (test_nemo_relay_downstream_unwrap_matches_real_middleware_wrapper_shape)
  that drives hermes_cli.middleware._run_execution_chain and asserts the
  plugin's _original_downstream_error unwraps the actual private
  _DownstreamExecutionError wrapper. The original synthetic tests modeled the
  wrapper with a local class, so a rename or shape change in core middleware
  would not have been caught; this test fails loudly if that contract drifts.

Co-authored-by: mnajafian-nv <mnajafian@nvidia.com>
2026-06-09 02:31:10 -07:00
Teknium 8d99b5bc4f fix(gateway): cap terminal code-block preview in non-verbose mode (#42729)
The markdown code-block change rendered args['command'] in full in both
verbose AND non-verbose (all/new) modes, so a long or multi-line terminal
command bypassed the tool_preview_length cap (default 40) and rendered as
a huge block. Non-verbose now collapses to a single line capped at the
preview length while keeping the fence; verbose keeps the full command.
2026-06-09 02:28:47 -07:00
kshitijandy0shua1ee a38cc69bcc fix(terminal): complete sane PATH entries on POSIX (salvage of #35614) (#42653)
* fix(terminal): complete sane PATH entries on POSIX

Fixes macOS gateway/launchd terminal sessions whose PATH already
includes /usr/bin while omitting Apple Silicon Homebrew paths.
LocalEnvironment._make_run_env() now appends each missing _SANE_PATH
entry individually on POSIX, preserving caller precedence and avoiding
duplicate sane entries.

Root cause: the previous logic used /usr/bin as the sentinel for sane
PATH injection. macOS launchd commonly provides /usr/bin while leaving
out /opt/homebrew/bin and /opt/homebrew/sbin, so Homebrew-installed
CLIs stayed unavailable in terminal tool calls.

Salvaged from #35614 by @y0shua1ee. Fixes #35613.

Co-authored-by: y0shua1ee <104712437+y0shua1ee@users.noreply.github.com>

* test(terminal): harden sane PATH completion against dup/empty entries

Follow-up to the #35613 fix. Strengthens _append_missing_sane_path_entries:

- De-duplicate the caller-supplied PATH (first occurrence wins) so a PATH
  that already contains duplicate entries is collapsed rather than carried
  through. Previously only newly-appended sane entries were guarded against
  duplication; pre-existing caller duplicates were preserved verbatim.
- Drop empty PATH entries (leading/trailing/double ':'), which POSIX shells
  interpret as the current working directory — a mild foot-gun in a
  default terminal environment.

Behaviour for well-formed PATHs (no duplicates, no empty entries) is
byte-identical to before; only malformed/duplicated inputs change.

Adds regression tests for: the literal macOS launchd PATH
(/usr/bin:/bin:/usr/sbin:/sbin), caller-duplicate collapsing with
order preservation, and empty-entry stripping.

* docs(terminal): clarify PATH normalisation semantics; drop dead set add

Addresses review findings on the sane-PATH completion follow-up:

- Sharpen the _append_missing_sane_path_entries docstring to state
  explicitly that on POSIX the caller PATH is rewritten (empty entries
  stripped, duplicates collapsed) rather than merely appended to, and
  that well-formed PATHs remain byte-identical bar the appended sane
  entries. This makes the intentional semantic change visible rather
  than buried under "hardening".
- Document why _path_env_key is a deliberate second Windows guard
  distinct from the helper's early return (key-casing selection vs
  standalone safety), so neither is mistaken for redundant and removed.
- Drop the dead `seen.add(entry)` in the sane-entry loop: _SANE_PATH is
  a static duplicate-free constant, so the membership check against the
  caller entries is sufficient and `seen` is never read afterwards.

No behaviour change: verified byte-identical output across the launchd,
minimal, empty, duplicate, empty-entry and already-full cases, and
re-confirmed gh/brew resolve through the real LocalEnvironment.execute()
path under a launchd-style PATH. 133 targeted tests pass.

Intentionally NOT consolidating with tools/browser_tool._merge_browser_path:
it prepends (vs append), filters on os.path.isdir, uses os.pathsep, and
draws from a dynamic candidate set — a shared helper is a separate
refactor, out of scope for this bugfix.

---------

Co-authored-by: y0shua1ee <104712437+y0shua1ee@users.noreply.github.com>
2026-06-09 02:21:12 -07:00
kshitij 76f89d66de fix(test): track TERMINAL_CONFIG_ENV_MAP after env-sync consolidation (#42695)
`test_terminal_config_env_sync.py::_save_config_env_sync_keys()`
AST-scanned `hermes_cli/config.py:set_config_value` for a
`_config_to_env_sync = {...}` literal. The terminal-config env bridging
was consolidated onto the canonical `TERMINAL_CONFIG_ENV_MAP` (now read
via `terminal_config_env_var_for_key()`), so that literal no longer
exists and the scanner raised:

    AssertionError: Could not find `_config_to_env_sync = {...}` literal in source

failing 8 of 9 tests on main for every PR.

Read the live `TERMINAL_CONFIG_ENV_MAP` instead — the actual source of
truth `set_config_value` bridges through — mirroring its `terminal.cwd`
exclusion. Refresh the stale module docstring and the now-incorrect
error-message hints that still referenced `_config_to_env_sync`.

Verified: the suite goes green, and a mutation (dropping `docker_volumes`
from `TERMINAL_CONFIG_ENV_MAP`) still trips the pinned regression test,
so the drift guard retains its teeth.
2026-06-09 02:11:46 -07:00
helix4u f8adefdebf fix(tui): apply terminal backend config before launch 2026-06-09 00:31:27 -07:00
teknium1andLeonSGP43 dbbd1d4d05 feat(desktop+gateway): remote-gateway file attachments via file.attach
@file: attachments now work when the desktop is connected to a remote
gateway. Previously a referenced file resolved to a client-disk path the
gateway couldn't see, so context_references rejected it with "path is
outside the allowed workspace" and the agent never saw the file.

Adds a file.attach RPC (sibling to the existing image.attach_bytes /
pdf.attach byte-upload pipeline): the desktop uploads the file bytes, the
gateway stages them into <workspace>/.hermes/desktop-attachments/ and
returns a workspace-relative @file: ref that resolves cleanly. Local mode
passes the path directly; a gateway-visible file outside the workspace is
copied in; an in-workspace file is referenced as-is with no copy.

Consolidates the file-sync design from #38615 (LeonSGP43) and the
host-file-staging idea from #33455 (Carry00), rebased onto the
image/PDF remote-media helpers already on main.

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
2026-06-09 00:03:49 -07:00
Teknium e687292eb4 feat(models): persist Nous recommended-models to disk; fall back on Portal failure (#42628)
The Portal's /api/nous/recommended-models endpoint is the source of truth for
which models are free/paid right now, but its result was cached in-process
only. When the live fetch failed (network, parse, non-2xx), the function
returned {} and the model picker silently dropped the free/paid
recommendations — free models would vanish with no indication anything went
wrong.

Add a per-base disk cache at $HERMES_HOME/cache/nous_recommended_cache.json:
a successful live fetch is persisted as last-known-good, and a failed fetch
with an empty in-process cache falls back to the disk copy instead of {}.
Self-heals on the next successful fetch. With no disk copy, still degrades to
{} (callers already handle that). Keyed by portal base URL so staging/prod
don't collide.

E2E: live fetch writes disk; simulated Portal failure returns the cached free
models from disk; no-disk + failure returns {}.
2026-06-09 00:03:43 -07:00
Teknium c4066091ca feat(models): add laguna-m.1 + nemotron-3-ultra to curated OpenRouter list (#42629)
Two new free-tier slugs surfaced in /model and `hermes model`. owl-alpha
was already present. Regenerated website/static/api/model-catalog.json to
keep the manifest sync test green.
2026-06-08 23:05:35 -07:00
Teknium 50ad191a8b test(hermes_cli): harden concurrent-gate fixture against partial-import race (#42626)
The autouse _suppress_concurrent_hermes_gate fixture did
monkeypatch.setattr(main, '_detect_concurrent_hermes_instances', ...) with
no raising=False. Its try/except guards the import but not the setattr, so
under pytest's per-test spawn isolation a transiently partial hermes_cli.main
module (one a concurrent worker is mid-importing) made setattr raise
AttributeError and errored unrelated tests in the slice.

Add raising=False so a transiently-absent attribute is a no-op default rather
than a hard error. The attribute always exists once main.py finishes
importing; the real-function opt-out (@pytest.mark.real_concurrent_gate) is
unaffected.
2026-06-08 22:54:25 -07:00
teknium1 520b59db16 fix(tui): use canonical get_fallback_chain for parity + map author
Follow-up to the salvaged fallback-chain fix:
- Replace the hand-rolled fallback loader with the shared
  hermes_cli.fallback_config.get_fallback_chain() helper so the TUI path
  matches HermesCLI and gateway/run.py exactly: fallback_providers stays
  first and keeps order, with distinct legacy fallback_model entries
  merged in after (deduped). Previously the TUI loader picked one key OR
  the other, diverging from CLI/gateway when both were set.
- Update the test to assert the merged canonical semantics.
- Add psionic73 to scripts/release.py AUTHOR_MAP (CI gate).
2026-06-08 22:53:42 -07:00
psionic73 4b073d0906 fix(tui): preserve fallback provider chain 2026-06-08 22:53:42 -07:00
underthestars-zhy dbf2470d46 feat(photon): Add voice message support to Photon adapter
Extend the sidecar and Python adapter to handle `voice` content
alongside `attachment`. Voice notes are inlined as base64 (same
size-cap logic), surfaced as `MessageType.VOICE`, and include an
optional `duration` field in fallback markers when bytes are
unavailable.
2026-06-08 22:53:01 -07:00
underthestars-zhy 9fb83eaa2f fix(photon): bump spectrum-ts to ^1.18.0 and always install latest on
setup
2026-06-08 22:53:01 -07:00
underthestars-zhy 0337658904 fix(photon): migrate user API calls to Spectrum backend
Switch `list_users`, `find_user_by_phone`, `create_user`,
`register_user_if_absent`, and `refresh_user_numbers` from the
Dashboard API (Bearer token) to the Spectrum API (Basic auth with
project credentials). Update response unwrapping to handle the nested
`data.users` envelope returned by Spectrum, add `_spectrum_host()`
resolver, `_basic()` header helper, and structured error helpers.
Update tests, docs, and plugin.yaml accordingly.
2026-06-08 22:53:01 -07:00
underthestars-zhy b58ff93459 feat(photon): persist and display user phone numbers in status
Store operator and assigned iMessage numbers in `auth.json` after
setup, and surface them in `hermes photon status`. When numbers are
missing, status auto-refreshes from the dashboard without provisioning
new lines.
2026-06-08 22:53:01 -07:00
underthestars-zhy 2130ef68b3 fix(photon): Enable group flattening in Spectrum config 2026-06-08 22:53:01 -07:00
underthestars-zhy 637cf94bed fix(photon): strip markdown and add send retry logic 2026-06-08 22:53:01 -07:00
Teknium 9351cbafab fix(gateway): auto-deliver image_generate output as native media (#42616)
image_generate returns its artifact as JSON ({"image": "/abs/path.png"})
with no MEDIA: tag, so the gateway auto-append path (which only recognized
text_to_speech MEDIA: tags) never delivered it — image delivery silently
depended on the model restating the path in its reply. Add image_generate to
the producer allowlist and extract the local path from its JSON result
(host_image > image > agent_visible_image), reusing the existing
extension-anchored matcher and history-dedupe so remote URLs, unknown
extensions, failures, and already-sent paths are rejected.

Closes the remaining unfixed path from #19105.
2026-06-08 22:51:03 -07:00
teknium 18ead88273 test: update docker preflight assertion for stdin=DEVNULL kwarg
The blanket stdin=subprocess.DEVNULL pass added the kwarg to the docker
'version' preflight call; the test pinned the exact kwargs dict. Update
the expected dict to match.
2026-06-08 22:46:57 -07:00
teknium dba6380ca6 test: guard OAuth setup-token stays interactive + marker exemption
Regression tests for the salvage follow-up: the interactive 'claude
setup-token' login must keep inherited stdin, and the guard's inline
'noqa: subprocess-stdin' marker must exempt a call.
2026-06-08 22:46:57 -07:00
teknium ba622d44e4 chore(release): add AUTHOR_MAP entry for m4dni5 2026-06-08 22:46:57 -07:00
teknium 2c1aaa9cba fix: keep interactive OAuth setup-token inheriting stdin
The blanket DEVNULL pass muzzled run_oauth_setup_token()'s interactive
'claude setup-token' login, which needs inherited stdin to prompt the
user. Revert that one call and replace the guard's brittle file:line
whitelist with an inline 'noqa: subprocess-stdin' marker that travels
with the code.
2026-06-08 22:46:57 -07:00
m4dni5 8bb60ff039 test: add pytest guard for subprocess stdin= in TUI-context code
Wraps scripts/check_subprocess_stdin.py as a pytest so CI catches
regressions when new subprocess calls are added without stdin=.
2026-06-08 22:46:57 -07:00
m4dni5 bddab61bcb ci: add subprocess stdin= regression check for TUI-context code
scripts/check_subprocess_stdin.py scans agent/, tools/, plugins/, and
tui_gateway/ for subprocess.run() and subprocess.Popen() calls that
don't explicitly set stdin=. Missing stdin= means the child inherits the
parent's fd, which in TUI mode is the JSON-RPC pipe — causing gateway
crashes on stdin EOF.

Exits 0 (pass) or 1 (violations found). Can be run manually or added to
CI. Skips comments, docstring references, and calls that use input= (which
creates its own pipe).

Usage: python scripts/check_subprocess_stdin.py
2026-06-08 22:46:57 -07:00
m4dni5 d1f23bb2d5 fix: prevent TUI gateway stdin EOF crash across all TUI-context subprocess calls
When Hermes runs in TUI mode, the gateway child process communicates with
the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that
inherit this stdin fd can trigger a race condition where the child's stdin
read returns EOF, causing the gateway to exit cleanly (exit code 0) mid-tool-
execution.

This is the same root cause as issue #14036 (byterover plugin) and PR #39257
(SSH environment backend). This commit applies the fix — stdin=subprocess.DEVNULL
— to all 85 subprocess.run() and subprocess.Popen() calls that execute inside
the TUI gateway child process.

Scope: TUI-context code only (agent/, tools/, plugins/, tui_gateway/server.py).
CLI code (cli.py, hermes_cli/), tests, scripts, and gateway process management
are excluded — they don't run inside the TUI child and inherit the terminal's
stdin, not the JSON-RPC pipe.

85 call sites across 28 files. All files pass syntax check.
2026-06-08 22:46:57 -07:00
Teknium 54318c65b0 feat(models): seed model-catalog disk cache from checkout on update (#42614)
hermes update pulls the latest repo, so the freshly-pulled
website/static/api/model-catalog.json is already the newest catalog. Copy
it straight over ~/.hermes/cache/model_catalog.json instead of relying on a
network fetch (which can be Vercel bot-gated or hit a Portal hiccup and
silently degrade the picker to a stale/short list).

Adds seed_cache_from_checkout() in model_catalog.py (read shipped manifest,
validate, atomic write via _write_disk_cache, reset in-process cache) and
calls it from both update paths in main.py: _cmd_update_impl (git pull) and
_update_via_zip (Docker/no-git). Non-fatal on missing/malformed/invalid
files — the normal network refresh still applies on next picker open.
2026-06-08 22:31:06 -07:00
xxxigm c1927d2342 fix(desktop): set tsconfig lib/target to ES2023 for findLast/findLastIndex
The desktop code uses Array.prototype.findLast (chat/composer/index.tsx) and
findLastIndex (session/hooks/use-session-actions.ts), which are ES2023 APIs,
but tsconfig declared only the ES2022 lib. Some TypeScript builds tolerate this,
but a correct/stricter tsc fails the desktop build with:

  TS2550: Property 'findLast' does not exist on type 'ChatMessage[]'.
  Do you need to change your target library? Try changing 'lib' to 'es2023'.

Declare es2023 so the build is correct regardless of the resolved TypeScript
version (reported on Windows with Node 24).

Refs #38970
2026-06-08 22:14:28 -07:00
Teknium 3705625b74 feat(gateway): render terminal commands as bare fenced code blocks in chat (#42576)
Terminal tool progress on markdown-capable gateways (Telegram, Slack,
Discord, WhatsApp, Matrix, Weixin, Feishu) renders the full command in a
fenced code block again, in all/new AND verbose modes — gated on the
adapter's supports_code_blocks capability. Plain-text platforms keep the
short truncated preview.

No language tag is emitted: Slack mrkdwn renders a '```bash' fence with
'bash' as a literal first code line, so a bare '```' fence is used, which
renders correctly on every platform that supports blocks.

This restores the #41215 feature (removed in #41950 due to the command
showing in group chats) as the default. For a personal assistant the
command display is desired; the group-chat concern is a preference, not a
vulnerability.
2026-06-08 21:19:05 -07:00
teknium1 3dcfbbfc49 chore(release): add underthestars-zhy to AUTHOR_MAP
Salvage follow-up for PR #42444 — maps the contributor's commit email
so the changelog generator can attribute the Photon gRPC channel work.
2026-06-08 21:03:58 -07:00
underthestars-zhy 3b983e7791 fix(photon): add home channel env seed and simplify space resolution 2026-06-08 21:03:58 -07:00
underthestars-zhy 0d25cae041 fix(photon): remove reply-to support and fix typing API
Drop `replyTo` from all outbound send paths and update the `/typing`
endpoint to use the documented `typing("start" | "stop")` content
builder. Adds a `stop_typing` method on the adapter to pair with
`send_typing`.
2026-06-08 21:03:58 -07:00
underthestars-zhy e79e44af79 fix(photon): use spectrum-ts reply builder for threaded messages
Replace raw `{ replyTo }` send options with the `spectrumReply` content
builder from spectrum-ts, which is the correct API for threading
replies.
Adds `maybeReplyContent` helper with graceful fallback to normal send
when
the reply target cannot be resolved.
2026-06-08 21:03:58 -07:00
underthestars-zhy fdf48c63c8 fix(photon): wrap text sends with spectrumText helper 2026-06-08 21:03:58 -07:00
underthestars-zhy 0646656884 fix(photon): support E.164 and DM GUID targets for home channel
Allow PHOTON_HOME_CHANNEL to accept a bare E.164 phone number or a
`any;-;+1...` DM chat GUID in addition to a Spectrum space id. Inbound
DM spaces are cached so replies resolve without a second SDK lookup,
and `photon` is added to _PHONE_PLATFORMS so send_message treats E.164
strings as explicit targets rather than falling through to channel-name
resolution.
2026-06-08 21:03:58 -07:00
underthestars-zhy 92179352fb feat(photon): auto-configure allowlist and cron channel on setup
During `hermes photon setup`, allowlist the operator's number and set
their DM as the cron home channel when those env vars are unset. Without
this, the gateway denies the operator's own messages and cron has no
default delivery target. Re-runs never overwrite hand-tuned values.

Also teaches the sidecar's `resolveSpace` to accept a bare E.164 number
as a space identifier, resolving it to the user's DM space so
`PHOTON_HOME_CHANNEL` can be set to a phone number instead of an opaque
space id.
2026-06-08 21:03:58 -07:00
underthestars-zhy e9b26c7c8b style(photon): Colorize iMessage number box in setup output 2026-06-08 21:03:58 -07:00
underthestars-zhy 84e4b4b9a5 fix(photon): use per-user assigned line for agent iMessage number
On shared-number plans, `/lines` has no dedicated entry, so the
`assignedPhoneNumber` field on the user object is the source of truth
for which number to text the agent. Fall back to the line inventory
only when no per-user assignment exists.
2026-06-08 21:03:58 -07:00
underthestars-zhy 314af28e86 feat(photon): download and inline inbound attachments 2026-06-08 21:03:58 -07:00
underthestars-zhy b3aef57f21 refactor(photon): use TYPE_CHECKING for httpx import and fix client ref 2026-06-08 21:03:58 -07:00
underthestars-zhyandClaude Opus 4.8 4e4d27875f feat(photon): gRPC-native iMessage channel (no webhook)
Make Photon iMessage a first-class persistent-connection channel like
Discord/Slack, using the spectrum-ts gRPC stream for both directions.

- Inbound: the sidecar forwards the SDK's app.messages gRPC stream to the
  adapter over a loopback GET /inbound (NDJSON) instead of webhooks. Drops
  the aiohttp webhook server, HMAC signature verification, public URL, and
  PHOTON_WEBHOOK_* config; adapter reconnects with backoff.
- Management plane: device login uses client_id=photon-cli against the
  single dashboard host (Bearer), matching the official photon-hq/cli;
  find-or-create "Hermes Agent" project, enable Spectrum, rotate secret,
  register user (with phone dedup), surface the assigned iMessage line.
- SDK projectId is the project's spectrumProjectId, not the dashboard id;
  runtime creds persist to ~/.hermes/.env like every other channel.
- CLI: 6-step setup, webhook subcommands removed.
- Tests/docs updated for the gRPC flow; sidecar pins spectrum-ts ^1.17.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:03:58 -07:00
teknium1 c3420d91ad chore: add jooray to AUTHOR_MAP for salvaged simplex PR #27978 2026-06-08 21:03:45 -07:00
Juraj Bednar 0c2e81df00 feat(simplex): groups, native attachments, text batching, auto-accept
Salvage of PR #27978 cherry-picked onto current main, resolving conflicts
with main's intervening SimpleX plugin fixes (resp-envelope normalization,
health-monitor reconnect-churn fix, bare-form DM addressing).

What's new:
- Group support via SIMPLEX_GROUP_ALLOWED (comma-separated IDs or '*');
  inbound items surface chat_id=group:<id> + chat_type=group. Disabled by
  default so a bot in a group doesn't process every member's traffic.
- Inbound files/voice via rcvFileDescrReady (immediate /freceive) deferred
  through _pending_file_transfers, replayed on rcvFileComplete. Voice notes
  -> MessageType.VOICE.
- Native outbound media: send_image (PNG/JPEG + inline thumbnail), send_voice
  (msgContent.type=voice), send_video, send_document. All addressed by numeric
  ID via /_send ... json [...].
- MEDIA:<path> tags in agent replies stripped and dispatched as voice/document.
- Text-burst batching (HERMES_SIMPLEX_TEXT_BATCH_DELAY, default 0.8s).
- Auto-accept contact requests (SIMPLEX_AUTO_ACCEPT, default true).
- Group send path uses structured /_send #<id> json form (the bracket
  #[<id>] form is parsed as display-name lookup and silently drops).

plugin.yaml bumped to 1.1.0; docs updated. All inside plugins/platforms/simplex/
- no core edits.

Co-authored-by: Juraj Bednar <juraj@bednar.io>
2026-06-08 21:03:45 -07:00
Ben BarclayandHermes Agent a46462ec65 fix(cli): persist custom --portal-url to .env on dashboard register (#42435)
* fix(cli): persist custom --portal-url to .env on dashboard register

`hermes dashboard register --portal-url <url>` resolved the custom portal
for the registration request but only persisted it to .env when the var was
absent AND non-default. So a user who re-registered against a different
portal (e.g. switching preview deploys) silently kept the stale
HERMES_DASHBOARD_PORTAL_URL, and an explicit request for the production
portal was never written at all.

Track whether a custom portal was *explicitly supplied* (--portal-url flag
or HERMES_DASHBOARD_PORTAL_URL env), separately from the resolved value:

  - explicit custom URL -> always persist (update in place via
    save_env_value, which overwrites the matching key rather than appending
    a duplicate), even when it equals the production default; no-op when it
    already matches.
  - no custom URL supplied -> unchanged conservative behaviour: only write an
    inferred portal when absent and non-default; never alter an existing
    entry unexpectedly.

save_env_value already preserves other lines/comments and dedups in place;
this only changes the decision of *when* to call it.

Adds TestCustomPortalPersistence covering all four cases.

Co-authored-by: Hermes Agent <agent@nousresearch.com>

* feat(cli): persist dashboard public URL from --redirect-uri on register

When the user registers a publicly-exposed dashboard with --redirect-uri
(the full OAuth callback, e.g. https://hermes.example.com/auth/callback),
derive its origin and persist it as HERMES_DASHBOARD_PUBLIC_URL — the env var
the dashboard auth layer actually consumes at serve time.

dashboard_auth/routes._redirect_uri reconstructs the callback as
HERMES_DASHBOARD_PUBLIC_URL + "/auth/callback" (verbatim), and
dashboard_auth/prefix.resolve_public_url reads that var (then config.yaml
dashboard.public_url) to decide the public origin. Previously --redirect-uri
was sent to the portal at registration but never persisted, so the operator
had to set HERMES_DASHBOARD_PUBLIC_URL by hand for the login gate to engage
and the callback to round-trip. We now wire it automatically.

Persist the ORIGIN (scheme://host[:port]), not the full callback path —
persisting the raw redirect would double the path when the runtime appends
/auth/callback. Mirrors the portal-url persistence semantics already in this
PR: always write an explicitly-derived value (updating in place, no
duplicate), no-op when it already matches, never written on a localhost-only
install (no --redirect-uri), and skipped for a non-http(s)/malformed redirect.

Verified end-to-end: cmd_dashboard_register writes the origin to .env, then
resolve_public_url() reads it back and public_url + /auth/callback
reconstructs exactly the originally-supplied --redirect-uri.

Adds TestPublicUrlPersistence (8 cases) incl. origin-derivation, port
preservation, update-in-place, no-op, no-flag, non-http skip, and
both-portal-and-public-url-persisted.

Co-authored-by: Hermes Agent <agent@nousresearch.com>

---------

Co-authored-by: Hermes Agent <agent@nousresearch.com>
2026-06-09 13:56:33 +10:00
helix4u b23184cad4 fix(api-server): bind request session context for tools 2026-06-08 20:52:08 -07:00
Ben Barclay 52ae9d9f02 feat(dashboard): make hermes dashboard register idempotent (#42455)
Re-running `hermes dashboard register` now updates the existing dashboard
record in nous-account-service instead of creating a duplicate.

The stable key is the client_id this install already persisted in
HERMES_DASHBOARD_OAUTH_CLIENT_ID on a prior run:
- No stored client_id -> first registration -> create a fresh client with an
  auto-generated name (unchanged behavior).
- Stored client_id present -> re-send it as `client_id` so the portal updates
  that row in place. Without an explicit --name, the name is omitted so the
  portal-stored name isn't churned to a new random value on every re-run.
- Prints "Updated dashboard" vs "Registered dashboard" based on whether the
  portal echoed back the same client_id. A stale/deleted id safely falls
  through to a fresh create server-side.

Requires the matching nous-account-service change (POST
/api/oauth/self-hosted-client accepting an optional client_id + optional name).

Tests: 7 new TestIdempotentRerun cases (key sent, name preserved/overridden,
Updated message, persisted id, stale-id fall-through, blank-id first-run);
existing create-path tests unchanged (23 pass).
2026-06-09 13:19:35 +10:00
brooklyn! 1e5ff4a577 fix(hermes-ink): disable mouse tracking on raw-mode teardown to stop SGR leak (#42527)
The raw-mode teardown path (rawModeEnabledCount -> 0) disabled
modifyOtherKeys, kitty keyboard, focus reporting, and bracketed paste,
then dropped raw mode and detached the readable listener -- but left DEC
mouse tracking (1000/1002/1003/1006) asserted. With raw mode off and no
reader attached, the terminal falls back to cooked-mode echo, so every
mouse move emits a hover report (DEC 1003) that prints as literal text:
a flood of '35;col;row M' shards over the prompt in a long session.

handleSuspend() already guards against exactly this (it writes
DISABLE_MOUSE_TRACKING before SIGSTOP); the ordinary teardown path
missed the same guard. Add DISABLE_MOUSE_TRACKING to the teardown, and
re-assert tracking on raw-mode re-entry (via the Ink instance's
reassertTerminalModes, which is gated on altScreenActive and idempotent)
so a transient drop->re-add round-trips cleanly instead of silently
leaving the mouse dead.

Adds a regression test driving a real Ink mount: the last raw-mode
consumer detaching must emit DISABLE_MOUSE_TRACKING.

Reported via a community bug report.
2026-06-08 21:31:06 -05:00
Jeffrey Quesnelle 6a8dda171c Merge pull request #42515 from NousResearch/fix/desktop-debug-report-links
fix(desktop): render debug-report paste URLs as real clickable links
2026-06-08 22:19:17 -04:00
emozilla e0f6a35ac6 fix(desktop): render debug-report paste URLs as real clickable links
System messages (slash-command output like /debug, plus the generic
system-message fallback) were rendered as plain text, so the uploaded
paste.rs URLs in a debug report were neither clickable nor easily
copyable.

Route both through LinkifiedText so URLs become real <a> links (open
externally via the desktop bridge, selectable/copyable text). Add an
opt-in explicitOnly mode that matches only explicit http(s):// / www.
URLs, used here so filename-shaped tokens in the report (agent.log,
errors.log, gateway.log) aren't mistaken for bare domains and linkified.
Bare-domain matching is preserved for all other LinkifiedText callers.

Adds regression tests covering explicitOnly (links only real URLs, keeps
.log filenames as text) and the default bare-domain behavior.
2026-06-08 21:35:21 -04:00
teknium1 b5f8996ccc test(cli): exercise real _prompt_text_input for native-Windows confirm deadlock
The existing #33961 tests mock _prompt_text_input away, so they only assert
modal-vs-stdin routing — they cannot observe the actual hang. Add a guard
class that drives the real helper chain with a blocking input() on a win32
daemon thread and asserts the worker never hangs. Fails on the pre-#33961
code (win32 -> _prompt_text_input -> off-main input() -> deadlock), passes
on the modal path. Also covers the scheduling-failure degraded branch
(must clean-cancel to None, never call input()).
2026-06-08 15:53:28 -07:00
firefly 714183530b test(cli): convert stale win32 stdin-fallback tests to the modal contract
The four win32 tests asserted the old deadlocking behavior (win32 -> raw
input()). Rewrite them to the corrected contract: native Windows uses the
modal via the app loop, and stdin is kept only for the safe no-app /
scheduling-failure cases. Consolidate three near-identical daemon-thread
tests into one parametrized (linux/win32) test behind a shared _run_on_daemon
harness, and drop dead code from the old main-thread test.

Refs #33961
2026-06-08 15:53:28 -07:00
firefly ab98818e5b fix(cli): use the confirm modal on native Windows instead of deadlocking input()
Native Windows bypassed the destructive-slash modal and fell back to a raw
input() prompt. When the confirm was triggered from the process_loop daemon
thread (the normal case), that input() deadlocked against prompt_toolkit's
main-thread stdin ownership: bare /reset froze with Ctrl-C swallowed, while
/reset now worked only because it skips the prompt. Route native Windows
through the existing call_soon_threadsafe modal path (the same key-binding
channel that already handles normal typing on Windows); keep the stdin
fallback only for the safe no-app / scheduling-failure cases, and clean-cancel
(None) off the main thread on win32 so a degraded path never re-deadlocks.

Addresses #33961
Refs #30768
2026-06-08 15:53:28 -07:00
firefly d66bac5a1a test(cli): failing regression test for native-Windows confirm deadlock (#33961) 2026-06-08 15:53:28 -07:00
teknium1 300371c3f2 chore: add AUTHOR_MAP entry for ruangraung (PR #42308 salvage) 2026-06-08 15:53:16 -07:00
ruangraung f4531feee8 fix(telegram): improve MarkdownV2 edit fallback and fix _strip_mdv2 bold handling
When edit_message(finalize=True) fails with a MarkdownV2 parse error,
the silent fallback previously sent raw content with escape sequences.
Now it logs the error and strips markdown formatting via _strip_mdv2()
for clean plain-text fallback.

Also fixes _strip_mdv2 to handle standard markdown bold (\*\*text\*\*)
before MarkdownV2 bold (\*text\*), preventing half-stripped asterisks.

Refs: #41955, #41732
2026-06-08 15:53:16 -07:00
ruangraung 6d2732e786 fix(gateway): apply MarkdownV2 formatting on progress message edits
When a platform adapter sets REQUIRES_EDIT_FINALIZE=True (e.g.
TelegramAdapter), tool progress edits now pass finalize=True so
format_message() is applied before sending to the platform.

Previously, the initial send() formatted the message correctly via
MarkdownV2, but subsequent edit_message() calls skipped formatting
(finalize=False), causing raw markdown (e.g. triple backticks for
bash code blocks) to render as plain text on Telegram.

Refs: #41955, #41732
2026-06-08 15:53:16 -07:00
teknium1 aa424e51ac refactor(doctor): fold custom-provider vendor-slug check into one predicate
Collapse the bare-"custom" allowlist entry and the custom:<name> guard into
a single provider_accepts_vendor_slug predicate so the slug-warning suppression
reads as one rule instead of two scattered conditions. No behavior change.
2026-06-08 15:53:09 -07:00
helix4u 732ababa1a fix(doctor): allow vendor slugs for named custom providers 2026-06-08 15:53:09 -07:00
GodsBoy 421226e404 fix(gateway): stop terminal progress from posting the full command to messaging chats
#41215 rendered a terminal tool call as a native ```bash fenced block on
markdown platforms (Telegram, WhatsApp, Slack, and others), showing the full
command with no truncation, in both all/new and verbose modes. That posted
complete shell commands (heredocs, internal paths, destructive commands) into
the chat before the final answer, visible to everyone in it.

This restores the prior behavior: terminal progress shows the short, truncated
preview line that every other tool already uses, capped at tool_preview_length.
The supports_code_blocks capability flag is left in place for future use.
CLI/TUI rendering is a separate path and was unaffected.

Adds a regression test asserting terminal progress renders as a truncated
preview, not a fenced bash block, even on a markdown-capable gateway.

Fixes #41955
2026-06-08 15:53:00 -07:00
Ray Sun 37561c214b fix(photon): use allowlisted device client_id + validate token before save
Photon now allowlists registered device clients on the device-code
endpoint; the old client_id "hermes-agent" is rejected with
400 invalid_client, breaking the entire login flow. Switch to Photon's
published "photon-cli" device client and send the standard scope.

Also validate the device-flow token against /api/auth/get-session and
/api/projects/ before persisting it, and extract token candidates from
every response shape Photon has used (access_token, accessToken,
data.*, set-auth-token header) so a token that authenticates the
session lookup but is rejected by the project API fails loudly at
login instead of 404ing downstream.

Verified live: request_device_code() now returns 200 + a valid
user_code where "hermes-agent" returned 400 invalid_client.

Salvaged from #34467 by @yanxue06.
2026-06-08 15:52:33 -07:00
153 changed files with 12028 additions and 2529 deletions
+40 -8
View File
@@ -55,15 +55,31 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
# pyproject.toml or uv.lock changes. `uv sync` still runs every
# time, but resolves from the warm cache instead of re-downloading
# and re-building wheels.
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
# `uv sync --locked` installs the exact pinned set from uv.lock (and
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
- name: Run tests (slice ${{ matrix.slice }}/6)
# Per-file isolation via scripts/run_tests_parallel.py: discovers
@@ -161,15 +177,31 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
# pyproject.toml or uv.lock changes. `uv sync` still runs every
# time, but resolves from the warm cache instead of re-downloading
# and re-building wheels.
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
# `uv sync --locked` installs the exact pinned set from uv.lock (and
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
- name: Packaged-wheel i18n smoke test
run: |
+1
View File
@@ -1,5 +1,6 @@
graft skills
graft optional-skills
graft optional-mcps
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
+3 -1
View File
@@ -3,7 +3,9 @@
</p>
# Hermes Agent ☤
<p align="center">
<a href="https://hermes-agent.nousresearch.com/">Hermes Agent</a> | <a href="https://hermes-agent.nousresearch.com/">Hermes Desktop</a>
</p>
<p align="center">
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
+80 -20
View File
@@ -73,20 +73,50 @@ ADAPTIVE_EFFORT_MAP = {
"minimal": "low",
}
# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added
# xhigh as a distinct level between high and max; older adaptive-thinking
# models (4.6) reject it with a 400. Keep this substring list in sync with
# the Anthropic migration guide as new model families ship.
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
# ── Anthropic thinking-mode classification ────────────────────────────
# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking,
# and 4.7 additionally forbids the manual ``thinking`` block entirely and drops
# temperature/top_p/top_k. Newer Claude releases (4.8, and named models like
# claude-fable-5) follow the same modern contract — but they share no common
# version substring, so an allowlist of version numbers ("4.6", "4.7", …) goes
# stale the moment a model ships without a recognized number and silently
# routes it down the legacy manual-thinking path.
#
# Instead we DEFAULT unknown Claude models to the modern contract and keep an
# explicit *legacy* list of the older Claude families that still require manual
# thinking. This mirrors _get_anthropic_max_output's "default to newest" design
# (future models are unlikely to regress to the older contract), so each new
# Claude release works without a code change.
#
# Non-Claude Anthropic-Messages models (minimax, qwen3, GLM, …) are NOT Claude,
# so they fall through to the legacy path automatically — exactly what those
# manual-thinking endpoints need.
# Older Claude families that DON'T support adaptive thinking (manual thinking
# with budget_tokens only). Substring-matched against the model name.
_LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = (
"claude-3", # 3, 3.5, 3.7
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
"claude-sonnet-4-0", "claude-sonnet-4.0",
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
"claude-opus-4-5", "claude-opus-4.5",
"claude-sonnet-4-5", "claude-sonnet-4.5",
"claude-haiku-4-5", "claude-haiku-4.5",
)
# Older Claude families that DON'T accept the "xhigh" effort level (4.6 only
# supports low/medium/high/max). xhigh arrived with Opus 4.7. Adaptive models
# not in this list (4.7, 4.8, fable, future) accept xhigh.
_NO_XHIGH_CLAUDE_SUBSTRINGS = (
"claude-opus-4-6", "claude-opus-4.6",
"claude-sonnet-4-6", "claude-sonnet-4.6",
)
def _is_claude_model(model: str | None) -> bool:
return "claude" in (model or "").lower()
# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
# is the only supported mode; 4.7 additionally forbids manual thinking entirely
# and drops temperature/top_p/top_k).
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8")
# Models where temperature/top_p/top_k return 400 if set to non-default values.
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# ── Max output token limits per Anthropic model ───────────────────────
@@ -94,6 +124,8 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
# starves thinking-enabled models (thinking tokens count toward the limit).
_ANTHROPIC_OUTPUT_LIMITS = {
# Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
"claude-fable": 128_000,
# Claude 4.8
"claude-opus-4-8": 128_000,
# Claude 4.7
@@ -208,8 +240,17 @@ def _resolve_anthropic_messages_max_tokens(
def _supports_adaptive_thinking(model: str) -> bool:
"""Return True for Claude 4.6+ models that support adaptive thinking."""
return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
"""Return True for Claude models that use adaptive thinking (4.6+).
Defaults *unknown* Claude models to adaptive (the modern contract) and
only returns False for the explicit legacy list of older Claude families
that require manual budget-based thinking. Non-Claude Anthropic-Messages
models (minimax, qwen3, …) return False so they keep the manual path.
"""
if not _is_claude_model(model):
return False
m = model.lower()
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
def _supports_xhigh_effort(model: str) -> bool:
@@ -219,18 +260,33 @@ def _supports_xhigh_effort(model: str) -> bool:
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max
when this returns False.
Defaults unknown adaptive Claude models to accepting xhigh (4.7+ contract);
only the 4.6 family and legacy manual-thinking models are excluded.
"""
return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS)
if not _supports_adaptive_thinking(model):
return False
m = model.lower()
return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS)
def _forbids_sampling_params(model: str) -> bool:
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
Opus 4.7 explicitly rejects sampling parameters; later Claude releases are
expected to follow suit. Callers should omit these fields entirely rather
than passing zero/default values (the API rejects anything non-null).
Opus 4.7 introduced this restriction; later Claude releases follow it.
Defaults unknown Claude models to forbidding sampling params (the modern
contract). The 4.6 family still accepts them, and the legacy manual-thinking
families (4.5 and older) accept them too, so both are excluded. Non-Claude
models are unaffected. Callers should omit these fields entirely rather than
passing zero/default values (the API rejects anything non-null).
"""
return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
if not _is_claude_model(model):
return False
m = model.lower()
# 4.6 family is adaptive but still accepts sampling params.
if any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS):
return False
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
def _supports_fast_mode(model: str) -> bool:
@@ -821,6 +877,7 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
capture_output=True,
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
except (OSError, subprocess.TimeoutExpired):
logger.debug("Keychain: security command not available or timed out")
@@ -1163,7 +1220,10 @@ def run_oauth_setup_token() -> Optional[str]:
"Install it with: npm install -g @anthropic-ai/claude-code"
)
# Run interactively — stdin/stdout/stderr inherited so user can interact
# Run interactively — stdin/stdout/stderr inherited so the user can
# complete the OAuth login prompt. Must keep inherited stdin; the TUI-EOF
# concern does not apply to an interactive login the user explicitly
# invokes. noqa: subprocess-stdin
try:
subprocess.run([claude_path, "setup-token"])
except (KeyboardInterrupt, EOFError):
+152 -1
View File
@@ -25,6 +25,154 @@ from typing import Any, Dict, List
logger = logging.getLogger(__name__)
def _coerce_usage_int(value: Any) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(value, 0)
if isinstance(value, float):
return max(int(value), 0)
if isinstance(value, str):
try:
return max(int(value), 0)
except ValueError:
return 0
return 0
def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
"""Translate Codex app-server token usage into Hermes accounting.
Codex app-server reports usage via thread/tokenUsage/updated as:
inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens,
totalTokens.
Hermes' canonical prompt bucket includes uncached input + cached input.
The Codex app-server protocol does not currently expose cache-write tokens,
so that bucket remains zero on this runtime.
Even when Codex omits usage for a turn, Hermes should still count that turn
as one API call for session/status accounting.
"""
agent.session_api_calls += 1
usage = getattr(turn, "token_usage_last", None)
if not isinstance(usage, dict) or not usage:
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
agent.session_id,
model=agent.model,
api_call_count=1,
)
except Exception as exc:
logger.debug(
"Codex app-server api-call persistence failed (session=%s): %s",
agent.session_id, exc,
)
return {}
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost
input_tokens = _coerce_usage_int(usage.get("inputTokens"))
cache_read_tokens = _coerce_usage_int(usage.get("cachedInputTokens"))
output_tokens = _coerce_usage_int(usage.get("outputTokens"))
reasoning_tokens = _coerce_usage_int(usage.get("reasoningOutputTokens"))
reported_total = _coerce_usage_int(usage.get("totalTokens"))
canonical_usage = CanonicalUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=0,
reasoning_tokens=reasoning_tokens,
raw_usage=usage,
)
prompt_tokens = canonical_usage.prompt_tokens
completion_tokens = canonical_usage.output_tokens
total_tokens = reported_total or canonical_usage.total_tokens
usage_dict = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
"input_tokens": canonical_usage.input_tokens,
"output_tokens": canonical_usage.output_tokens,
"cache_read_tokens": canonical_usage.cache_read_tokens,
"cache_write_tokens": canonical_usage.cache_write_tokens,
"reasoning_tokens": canonical_usage.reasoning_tokens,
}
compressor = getattr(agent, "context_compressor", None)
if compressor is not None:
try:
compressor.update_from_response(usage_dict)
context_window = getattr(turn, "model_context_window", None)
if isinstance(context_window, int) and context_window > 0:
compressor.context_length = context_window
except Exception:
logger.debug("codex app-server usage update failed", exc_info=True)
agent.session_prompt_tokens += prompt_tokens
agent.session_completion_tokens += completion_tokens
agent.session_total_tokens += total_tokens
agent.session_input_tokens += canonical_usage.input_tokens
agent.session_output_tokens += canonical_usage.output_tokens
agent.session_cache_read_tokens += canonical_usage.cache_read_tokens
agent.session_cache_write_tokens += canonical_usage.cache_write_tokens
agent.session_reasoning_tokens += canonical_usage.reasoning_tokens
cost_result = estimate_usage_cost(
agent.model,
canonical_usage,
provider=agent.provider,
base_url=agent.base_url,
api_key=getattr(agent, "api_key", ""),
)
if cost_result.amount_usd is not None:
agent.session_estimated_cost_usd += float(cost_result.amount_usd)
agent.session_cost_status = cost_result.status
agent.session_cost_source = cost_result.source
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
output_tokens=canonical_usage.output_tokens,
cache_read_tokens=canonical_usage.cache_read_tokens,
cache_write_tokens=canonical_usage.cache_write_tokens,
reasoning_tokens=canonical_usage.reasoning_tokens,
estimated_cost_usd=float(cost_result.amount_usd)
if cost_result.amount_usd is not None else None,
cost_status=cost_result.status,
cost_source=cost_result.source,
billing_provider=agent.provider,
billing_base_url=agent.base_url,
billing_mode="subscription_included"
if cost_result.status == "included" else None,
model=agent.model,
api_call_count=1,
)
except Exception as exc:
logger.debug(
"Codex app-server token persistence failed (session=%s, tokens=%d): %s",
agent.session_id, total_tokens, exc,
)
return {
**usage_dict,
"last_prompt_tokens": prompt_tokens,
"estimated_cost_usd": float(cost_result.amount_usd)
if cost_result.amount_usd is not None else None,
"cost_status": cost_result.status,
"cost_source": cost_result.source,
}
def run_codex_app_server_turn(
agent,
*,
@@ -120,6 +268,8 @@ def run_codex_app_server_turn(
agent._iters_since_skill = (
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
)
usage_result = _record_codex_app_server_usage(agent, turn)
api_calls = 1
# Now check the skill nudge AFTER iters were incremented — same
# pattern the chat_completions path uses (line ~15432).
@@ -164,12 +314,13 @@ def run_codex_app_server_turn(
return {
"final_response": turn.final_text,
"messages": messages,
"api_calls": 1, # one app-server "turn" maps to one logical API call
"api_calls": api_calls,
"completed": not turn.interrupted and turn.error is None,
"partial": turn.interrupted or turn.error is not None,
"error": turn.error,
"codex_thread_id": turn.thread_id,
"codex_turn_id": turn.turn_id,
**usage_result,
}
+2
View File
@@ -290,6 +290,7 @@ def _expand_git_reference(
capture_output=True,
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"{ref.raw}: git command timed out (30s)", None
@@ -482,6 +483,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
+3
View File
@@ -262,6 +262,7 @@ def _install_npm(
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -310,6 +311,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
text=True,
timeout=600,
env=env,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -347,6 +349,7 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
+50
View File
@@ -141,6 +141,8 @@ DEFAULT_CONTEXT_LENGTHS = {
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
# substring of "anthropic/claude-sonnet-4.6").
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
"claude-fable-5": 1000000,
"claude-fable": 1000000,
"claude-opus-4-8": 1000000,
"claude-opus-4.8": 1000000,
"claude-opus-4-7": 1000000,
@@ -968,6 +970,16 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
# OpenRouter/Nous phrasing of the same condition.
"in the output" in error_lower
and "maximum context length" in error_lower
) or (
# LM Studio / llama.cpp / some OpenAI-compatible servers:
# "This model's maximum context length is 65536 tokens. However, you
# requested 65536 output tokens and your prompt contains 77409
# characters ..."
# The "requested N output tokens" phrasing means the OUTPUT cap is the
# problem (the input itself fits) — reduce max_tokens, don't compress.
"maximum context length" in error_lower
and "requested" in error_lower
and "output tokens" in error_lower
)
if not is_output_cap_error:
return None
@@ -999,6 +1011,22 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
if _available >= 1:
return _available
# LM Studio / llama.cpp style: context window is reported in tokens but the
# prompt size is reported in CHARACTERS, e.g.
# "maximum context length is 65536 tokens ... your prompt contains 77409
# characters ...".
# Estimate the input tokens conservatively (~3 chars/token, which
# over-reserves the input so the retried output cap stays safely inside the
# window) and leave the remainder of the window for output.
_m_ctx_tok = re.search(r'maximum context length is (\d+)\s*token', error_lower)
_m_chars = re.search(r'prompt contains (\d+)\s*character', error_lower)
if _m_ctx_tok and _m_chars:
_ctx = int(_m_ctx_tok.group(1))
_est_input = (int(_m_chars.group(1)) + 2) // 3
_available = _ctx - _est_input
if _available >= 1:
return _available
return None
@@ -1784,6 +1812,28 @@ def get_model_context_length(
if ctx is not None:
save_context_length(model, base_url, ctx)
return ctx
# 5f. OpenRouter live /models metadata — authoritative for OpenRouter-routed
# models. OpenRouter's catalog carries per-model context_length (e.g.
# anthropic/claude-fable-5 -> 1M) and refreshes as new slugs ship, so it
# must win over both models.dev (step 5g) and the hardcoded family catch-all
# (step 8). Before this branch, an OpenRouter selection set
# effective_provider="openrouter", which (a) made the models.dev lookup miss
# brand-new slugs and (b) skipped the step-6 OR fallback (gated on `not
# effective_provider`), so a fresh slug like claude-fable-5 fell through to
# the generic "claude": 200K entry and under-reported a 1M window. Mirrors
# the dedicated Nous/Copilot/GMI branches above.
if effective_provider == "openrouter":
metadata = fetch_model_metadata()
entry = metadata.get(model)
if entry:
or_ctx = entry.get("context_length")
# Guard against the known OpenRouter Kimi-family 32k underreport
# (same class the hardcoded overrides exist to mitigate).
if isinstance(or_ctx, int) and or_ctx > 0 and not (
or_ctx == 32768 and _model_name_suggests_kimi(model)
):
return or_ctx
if effective_provider:
from agent.models_dev import lookup_models_dev_context
ctx = lookup_models_dev_context(effective_provider, model)
+2
View File
@@ -274,6 +274,7 @@ def _platform_asset_name() -> str:
capture_output=True,
text=True,
timeout=2,
stdin=subprocess.DEVNULL,
)
if "musl" in (res.stdout + res.stderr).lower():
libc = "musl"
@@ -525,6 +526,7 @@ def _run_bws_list(
capture_output=True,
text=True,
timeout=_BWS_RUN_TIMEOUT,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
+1
View File
@@ -74,6 +74,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
text=True,
timeout=max(1, int(timeout)),
check=False,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"[inline-shell timeout after {timeout}s: {command}]"
+1
View File
@@ -378,6 +378,7 @@ def check_codex_binary(
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
return False, (
@@ -72,6 +72,9 @@ class TurnResult:
error: Optional[str] = None # Set if turn ended in a non-recoverable error
turn_id: Optional[str] = None
thread_id: Optional[str] = None
token_usage_last: Optional[dict[str, Any]] = None
token_usage_total: Optional[dict[str, Any]] = None
model_context_window: Optional[int] = None
# Hint to the caller that the underlying codex subprocess is likely
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
# token-refresh failure killed the child). The caller should retire
@@ -501,6 +504,7 @@ class CodexAppServerSession:
pending = self._client.take_notification(timeout=0)
if pending is None:
break
_apply_token_usage_notification(result, pending)
self._track_pending_file_change(pending)
proj = projector.project(pending)
if proj.messages:
@@ -536,6 +540,8 @@ class CodexAppServerSession:
except Exception: # pragma: no cover - display callback
logger.debug("on_event callback raised", exc_info=True)
_apply_token_usage_notification(result, note)
# Track in-progress fileChange items so the approval bridge
# can surface a real change summary when codex requests
# approval (the approval params themselves don't carry the
@@ -802,6 +808,30 @@ class CodexAppServerSession:
return cached
def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
"""Capture Codex app-server token usage updates for caller accounting.
Codex does not put token usage on turn/completed. It emits a separate
thread/tokenUsage/updated notification containing cumulative totals and
the latest turn breakdown.
"""
if not isinstance(note, dict) or note.get("method") != "thread/tokenUsage/updated":
return
params = note.get("params") or {}
token_usage = params.get("tokenUsage") or {}
if not isinstance(token_usage, dict):
return
last = token_usage.get("last")
total = token_usage.get("total")
if isinstance(last, dict):
result.token_usage_last = dict(last)
if isinstance(total, dict):
result.token_usage_total = dict(total)
window = token_usage.get("modelContextWindow")
if isinstance(window, int) and window > 0:
result.model_context_window = window
def _approval_choice_to_codex_decision(choice: str) -> str:
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
/ FileChangeApprovalDecision wire values.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

After

Width:  |  Height:  |  Size: 561 KiB

+75 -4
View File
@@ -4295,20 +4295,31 @@ async function teardownPrimaryBackendAndWait() {
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
resetHermesConnection()
if (!dying) {
await waitForBackendExit(dying)
}
async function waitForBackendExit(child, timeoutMs = 5000) {
if (!child) {
return
}
if (child.exitCode !== null || child.signalCode !== null) {
return
}
await new Promise(resolve => {
const timer = setTimeout(() => {
try {
dying.kill('SIGKILL')
if (IS_WINDOWS && Number.isInteger(child.pid)) {
forceKillProcessTree(child.pid)
} else {
child.kill('SIGKILL')
}
} catch {
// Already gone.
}
resolve()
}, 5000)
dying.once('exit', () => {
}, timeoutMs)
child.once('exit', () => {
clearTimeout(timer)
resolve()
})
@@ -4500,12 +4511,70 @@ function stopPoolBackend(profile) {
}
}
async function teardownPoolBackendAndWait(profile) {
const entry = backendPool.get(profile)
if (!entry) return
backendPool.delete(profile)
if (entry.process && !entry.process.killed) {
try {
entry.process.kill('SIGTERM')
} catch {
// Already gone.
}
}
await waitForBackendExit(entry.process)
}
function stopAllPoolBackends() {
for (const profile of [...backendPool.keys()]) {
stopPoolBackend(profile)
}
}
function profileNameFromDeleteRequest(request) {
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
return null
}
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
if (!match) {
return null
}
let raw = ''
try {
raw = decodeURIComponent(match[1])
} catch {
return null
}
const name = raw.trim()
if (!name) {
return null
}
if (name.toLowerCase() === 'default') {
return 'default'
}
return name.toLowerCase()
}
async function prepareProfileDeleteRequest(request) {
const profile = profileNameFromDeleteRequest(request)
if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
return
}
if (profile === primaryProfileKey()) {
writeActiveDesktopProfile('default')
await teardownPrimaryBackendAndWait()
return
}
await teardownPoolBackendAndWait(profile)
}
async function startHermes() {
// Latched-failure short-circuit: once bootstrap has failed in this
// process, every subsequent startHermes() call re-throws the same error
@@ -5124,6 +5193,8 @@ ipcMain.handle('hermes:api', async (_event, request) => {
return rerouted
}
await prepareProfileDeleteRequest(request)
const connection = await ensureBackend(request?.profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 528 KiB

+173 -125
View File
@@ -76,6 +76,9 @@ import {
} from '@/store/profile'
import {
$cronSessions,
$messagingPlatformTotals,
$messagingSessions,
$messagingTruncated,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
@@ -124,7 +127,6 @@ const WORKSPACE_PAGE = 5
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const GROUP_DND_ID_PREFIX = 'group:'
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
@@ -141,24 +143,25 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
const byId = new Map(items.map(item => [getId(item), item]))
const seen = new Set<string>()
const out: T[] = []
const ordered: T[] = []
for (const id of orderIds) {
const item = byId.get(id)
if (item) {
out.push(item)
ordered.push(item)
seen.add(id)
}
}
for (const item of items) {
if (!seen.has(getId(item))) {
out.push(item)
}
}
// Items missing from the persisted order are new since it was last
// reconciled. Callers pass recency-sorted lists (newest first), so surface
// these at the TOP instead of burying them beneath the saved order —
// otherwise a brand-new session sinks to the bottom of the sidebar and reads
// as "my latest session never showed up".
const fresh = items.filter(item => !seen.has(getId(item)))
return out
return fresh.length ? [...fresh, ...ordered] : ordered
}
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
@@ -171,17 +174,15 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
}
const current = new Set(currentIds)
const next = orderIds.filter(id => current.has(id))
const known = new Set(next)
const retained = orderIds.filter(id => current.has(id))
const retainedSet = new Set(retained)
for (const id of currentIds) {
if (!known.has(id)) {
next.push(id)
known.add(id)
}
}
// New ids (absent from the saved order) are the newest sessions/groups; keep
// them ahead of the persisted order so fresh activity surfaces at the top of
// the sidebar rather than being appended to the bottom.
const fresh = currentIds.filter(id => !retainedSet.has(id))
return next
return [...fresh, ...retained]
}
function sameIds(left: string[], right: string[]) {
@@ -251,43 +252,6 @@ function workspaceGroupsFor(
return [...groups.values()]
}
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
localSessions: SessionInfo[]
sourceGroups: SidebarSessionGroup[]
} {
const groups = new Map<string, SidebarSessionGroup>()
const localSessions: SessionInfo[] = []
for (const session of sessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
localSessions.push(session)
continue
}
const label = sessionSourceLabel(sourceId) ?? sourceId
const group = groups.get(sourceId) ?? {
id: `source:${sourceId}`,
label,
mode: 'source',
path: null,
sessions: [],
sourceId
}
group.sessions.push(session)
groups.set(sourceId, group)
}
return {
localSessions,
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@@ -309,6 +273,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onNavigate: (item: SidebarNavItem) => void
onLoadMoreSessions: () => void
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
@@ -322,6 +287,7 @@ export function ChatSidebar({
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
onLoadMoreMessaging,
onResumeSession,
onDeleteSession,
onArchiveSession,
@@ -345,6 +311,9 @@ export function ChatSidebar({
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
const messagingSessions = useStore($messagingSessions)
const messagingPlatformTotals = useStore($messagingPlatformTotals)
const messagingTruncated = useStore($messagingTruncated)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
@@ -364,6 +333,8 @@ export function ChatSidebar({
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})
const [messagingOpen, setMessagingOpen] = useState<Record<string, boolean>>({})
const searchInputRef = useRef<HTMLInputElement>(null)
const trimmedQuery = searchQuery.trim()
@@ -529,24 +500,12 @@ export function ChatSidebar({
[unpinnedAgentSessions, agentOrderIds]
)
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
() => sourceSessionGroupsFor(agentSessions),
[agentSessions]
)
const orderedSourceGroups = useMemo(
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
[sourceGroups, workspaceOrderIds]
)
// Recents are local-only: messaging-platform sessions are fetched as their
// own slice ($messagingSessions) and rendered in self-managed per-platform
// sections below, so there is no source-grouping magic to untangle here.
const agentGroups = useMemo(
() =>
orderByIds(
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
g => g.id,
workspaceOrderIds
),
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -564,6 +523,64 @@ export function ChatSidebar({
[onLoadMoreProfileSessions]
)
const loadMoreForMessaging = useCallback(
(platform: string) => {
if (!onLoadMoreMessaging) {
return
}
setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true }))
void Promise.resolve(onLoadMoreMessaging(platform))
.catch(() => undefined)
.finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest))
},
[onLoadMoreMessaging]
)
// Each messaging platform is its own self-managed section: split the
// separately-fetched messaging slice by source, newest platform first, rows
// within a platform by recency. Per-platform totals (when a "load more" has
// resolved them) drive the count + whether more remain on disk.
const messagingGroups = useMemo<MessagingSection[]>(() => {
if (!messagingSessions.length) {
return []
}
const bySource = new Map<string, SessionInfo[]>()
for (const session of messagingSessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId) {
continue
}
const list = bySource.get(sourceId) ?? []
list.push(session)
bySource.set(sourceId, list)
}
return [...bySource.entries()]
.map(([sourceId, list]) => {
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
const known = messagingPlatformTotals[sourceId]
const total = Math.max(ordered.length, known ?? 0)
return {
// Known exact total → more exist iff total exceeds loaded; otherwise
// the seed fetch was capped, so assume more until a per-platform load
// resolves the count.
hasMore: known != null ? known > ordered.length : messagingTruncated,
label: sessionSourceLabel(sourceId) ?? sourceId,
sessions: ordered,
sourceId,
total
}
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [messagingSessions, messagingPlatformTotals, messagingTruncated])
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
@@ -610,56 +627,7 @@ export function ChatSidebar({
sessionProfileTotals
])
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
const displayAgentGroups = useMemo(() => {
if (orderedSourceGroups.length) {
const localGroups = agentsGrouped
? agentGroups
: localAgentSessions.length
? [
{
id: 'local-sessions',
label: 'Local',
mode: 'workspace' as const,
path: null,
sessions: localAgentSessions
}
]
: []
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
}
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
}, [
agentGroups,
agentsGrouped,
localAgentSessions,
orderedSourceGroups,
profileGroups,
showAllProfiles,
workspaceOrderIds
])
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
return
}
const next = reconcileOrderIds(
displayAgentGroups.map(g => g.id),
workspaceOrderIds
)
if (!sameIds(next, workspaceOrderIds)) {
setSidebarWorkspaceOrderIds(next)
}
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
const displayAgentSessions = agentSessions
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
@@ -680,6 +648,27 @@ export function ChatSidebar({
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
return
}
const next = reconcileOrderIds(
displayAgentGroups.map(g => g.id),
workspaceOrderIds
)
if (!sameIds(next, workspaceOrderIds)) {
setSidebarWorkspaceOrderIds(next)
}
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
@@ -902,7 +891,7 @@ export function ChatSidebar({
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && localAgentSessions.length > 0 ? (
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
@@ -942,6 +931,46 @@ export function ChatSidebar({
/>
)}
{contentVisible && showSessionSections && !trimmedQuery &&
messagingGroups.map(group => (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex max-h-56 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={null}
footer={
group.hasMore ? (
<SidebarLoadMoreRow
loading={Boolean(messagingLoadMorePending[group.sourceId])}
onClick={() => loadMoreForMessaging(group.sourceId)}
step={Math.max(0, group.total - group.sessions.length)}
/>
) : null
}
key={group.sourceId}
label={group.label}
labelIcon={
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
platformId={group.sourceId}
platformName={group.label}
/>
}
labelMeta={countLabel(group.sessions.length, group.total)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() =>
setMessagingOpen(prev => ({ ...prev, [group.sourceId]: prev[group.sourceId] === false }))
}
onTogglePin={pinSession}
open={messagingOpen[group.sourceId] !== false}
pinned={false}
rootClassName="shrink-0 p-0"
sessions={group.sessions}
workingSessionIdSet={workingSessionIdSet}
/>
))}
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
@@ -972,9 +1001,10 @@ interface SidebarSectionHeaderProps {
onToggle: () => void
action?: React.ReactNode
meta?: React.ReactNode
icon?: React.ReactNode
}
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {
return (
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
@@ -982,6 +1012,7 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSe
onClick={onToggle}
type="button"
>
{icon}
<SidebarPanelLabel>{label}</SidebarPanelLabel>
{meta && <SidebarCount>{meta}</SidebarCount>}
<DisclosureCaret
@@ -1044,6 +1075,14 @@ interface SidebarSessionGroup {
totalCount?: number
}
interface MessagingSection {
sourceId: string
label: string
sessions: SessionInfo[]
total: number
hasMore: boolean
}
interface SidebarSessionsSectionProps {
label: string
open: boolean
@@ -1065,6 +1104,7 @@ interface SidebarSessionsSectionProps {
footer?: React.ReactNode
groups?: SidebarSessionGroup[]
labelMeta?: React.ReactNode
labelIcon?: React.ReactNode
sortable?: boolean
onReorder?: (event: DragEndEvent) => void
dndSensors?: ReturnType<typeof useSensors>
@@ -1091,6 +1131,7 @@ function SidebarSessionsSection({
footer,
groups,
labelMeta,
labelIcon,
sortable = false,
onReorder,
dndSensors
@@ -1209,7 +1250,14 @@ function SidebarSessionsSection({
return (
<SidebarGroup className={rootClassName}>
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
<SidebarSectionHeader
action={headerAction}
icon={labelIcon}
label={label}
meta={labelMeta}
onToggle={onToggle}
open={open}
/>
{open && (
<SidebarGroupContent className={resolvedContentClassName}>
{body}
@@ -2,12 +2,15 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
@@ -67,6 +70,11 @@ export function SidebarSessionRow({
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
// A handed-off session's live source is local, but it originated on a
// messaging platform — surface that origin as a small badge so e.g. a
// Telegram thread continued here still reads as Telegram.
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
@@ -179,6 +187,15 @@ export function SidebarSessionRow({
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
{handoffSource && handoffLabel ? (
<Tip label={r.handoffOrigin(handoffLabel)}>
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
platformId={handoffSource}
platformName={handoffLabel}
/>
</Tip>
) : null}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
+70 -3
View File
@@ -14,6 +14,12 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
MESSAGING_SESSION_SOURCE_IDS,
normalizeSessionSource
} from '../lib/session-source'
import { setCronFocusJobId, setCronJobs } from '../store/cron'
import {
$panesFlipped,
@@ -46,10 +52,12 @@ import {
$gatewayState,
$selectedStoredSessionId,
$sessions,
$messagingSessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
sessionPinId,
setAwaitingResponse,
setBusy,
@@ -59,6 +67,9 @@ import {
setCurrentModel,
setCurrentProvider,
setMessages,
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
@@ -121,6 +132,15 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill
// this cadence while the app is open + visible so new runs surface promptly
// instead of waiting for the next user-triggered refreshSessions().
const CRON_POLL_INTERVAL_MS = 30_000
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
// keeps "Load more" paging through interactive local chats instead of
// interleaving gateway threads that bury them.
const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
// The messaging slice is the inverse: drop cron + every local source so only
// external-platform conversations remain, then split per platform in the UI.
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
// Cheap signature compare so the poll only swaps the atom (and re-renders the
// sidebar) when the visible cron rows actually changed.
@@ -280,6 +300,51 @@ export function DesktopController() {
}
}, [])
// Messaging-platform sessions as their own slice, fetched separately from
// local recents so each platform renders a self-managed section and never
// competes with local chats for the recents page budget. One combined fetch
// seeds every platform; the sidebar splits the rows per source.
const refreshMessagingSessions = useCallback(async () => {
try {
const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
excludeSources: MESSAGING_EXCLUDED_SOURCES
})
// Drop any non-messaging source the broad exclude didn't catch (custom
// sources) — those stay in local recents, not a platform section.
const rows = result.sessions.filter(s => isMessagingSource(s.source))
setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows))
// Hit the cap → at least one platform may have more on disk than loaded,
// so platform sections offer their own per-platform "load more".
setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT)
} catch {
// Non-fatal: the messaging sections just stay empty/stale.
}
}, [])
// Page a single platform's section independently (mirrors the per-profile
// pager): fetch that source's next window and merge it back in place, leaving
// every other platform's rows untouched. Resolves the platform's exact total.
const loadMoreMessagingForPlatform = useCallback(async (platform: string) => {
const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform
const loaded = $messagingSessions.get().filter(inPlatform).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', {
source: platform
})
const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform)
setMessagingSessions(prev => [
...prev.filter(s => !inPlatform(s)),
...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep())
])
const total = result.total ?? incoming.length
setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) }))
}, [])
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
// synchronously (agent tool call or the cron UI), so refreshing here right
// after an agent turn surfaces a new job immediately; the interval poll keeps
@@ -316,7 +381,7 @@ export function DesktopController() {
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: ['cron']
excludeSources: SIDEBAR_EXCLUDED_SOURCES
})
if (refreshSessionsRequestRef.current === requestId) {
@@ -332,7 +397,8 @@ export function DesktopController() {
void refreshCronSessions()
void refreshCronJobs()
}, [profileScope, refreshCronSessions, refreshCronJobs])
void refreshMessagingSessions()
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
@@ -347,7 +413,7 @@ export function DesktopController() {
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
excludeSources: ['cron']
excludeSources: SIDEBAR_EXCLUDED_SOURCES
})
const keep = sessionsToKeep(key)
@@ -704,6 +770,7 @@ export function DesktopController() {
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreMessaging={loadMoreMessagingForPlatform}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onManageCronJob={jobId => {
@@ -18,6 +18,7 @@ import {
toggleSidebarOpen
} from '@/store/layout'
import {
$newChatProfile,
cycleProfile,
requestProfileCreate,
switchProfileToSlot,
@@ -106,6 +107,10 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'nav.agents': () => navigate(AGENTS_ROUTE),
'session.new': () => {
// Match the sidebar New Session button. A plain keyboard new chat should
// target the current live profile, not a stale per-profile quick-create
// selection from a prior action.
$newChatProfile.set(null)
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
@@ -4,6 +4,8 @@ import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $sessions, setSessions } from '@/store/session'
import { $connection } from '@/store/session'
import type { ComposerAttachment } from '@/store/composer'
import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
@@ -42,7 +44,10 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
interface HarnessHandle {
steerPrompt: (text: string) => Promise<boolean>
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
submitText: (
text: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean>
}
function Harness({
@@ -50,16 +55,20 @@ function Harness({
onReady,
onSeedState,
refreshSessions,
requestGateway
requestGateway,
storedSessionId
}: {
busyRef?: MutableRefObject<boolean>
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
storedSessionId?: null | string
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
}
const localBusyRef = busyRef ?? { current: false }
const actions = usePromptActions({
@@ -314,3 +323,198 @@ describe('usePromptActions steerPrompt', () => {
expect(requestGateway).not.toHaveBeenCalled()
})
})
describe('usePromptActions file attachment sync', () => {
afterEach(() => {
cleanup()
$connection.set(null)
vi.restoreAllMocks()
})
function fileAttachment(): ComposerAttachment {
return {
id: 'file:report.txt',
kind: 'file',
label: 'report.txt',
path: '/Users/alice/Downloads/report.txt',
refText: '@file:`/Users/alice/Downloads/report.txt`'
}
}
it('uploads file bytes via file.attach on a remote gateway and submits the rewritten ref', async () => {
// Remote gateway can't read the client-disk path, so the desktop must upload
// the bytes and submit the workspace-relative ref the gateway hands back —
// not the original /Users/... path (which would dead-end as "outside the
// allowed workspace").
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') }
})
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return {
attached: true,
path: '/remote/work/.hermes/desktop-attachments/report.txt',
ref_text: '@file:.hermes/desktop-attachments/report.txt',
uploaded: true
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('convert this to epub', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['file.attach', 'prompt.submit'])
expect(calls[0]?.params).toMatchObject({
session_id: RUNTIME_SESSION_ID,
path: '/Users/alice/Downloads/report.txt',
name: 'report.txt',
data_url: 'data:text/plain;base64,aGVsbG8='
})
expect(calls[1]?.params).toEqual({
session_id: RUNTIME_SESSION_ID,
text: '@file:.hermes/desktop-attachments/report.txt\n\nconvert this to epub'
})
})
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
$connection.set({ mode: 'local' } as never)
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls[0]?.method).toBe('file.attach')
// Local mode sends no data_url — the gateway shares this disk.
expect(calls[0]?.params).not.toHaveProperty('data_url')
expect(calls[1]).toEqual({
method: 'prompt.submit',
params: { session_id: RUNTIME_SESSION_ID, text: '@file:data/report.txt\n\nsummarize' }
})
})
})
describe('usePromptActions sleep/wake session recovery', () => {
const STORED_SESSION_ID = 'stored-db-xyz789'
const RECOVERED_SESSION_ID = 'rt-recovered-456'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('resumes the stored session and retries once when prompt.submit reports "session not found"', async () => {
// After sleep/wake the gateway's in-memory session table is cleared, so the
// first prompt.submit with the stale runtime id fails. The hook resumes the
// durable stored id (which survives gateway restarts), gets a fresh live id,
// and retries the send transparently.
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
const ok = await handle!.submitText('message after wake')
expect(ok).toBe(true)
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
const calls: string[] = []
const states: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session busy')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
// submitText swallows the error into an inline bubble and returns false.
expect(await handle!.submitText('message')).toBe(false)
// No resume attempt for a non-recoverable error.
expect(calls).not.toContain('session.resume')
})
it('surfaces "session not found" (no resume) when there is no stored session id', async () => {
const calls: string[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session not found')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={null}
/>
)
// With a null stored ref, the `&& selectedStoredSessionIdRef.current` guard
// short-circuits — no resume is attempted and the error surfaces normally.
expect(await handle!.submitText('message')).toBe(false)
expect(calls).not.toContain('session.resume')
})
})
@@ -47,6 +47,7 @@ import {
import type {
ClientSessionState,
FileAttachResponse,
ImageAttachResponse,
SessionSteerResponse,
SessionTitleResponse,
@@ -103,6 +104,20 @@ async function readImageForRemoteAttach(
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
}
// Read a non-image file as a data URL for upload via file.attach. Returns null
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
const reader = window.hermesDesktop?.readFileDataUrl
if (!reader) {
return null
}
const dataUrl = await reader(filePath)
return dataUrl || null
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
@@ -212,62 +227,114 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
const syncImageAttachmentsForSubmit = useCallback(
const syncAttachmentsForSubmit = useCallback(
async (
sessionId: string,
attachments: ComposerAttachment[],
options: { updateComposerAttachments?: boolean } = {}
) => {
): Promise<ComposerAttachment[]> => {
const updateComposerAttachments = options.updateComposerAttachments ?? true
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
const remote = $connection.get()?.mode === 'remote'
const synced: ComposerAttachment[] = []
for (const attachment of images) {
if (attachment.attachedSessionId === sessionId) {
for (const attachment of attachments) {
// Already-synced or pathless refs (terminal, url, etc.) pass through.
if (!attachment.path || attachment.attachedSessionId === sessionId) {
synced.push(attachment)
continue
}
let result: ImageAttachResponse
if (attachment.kind === 'image') {
let result: ImageAttachResponse
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = await readImageForRemoteAttach(attachment.path)
if (!payload) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(`Could not read ${label}`)
if (!payload) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(`Could not read ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
}
if (!result.attached) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(result.message || `Could not attach ${label}`)
}
if (!result.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(result.message || `Could not attach ${label}`)
}
const attachedPath = result.path || attachment.path
if (updateComposerAttachments) {
addComposerAttachment({
const attachedPath = result.path || attachment.path
const nextAttachment: ComposerAttachment = {
...attachment,
id: attachment.id,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
attachedSessionId: sessionId
})
}
if (updateComposerAttachments) {
addComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
continue
}
if (attachment.kind === 'file') {
// Non-image file refs are @file: paths the gateway reads with its file
// tools. On a remote gateway the desktop path doesn't exist there, so
// upload the bytes; the gateway stages them into the session workspace
// and hands back a workspace-relative ref that actually resolves.
// Local mode can pass the path directly (gateway shares this disk).
const dataUrl = remote ? await readFileDataUrlForAttach(attachment.path) : null
if (remote && !dataUrl) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(`Could not read ${label}`)
}
const result = await requestGateway<FileAttachResponse>('file.attach', {
session_id: sessionId,
path: attachment.path,
name: attachment.label || pathLabel(attachment.path),
...(dataUrl ? { data_url: dataUrl } : {})
})
if (!result.attached || !result.ref_text) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(result.message || `Could not attach ${label}`)
}
const nextAttachment: ComposerAttachment = {
...attachment,
id: attachment.id,
refText: result.ref_text,
attachedSessionId: sessionId
}
if (updateComposerAttachments) {
addComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
continue
}
synced.push(attachment)
}
return synced
},
[requestGateway]
)
@@ -278,35 +345,42 @@ export function usePromptActions({
const usingComposerAttachments = !options?.attachments
const attachments = options?.attachments ?? $composerAttachments.get()
const contextRefs = attachments
.map(a => a.refText)
.filter(Boolean)
.join('\n')
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
const hasImage = attachments.some(a => a.kind === 'image')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
const text =
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(hasImage ? 'What do you see in this image?' : '')
// Refs are recomputed after sync (file.attach rewrites @file: refs to
// workspace-relative paths the remote gateway can resolve). Seed the
// optimistic message with the pre-sync refs, then rewrite once synced.
let attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
const buildContextText = (atts: ComposerAttachment[]): string => {
const contextRefs = atts
.map(a => a.refText)
.filter(Boolean)
.join('\n')
return (
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
)
}
// Queue drains fire on the busy→false settle edge, where busyRef (synced
// from $busy by a separate effect) may still read true — honoring it would
// bounce the drained send. The drain lock serializes them; the user path
// keeps the guard so a stray Enter mid-turn can't double-submit.
if (!text || (!options?.fromQueue && busyRef.current)) {
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
return false
}
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const userMessage: ChatMessage = {
const buildUserMessage = (): ChatMessage => ({
id: optimisticId,
role: 'user',
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
}
})
const releaseBusy = () => {
setMutableRef(busyRef, false)
@@ -323,7 +397,7 @@ export function usePromptActions({
...state,
messages: state.messages.some(m => m.id === optimisticId)
? state.messages
: [...state.messages, userMessage],
: [...state.messages, buildUserMessage()],
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
@@ -336,6 +410,18 @@ export function usePromptActions({
selectedStoredSessionIdRef.current
)
// After sync rewrites refs, refresh the optimistic message in place so the
// transcript shows the resolved @file: ref rather than the local path.
const rewriteOptimistic = (sid: string) =>
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))
}),
selectedStoredSessionIdRef.current
)
const dropOptimistic = (sid: null | string) => {
if (!sid) {
setMessages(current => current.filter(m => m.id !== optimisticId))
@@ -366,7 +452,7 @@ export function usePromptActions({
if (sessionId) {
seedOptimistic(sessionId)
} else {
setMessages(current => [...current, userMessage])
setMessages(current => [...current, buildUserMessage()])
}
if (!sessionId) {
@@ -392,10 +478,46 @@ export function usePromptActions({
}
try {
await syncImageAttachmentsForSubmit(sessionId, attachments, {
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
updateComposerAttachments: usingComposerAttachments
})
await requestGateway('prompt.submit', { session_id: sessionId, text })
// Rewrite the optimistic message + prompt text with the synced refs so
// the gateway receives @file: paths that resolve in its workspace.
attachmentRefs = syncedAttachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
rewriteOptimistic(sessionId)
const text = buildContextText(syncedAttachments)
// On sleep/wake the gateway's in-memory session may have been cleared
// while the desktop app still holds the old session ID. Detect this,
// resume the stored session to re-register it, and retry once.
let submitErr: unknown = null
try {
await requestGateway('prompt.submit', { session_id: sessionId, text })
} catch (firstErr) {
const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr)
if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) {
// Re-register the session in the gateway and get a fresh live ID.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await requestGateway('prompt.submit', { session_id: recoveredId, text })
} else {
submitErr = firstErr
}
} else {
submitErr = firstErr
}
}
if (submitErr !== null) {
throw submitErr
}
if (usingComposerAttachments) {
clearComposerAttachments()
@@ -442,7 +564,7 @@ export function usePromptActions({
createBackendSessionForSend,
requestGateway,
selectedStoredSessionIdRef,
syncImageAttachmentsForSubmit,
syncAttachmentsForSubmit,
updateSessionState
]
)
@@ -84,6 +84,60 @@ describe('useRouteResume', () => {
expect(resumeSession).not.toHaveBeenCalled()
})
it('self-heals a stranded routed session (null selected/active, same pathname, not a fresh draft)', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
// A create/stream race nulls selected/active but the route stays on the
// session and freshDraftReady is false (NOT a new-chat transition).
activeSessionIdRef.current = null
selectedStoredSessionIdRef.current = null
rerender(
<RouteResumeHarness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId={null}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
it('resumes when pathname changes to a routed session', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
@@ -133,4 +187,72 @@ describe('useRouteResume', () => {
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
})
it('resumes the selected route again when the gateway reconnects', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
rerender(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="closed"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
rerender(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
})
@@ -56,13 +56,19 @@ export function useRouteResume({
startFreshSessionDraft
}: RouteResumeOptions) {
const lastPathnameRef = useRef<string | null>(null)
const seenGatewayStateRef = useRef(false)
const wasGatewayOpenRef = useRef(false)
useEffect(() => {
const gatewayOpen = gatewayState === 'open'
const pathnameChanged = lastPathnameRef.current !== locationPathname
const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
// Fire only on a genuine closed->open transition (a reconnect). seenGatewayStateRef
// stays false until the first effect run, so a session that mounts with the gateway
// already open is not mistaken for "became open" and does not double-resume with the
// pathname-driven initial resume below.
const gatewayBecameOpen = seenGatewayStateRef.current && !wasGatewayOpenRef.current && gatewayOpen
lastPathnameRef.current = locationPathname
seenGatewayStateRef.current = true
wasGatewayOpenRef.current = gatewayOpen
if (currentView !== 'chat' || !gatewayOpen) {
@@ -77,12 +83,33 @@ export function useRouteResume({
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
// Resume only when the route meaningfully changed (or gateway just opened).
// This avoids a transient /:sid re-resume during "new chat" state clears
// before the pathname updates from /:sid -> /.
const shouldResume = pathnameChanged || gatewayBecameOpen
// Self-heal a desynced view: the route points at a session that isn't the
// loaded one. A create/stream race can leave selected/active null while
// the route stays on /:sid (symptom: brand-new chat shows "Thinking" then
// an empty transcript even though the turn completed and persisted). The
// pathname didn't change, so the normal gate would skip and the view stays
// stuck empty forever. selectedStoredSessionIdRef is set synchronously at
// resume entry, so this can't loop; the resume's cached fast-path restores
// the already-streamed messages without a refetch.
//
// Crucially this must NOT fire during a /:sid -> /new transition, where
// startFreshSessionDraft nulls selected/active one render before the
// pathname flips to / (same null+/:sid signature). freshDraftReady is the
// discriminator: it's true while heading into a blank new chat, false when
// genuinely stranded on a routed session.
const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady
if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
// Resume when the route meaningfully changed, the gateway just opened, or
// we're stranded on a routed session that never loaded. The first two
// guard against a transient /:sid re-resume during "new chat" state clears
// before the pathname updates from /:sid -> /.
const shouldResume = pathnameChanged || gatewayBecameOpen || stuckOnRoutedSession
// On a reconnect (gatewayBecameOpen) re-resume even when the route looks
// `alreadyActive`: the cached runtime id can be stale once the gateway
// rebinds/reaps the session on its side, and trusting it strands Desktop on
// a dead id ("session not found"). Otherwise keep skipping when already active.
if ((gatewayBecameOpen || !alreadyActive) && shouldResume && !creatingSessionRef.current) {
void resumeSession(routedSessionId, true)
}
@@ -150,6 +150,29 @@ export function useSessionStateCache({
pendingViewStateRef.current = { sessionId, state }
// Terminal / attention transitions (turn finished, error, or the agent is
// now waiting on the user) MUST reach the view immediately. Electron
// throttles `requestAnimationFrame` to ~0 while the window is
// backgrounded, occluded, or unfocused, so an RAF-deferred flush can be
// stranded in `pendingViewStateRef` indefinitely — that's the "new chat
// stuck on Thinking until I refocus / F5" bug. Flush these synchronously
// (cancelling any in-flight RAF, since we're about to publish the latest
// state anyway). The plain busy heartbeat stays RAF-batched: that
// coalescing exists only to keep periodic `session.info` updates from
// churning `$messages` and jerking the scroll position while reading.
const isCriticalTransition = !state.busy || state.needsInput
if (isCriticalTransition) {
if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(viewSyncRafRef.current)
viewSyncRafRef.current = null
}
flushPendingViewState()
return
}
if (viewSyncRafRef.current !== null) {
return
}
+14
View File
@@ -27,6 +27,20 @@ export interface ImageDetachResponse {
count?: number
}
export interface FileAttachResponse {
attached?: boolean
message?: string
// Gateway-side absolute path the file was staged to.
path?: string
// Workspace-relative path used to build ref_text.
ref_path?: string
// Rewritten @file: ref that resolves on the gateway (workspace-relative).
ref_text?: string
// True when bytes/host file were copied into the session workspace.
uploaded?: boolean
name?: string
}
export interface SlashExecResponse {
output?: string
warning?: string
@@ -494,11 +494,9 @@ export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: Markdo
const MarkdownTextImpl = () => {
return (
<SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
</SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
)
}
@@ -77,6 +77,7 @@ import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
@@ -919,7 +920,7 @@ const SystemMessage: FC = () => {
>
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
<span className="mx-1.5 text-muted-foreground/35">·</span>
<span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={slashStatus.groups.output.trim()} />
</MessagePrimitive.Root>
)
}
@@ -930,7 +931,7 @@ const SystemMessage: FC = () => {
data-role="system"
data-slot="aui_system-message-root"
>
<span className="whitespace-pre-wrap">{text}</span>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={text} />
</MessagePrimitive.Root>
)
}
+1
View File
@@ -1081,6 +1081,7 @@ export const en: Translations = {
sessionRunning: 'Session running',
needsInput: 'Needs your input',
waitingForAnswer: 'Waiting for your answer',
handoffOrigin: platform => `Handed off from ${platform}`,
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameTitle: 'Rename session',
+1
View File
@@ -1224,6 +1224,7 @@ export const ja = defineLocale({
sessionRunning: 'セッション実行中',
needsInput: '入力が必要です',
waitingForAnswer: '回答を待っています',
handoffOrigin: platform => `${platform} から引き継ぎ`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
renameTitle: 'セッションの名前を変更',
+1
View File
@@ -838,6 +838,7 @@ export interface Translations {
sessionRunning: string
needsInput: string
waitingForAnswer: string
handoffOrigin: (platform: string) => string
renamed: string
renameFailed: string
renameTitle: string
+1
View File
@@ -1190,6 +1190,7 @@ export const zhHant = defineLocale({
sessionRunning: '工作階段執行中',
needsInput: '需要您的輸入',
waitingForAnswer: '等待您的回答',
handoffOrigin: platform => `${platform} 轉接`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',
renameTitle: '重新命名工作階段',
+1
View File
@@ -1268,6 +1268,7 @@ export const zh: Translations = {
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
handoffOrigin: platform => `${platform} 转接`,
renamed: '已重命名',
renameFailed: '重命名失败',
renameTitle: '重命名会话',
@@ -165,4 +165,31 @@ describe('external link helpers', () => {
'https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
)
})
it('explicitOnly skips bare filename/domain tokens and only links explicit URLs', () => {
installDesktopBridge()
render(
<LinkifiedText
explicitOnly
pretty={false}
text={'Report https://paste.rs/abc\nagent.log https://paste.rs/def\nerrors.log'}
/>
)
const links = screen.getAllByRole('link')
expect(links.map(a => a.getAttribute('href'))).toEqual(['https://paste.rs/abc', 'https://paste.rs/def'])
// Bare filename-shaped tokens stay as plain text, not links.
expect(screen.queryByText(content => content.includes('agent.log'))).toBeTruthy()
expect(links.some(a => (a.textContent ?? '').includes('.log'))).toBe(false)
})
it('without explicitOnly, bare filename tokens are still linkified (default behavior)', () => {
installDesktopBridge()
render(<LinkifiedText pretty={false} text="open agent.log please" />)
const link = screen.getByRole('link', { name: 'agent.log' })
expect(link.getAttribute('href')).toBe('https://agent.log')
})
})
+9 -2
View File
@@ -12,6 +12,12 @@ const titleSubs = new Map<string, Set<(value: string) => void>>()
const URL_RE =
/(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]|[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?:\/[^\s<>"'`.,;:!?)]*)?/gi
// Explicit-scheme / www. URLs only — no bare-domain matching. Used where the
// surrounding text is full of filename-shaped tokens (e.g. `agent.log`,
// `errors.log` in a /debug report) that the bare-domain branch of URL_RE would
// otherwise mistake for domains and linkify.
const EXPLICIT_URL_RE = /(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]/gi
const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i
const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|hermes):/i
const LOCAL_HOST_RE = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?$/i
@@ -261,13 +267,14 @@ interface LinkifiedTextProps {
className?: string
text: string
pretty?: boolean
explicitOnly?: boolean
}
export function LinkifiedText({ className, pretty = true, text }: LinkifiedTextProps) {
export function LinkifiedText({ className, explicitOnly = false, pretty = true, text }: LinkifiedTextProps) {
const nodes: ReactNode[] = []
let cursor = 0
for (const match of text.matchAll(URL_RE)) {
for (const match of text.matchAll(explicitOnly ? EXPLICIT_URL_RE : URL_RE)) {
const raw = match[0]
const url = normalizeExternalUrl(raw)
const index = match.index ?? 0
+64
View File
@@ -34,12 +34,76 @@ const SOURCE_ALIASES: Record<string, string[]> = {
whatsapp: ['wa']
}
// Sources that run on the local machine rather than an external messaging
// platform. A handoff *from* one of these isn't a platform origin worth a badge.
// Exported so the recents fetch can keep these in the main list while the
// messaging fetch excludes them.
export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'local', 'tui']
const LOCAL_SOURCE_IDS = new Set(LOCAL_SESSION_SOURCE_IDS)
// External messaging platforms that each get their own self-managed sidebar
// section (fetched separately from local recents). Mirrors the gateway platform
// adapters; keep in sync with PLATFORM_ICONS in app/messaging/platform-icon.tsx.
export const MESSAGING_SESSION_SOURCE_IDS = [
'telegram',
'discord',
'slack',
'mattermost',
'matrix',
'signal',
'whatsapp',
'bluebubbles',
'homeassistant',
'email',
'sms',
'webhook',
'api_server',
'weixin',
'wecom',
'qqbot',
'yuanbao',
'dingtalk',
'feishu'
]
const MESSAGING_SOURCE_IDS = new Set(MESSAGING_SESSION_SOURCE_IDS)
/** True when a source id is an external messaging platform (gets its own
* sidebar section) rather than a local/CLI/desktop session. */
export function isMessagingSource(source: null | string | undefined): boolean {
const id = normalizeSessionSource(source)
return id != null && MESSAGING_SOURCE_IDS.has(id)
}
export function normalizeSessionSource(source: null | string | undefined): string | null {
const id = source?.trim().toLowerCase()
return id || null
}
/**
* Resolve the origin messaging platform for a handed-off session. Returns the
* normalized platform id (e.g. 'telegram') when the session completed a handoff
* from a real messaging platform, otherwise null. After a handoff the live
* source is local, so this is what drives the row's origin-platform badge.
*/
export function handoffOriginSource(
handoffState: null | string | undefined,
handoffPlatform: null | string | undefined
): string | null {
if (handoffState !== 'completed') {
return null
}
const id = normalizeSessionSource(handoffPlatform)
if (!id || LOCAL_SOURCE_IDS.has(id)) {
return null
}
return id
}
export function sessionSourceLabel(source: null | string | undefined): string | null {
const id = normalizeSessionSource(source)
+18
View File
@@ -85,6 +85,20 @@ export const $cronSessions = atom<SessionInfo[]>([])
// badge renders "N+". Lives here so the controller (fetch) and sidebar (badge)
// share one source of truth without a circular import.
export const CRON_SECTION_LIMIT = 50
// Messaging-platform sessions (telegram/discord/...) are fetched as their own
// slice — separate from local recents — so each platform renders a
// self-managed sidebar section and never interleaves with (or buries) local
// chats in the recents page. One combined fetch seeds every platform; a
// platform that exceeds this cap gets its own per-platform "load more".
export const $messagingSessions = atom<SessionInfo[]>([])
export const MESSAGING_SECTION_LIMIT = 100
// Exact per-platform conversation totals, keyed by source id. Empty until a
// per-platform "load more" fetch resolves it (the combined seed fetch only
// knows the aggregate), so sections fall back to their loaded count.
export const $messagingPlatformTotals = atom<Record<string, number>>({})
// True when the combined seed fetch hit MESSAGING_SECTION_LIMIT, so at least
// one platform may have more rows on disk than were loaded.
export const $messagingTruncated = atom<boolean>(false)
// Listable conversation count per profile (children excluded), keyed by profile
// name. Lets the sidebar scope its "Load more" footer to the active profile so a
// huge default profile doesn't keep "Load more" visible while browsing a small
@@ -129,6 +143,10 @@ export const setGatewayState = (next: Updater<string>) => updateAtom($gatewaySta
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
export const setMessagingSessions = (next: Updater<SessionInfo[]>) => updateAtom($messagingSessions, next)
export const setMessagingPlatformTotals = (next: Updater<Record<string, number>>) =>
updateAtom($messagingPlatformTotals, next)
export const setMessagingTruncated = (next: Updater<boolean>) => updateAtom($messagingTruncated, next)
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
updateAtom($sessionProfileTotals, next)
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
+2 -1
View File
@@ -88,7 +88,8 @@ function isUpdateToastSnoozed(): boolean {
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
// against. The backend reports its own value in session runtime info; a lower
// value (or none — a pre-GUI checkout) means GUI<->backend skew.
const REQUIRED_BACKEND_CONTRACT = 1
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
const REQUIRED_BACKEND_CONTRACT = 2
const SKEW_TOAST_ID = 'backend-contract-skew'
/**
+8
View File
@@ -297,6 +297,14 @@ export interface SessionInfo {
started_at: number
title: null | string
tool_call_count: number
/** Origin platform when this session was handed off from a messaging
* platform (e.g. a Telegram thread continued in the desktop app). The live
* {@link source} becomes local (tui/desktop) after a handoff, so the origin
* is preserved here to surface the platform badge on the row. */
handoff_platform?: null | string
/** Handoff lifecycle: 'pending' | 'in_progress' | 'completed' | 'failed'. */
handoff_state?: null | string
handoff_error?: null | string
/** Owning profile name, set by the cross-profile aggregator
* (`/api/profiles/sessions`). Absent on legacy single-profile responses,
* which the UI treats as the default profile. */
+2 -2
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
+92 -52
View File
@@ -6218,27 +6218,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
choices visible and lets the normal Enter key binding submit the typed
or highlighted choice.
**Platform note (Windows dead-lock issue #30768):**
The queue-based modal relies on prompt_toolkit key bindings receiving
keyboard events and calling ``_submit_slash_confirm_response``. On
Windows (PowerShell / Windows Terminal) the prompt_toolkit input
channel can become unresponsive when the modal is entered from the
``process_loop`` daemon thread, causing a dead-lock: the user sees the
confirmation panel but keystrokes never reach the key bindings and the
``response_queue.get()`` blocks until the 120-second timeout expires.
**Platform note (Windows issue #33961):**
Earlier code bypassed the modal on ``sys.platform == "win32"`` and fell
back to a raw ``input()`` prompt. When the confirm was triggered from the
``process_loop`` daemon thread (the normal case) that ``input()`` ran off
the main thread and deadlocked against prompt_toolkit's stdin ownership —
the user saw a frozen cursor and Ctrl-C was swallowed (bare ``/reset``
froze; ``/reset now`` worked only because it skips the prompt entirely).
To avoid this, we fall back to ``_prompt_text_input`` (a simple
``input()``-based prompt) when any of these conditions hold:
* ``sys.platform == "win32"`` native Windows console (ConPTY /
win32_input) does not support the modal reliably.
* ``self._app`` is not set unit tests / non-interactive contexts.
On non-Windows platforms the modal itself is still safe from the
``process_loop`` daemon thread as long as the main-thread event loop
owns the prompt_toolkit buffer mutations. When we are off the main
thread, schedule the modal snapshot / restore work on ``self._app.loop``
via ``call_soon_threadsafe`` and keep the queue-based response path.
Native Windows now uses the same path as Linux/macOS: the modal is set up
on ``self._app.loop`` via ``call_soon_threadsafe`` and answered by the
normal prompt_toolkit key bindings (the same input channel that already
handles ordinary typing on Windows). The raw ``input()`` fallback is kept
only for the genuinely safe cases: no running app (unit tests /
non-interactive), no resolvable event loop, or a scheduling failure.
"""
import threading
import time as _time
@@ -6251,23 +6244,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if not getattr(self, "_app", None):
return self._prompt_text_input("Choice [1/2/3]: ")
# On Windows the prompt_toolkit input channel can deadlock when the
# modal is entered from the process_loop daemon thread — keystrokes
# never reach the key bindings, so response_queue.get() blocks for
# the full timeout (issue #30768). Fall back to the simpler
# stdin-based prompt which works reliably on Windows.
if sys.platform == "win32":
return self._prompt_text_input("Choice [1/2/3]: ")
try:
app_loop = self._app.loop
except Exception:
app_loop = None
in_main_thread = threading.current_thread() is threading.main_thread()
if not in_main_thread and app_loop is None:
def _stdin_fallback() -> str | None:
# On native Windows a raw input() from a non-main thread deadlocks
# against prompt_toolkit's stdin ownership (#33961). With an app
# running we cannot safely prompt off the main thread, so cancel
# cleanly (None) rather than hang the terminal.
if sys.platform == "win32" and not in_main_thread:
self._invalidate()
return None
return self._prompt_text_input("Choice [1/2/3]: ")
if not in_main_thread and app_loop is None:
return _stdin_fallback()
response_queue = queue.Queue()
def _setup_modal() -> None:
@@ -6307,7 +6303,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
return ready.wait(timeout=5)
if not _run_on_app_loop(_setup_modal):
return self._prompt_text_input("Choice [1/2/3]: ")
return _stdin_fallback()
_last_countdown_refresh = _time.monotonic()
try:
@@ -7306,24 +7302,66 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._handle_browser_command(cmd_original)
elif canonical == "plugins":
try:
from hermes_cli.plugins import get_plugin_manager
mgr = get_plugin_manager()
plugins = mgr.list_plugins()
if not plugins:
print("No plugins installed.")
print(f"Drop plugin directories into {display_hermes_home()}/plugins/ to get started.")
# Discover from disk (bundled + user), matching `hermes plugins
# list` — so installed-but-not-enabled plugins are visible here
# too. The plugin manager only knows about *loaded* plugins, so
# using it alone made freshly-installed, not-yet-enabled plugins
# look like "nothing installed".
from hermes_cli.plugins_cmd import (
_discover_all_plugins,
_get_disabled_set,
_get_enabled_set,
_plugin_status,
)
entries = _discover_all_plugins()
enabled = _get_enabled_set()
disabled = _get_disabled_set()
# `/plugins` is a quick glance — default to user-installed
# plugins (what the user actually added). Bundled provider/
# platform plugins are summarized on one line; the full
# catalog lives behind `hermes plugins list`.
user_entries = [e for e in entries if e[3] != "bundled"]
bundled_count = len(entries) - len(user_entries)
if not user_entries:
print("No user plugins installed.")
print(" Install one: hermes plugins install owner/repo")
print(f" Or drop a plugin directory into {display_hermes_home()}/plugins/")
if bundled_count:
print(f" ({bundled_count} bundled plugins available — see: hermes plugins list)")
else:
print(f"Plugins ({len(plugins)}):")
for p in plugins:
status = "" if p["enabled"] else ""
version = f" v{p['version']}" if p["version"] else ""
tools = f"{p['tools']} tools" if p["tools"] else ""
hooks = f"{p['hooks']} hooks" if p["hooks"] else ""
commands = f"{p['commands']} commands" if p.get("commands") else ""
parts = [x for x in [tools, hooks, commands] if x]
detail = f" ({', '.join(parts)})" if parts else ""
error = f"{p['error']}" if p["error"] else ""
print(f" {status} {p['name']}{version}{detail}{error}")
# Loaded-plugin details (tools/hooks/commands counts, errors)
# keyed by name, when available.
loaded: dict = {}
try:
from hermes_cli.plugins import get_plugin_manager
for p in get_plugin_manager().list_plugins():
loaded[p["name"]] = p
except Exception:
loaded = {}
print(f"User plugins ({len(user_entries)}):")
for name, version, _desc, source, _dir, key in sorted(user_entries):
state = _plugin_status(name, enabled, disabled, key=key)
glyph = {"enabled": "", "disabled": ""}.get(state, "")
ver = f" v{version}" if version else ""
info = loaded.get(name) or {}
bits = []
if info.get("tools"):
bits.append(f"{info['tools']} tools")
if info.get("hooks"):
bits.append(f"{info['hooks']} hooks")
if info.get("commands"):
bits.append(f"{info['commands']} commands")
detail = f" ({', '.join(bits)})" if bits else ""
label = "" if state == "enabled" else f" [{state}]"
error = f"{info['error']}" if info.get("error") else ""
print(f" {glyph} {name}{ver}{label}{detail}{error}")
if bundled_count:
print(f" (+{bundled_count} bundled — see: hermes plugins list)")
print(" Enable/disable: hermes plugins enable/disable <name>")
except Exception as e:
print(f"Plugin system error: {e}")
elif canonical == "rollback":
@@ -8223,9 +8261,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
print(" ⚠️ MCP reload timed out (30s). Some servers may not have reconnected.")
# Inline-skip tokens that bypass the destructive-slash confirmation modal.
# Matches the escape-hatch pattern users on broken modal platforms
# (currently native Windows PowerShell — issue #30768) need to self-serve
# without having to flip approvals.destructive_slash_confirm in config.
# A general escape hatch for non-interactive use (scripting/automation) and
# for the degraded path where the modal can't be marshaled onto the app loop
# — lets users self-serve without flipping approvals.destructive_slash_confirm
# in config. (Native Windows now drives the modal normally — see #33961.)
_DESTRUCTIVE_SKIP_TOKENS = frozenset({"now", "--yes", "-y"})
@classmethod
@@ -8283,8 +8322,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
Inline-skip: if ``cmd_original`` contains ``now``, ``--yes``, or
``-y`` as an argument (e.g. ``/reset now``, ``/new --yes My title``),
the modal is bypassed and ``"once"`` is returned immediately. This is
an escape hatch for platforms where the prompt_toolkit modal hangs
(issue #30768 — native Windows PowerShell). Callers are responsible
an escape hatch for non-interactive use and for the degraded path where
the modal can't be marshaled onto the app loop (native Windows itself now
drives the modal normally see #33961). Callers are responsible
for stripping the skip tokens from any remaining argument parsing
(see :meth:`_split_destructive_skip`).
+39 -28
View File
@@ -3510,35 +3510,46 @@ class APIServerAdapter(BasePlatformAdapter):
loop = asyncio.get_running_loop()
def _run():
agent = self._create_agent(
ephemeral_system_prompt=ephemeral_system_prompt,
session_id=session_id,
stream_delta_callback=stream_delta_callback,
tool_progress_callback=tool_progress_callback,
tool_start_callback=tool_start_callback,
tool_complete_callback=tool_complete_callback,
gateway_session_key=gateway_session_key,
from gateway.session_context import clear_session_vars, set_session_vars
tokens = set_session_vars(
platform="api_server",
chat_id=session_id or "",
session_key=gateway_session_key or session_id or "",
session_id=session_id or "",
)
if agent_ref is not None:
agent_ref[0] = agent
effective_task_id = session_id or str(uuid.uuid4())
result = agent.run_conversation(
user_message=user_message,
conversation_history=conversation_history,
task_id=effective_task_id,
)
usage = {
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
}
# Include the effective session ID in the result so callers
# (e.g. X-Hermes-Session-Id header) can track compression-
# triggered session rotations. (#16938)
_eff_sid = getattr(agent, "session_id", session_id)
if isinstance(_eff_sid, str) and _eff_sid:
result["session_id"] = _eff_sid
return result, usage
try:
agent = self._create_agent(
ephemeral_system_prompt=ephemeral_system_prompt,
session_id=session_id,
stream_delta_callback=stream_delta_callback,
tool_progress_callback=tool_progress_callback,
tool_start_callback=tool_start_callback,
tool_complete_callback=tool_complete_callback,
gateway_session_key=gateway_session_key,
)
if agent_ref is not None:
agent_ref[0] = agent
effective_task_id = session_id or str(uuid.uuid4())
result = agent.run_conversation(
user_message=user_message,
conversation_history=conversation_history,
task_id=effective_task_id,
)
usage = {
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
}
# Include the effective session ID in the result so callers
# (e.g. X-Hermes-Session-Id header) can track compression-
# triggered session rotations. (#16938)
_eff_sid = getattr(agent, "session_id", session_id)
if isinstance(_eff_sid, str) and _eff_sid:
result["session_id"] = _eff_sid
return result, usage
finally:
clear_session_vars(tokens)
return await loop.run_in_executor(None, _run)
+5 -2
View File
@@ -1795,9 +1795,12 @@ class BasePlatformAdapter(ABC):
# Whether this platform renders triple-backtick fenced code blocks (i.e.
# ``format_message`` translates/preserves markdown fences into a real code
# block). Drives presentation choices like rendering a ``terminal`` tool
# call's command as a ```bash block instead of a flat preview line.
# block). Capability flag for markdown-aware presentation choices.
# Default False (plain-text platforms); markdown-rendering adapters set True.
# Tool-progress uses this to render a terminal command as a bare fenced code
# block (no language tag — Slack mrkdwn would print the tag as a literal
# first code line). Plain-text platforms fall back to the short truncated
# preview (see gateway/run.py progress_callback).
supports_code_blocks: bool = False
def __init__(self, config: PlatformConfig, platform: Platform):
+10 -2
View File
@@ -181,6 +181,8 @@ def _strip_mdv2(text: str) -> str:
"""
# Remove escape backslashes before special characters
cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text)
# Remove standard markdown bold (**text** → text) BEFORE MarkdownV2 bold
cleaned = re.sub(r'\*\*([^*]+)\*\*', r'\1', cleaned)
# Remove MarkdownV2 bold markers that format_message converted from **bold**
cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned)
# Remove MarkdownV2 italic markers that format_message converted from *italic*
@@ -2208,11 +2210,17 @@ class TelegramAdapter(BasePlatformAdapter):
# "Message is not modified" is a no-op, not an error
if "not modified" in str(fmt_err).lower():
return SendResult(success=True, message_id=message_id)
# Fallback: retry without markdown formatting
# Fallback: strip MarkdownV2 escapes and retry as clean plain text
logger.warning(
"[%s] MarkdownV2 edit failed, falling back to plain text: %s",
self.name,
fmt_err,
)
_plain = _strip_mdv2(content) if content else content
await self._bot.edit_message_text(
chat_id=int(chat_id),
message_id=int(message_id),
text=content,
text=_plain,
)
return SendResult(success=True, message_id=message_id)
except Exception as e:
+271 -175
View File
@@ -157,6 +157,12 @@ _YB_RES_REF_RE = re.compile(
r"\[(image|voice|video|file(?::[^|\]]*)?)\|ybres:([A-Za-z0-9_\-]+)\]"
)
# Patched local-media anchors once an inbound resource has been downloaded to the local cache.
# [image: /opt/data/image_cache/img_xxx.bmp]
# [file: report.pdf → /opt/data/.../report.pdf]
# (and any future kind, e.g. [video: /opt/.../clip.mp4])
_YB_LOCAL_MEDIA_RE = re.compile(r"\[(\w+):[^\]]*?(/[^\]]+?)\s*\]")
# Media kinds that can be resolved and injected into the model context
_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file"})
@@ -940,7 +946,11 @@ class InboundContext:
reply_to_text: Optional[str] = None
quote_media_refs: list = dc_field(default_factory=list) # List of (rid, kind, filename)
# Populated by MediaResolveMiddleware
# Populated by MediaResolveMiddleware. Combined list of resolved local
# paths from up to three sources (deduped, in this order):
# 1) media carried by the current message (always),
# 2) media from the quoted message (when reply_to_message_id is set),
# 3) recent group-observed media (only when chat_type == "group" and no quote is present).
media_urls: list = dc_field(default_factory=list)
media_types: list = dc_field(default_factory=list)
@@ -1685,10 +1695,10 @@ class ExtractContentMiddleware(InboundMiddleware):
"""Extract plain text content from MsgBody.
- TIMTextElem -> text field
- TIMImageElem -> "[image]"
- TIMFileElem -> "[file: {filename}]"
- TIMSoundElem -> "[voice]"
- TIMVideoFileElem -> "[video]"
- TIMImageElem -> "[image]" / "[image|ybres:RID]"
- TIMFileElem -> "[file: {filename}]" / "[file:{name}|ybres:RID]"
- TIMSoundElem -> "[voice]" / "[voice|ybres:RID]"
- TIMVideoFileElem -> "[video]" / "[video|ybres:RID]"
- TIMFaceElem -> "[emoji: {name}]" or "[emoji]"
- TIMCustomElem -> try to extract data field, otherwise "[custom message]"
- Multiple elems joined with spaces
@@ -2187,51 +2197,72 @@ class QuoteContextMiddleware(InboundMiddleware):
name = "quote-context"
@staticmethod
def _extract_quote_context(cloud_custom_data: str) -> Tuple[Optional[str], Optional[str], list]:
"""Extract quote context, mapping to MessageEvent.reply_to_*.
Returns:
(reply_to_message_id, reply_to_text, quote_media_refs)
where quote_media_refs is a list of (rid, kind, filename) tuples
def _extract_quote_context(self, cloud_custom_data: str) -> Tuple[Optional[str], Optional[str]]:
"""Extract quote text context, mapping to MessageEvent.reply_to_*.
"""
if not cloud_custom_data:
return None, None, []
return None, None
try:
parsed = json.loads(cloud_custom_data)
except (json.JSONDecodeError, TypeError):
return None, None, []
return None, None
quote = parsed.get("quote") if isinstance(parsed, dict) else None
if not isinstance(quote, dict):
return None, None, []
# type=2 corresponds to image reference; desc may be empty, provide a placeholder.
quote_type = int(quote.get("type") or 0)
desc = str(quote.get("desc") or "").strip()
if quote_type == 2 and not desc:
desc = "[image]"
if not desc:
return None, None, []
return None, None
quote_id = str(quote.get("id") or "").strip() or None
desc = str(quote.get("desc") or "").strip()
sender = str(quote.get("sender_nickname") or quote.get("sender_id") or "").strip()
quote_text = f"{sender}: {desc}" if sender else desc
quote_text = (f"{sender}: {desc}" if sender else desc) if desc else None
# Extract media references from desc using _YB_RES_REF_RE regex
media_refs: list = []
for m in _YB_RES_REF_RE.finditer(desc):
head = m.group(1) # "image" | "file:<name>" | "voice" | "video"
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
media_refs.append((rid, kind, filename.strip()))
return quote_id, quote_text
return quote_id, quote_text, media_refs
async def _extract_media_refs_from_transcript(
self, ctx: InboundContext
) -> List[Tuple[str, str, str]]:
"""Look up the quoted message in the transcript history and return any
``[kind|ybres:RID]`` anchors found in its content as
``(rid, kind, filename)`` tuples.
Returns ``[]`` when ``ctx.reply_to_message_id`` is unset, when the
transcript store / source is unavailable, or when the quoted message
carries no resolvable media anchors.
"""
if ctx.reply_to_message_id is None:
return []
adapter = ctx.adapter
media_refs: List[Tuple[str, str, str]] = []
try:
store = getattr(adapter, "_session_store", None)
if not store or ctx.source is None:
return []
session_entry = store.get_or_create_session(ctx.source)
history = store.load_transcript(session_entry.session_id)
for msg in reversed(history or []):
mid = msg.get("message_id", "")
if not mid or mid != ctx.reply_to_message_id:
continue
_content = msg.get("content", "")
if isinstance(_content, str) and "|ybres:" in _content:
for m in _YB_RES_REF_RE.finditer(_content):
head = m.group(1)
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind in _RESOLVABLE_MEDIA_KINDS:
media_refs.append((rid, kind, filename.strip()))
break
except Exception as exc:
logger.warning(
"[%s] quote transcript lookup failed: %s",
getattr(adapter, "name", "yuanbao"), exc,
)
return media_refs
async def handle(self, ctx: InboundContext, next_fn) -> None:
ctx.reply_to_message_id, ctx.reply_to_text, ctx.quote_media_refs = self._extract_quote_context(ctx.cloud_custom_data)
ctx.reply_to_message_id, ctx.reply_to_text = self._extract_quote_context(ctx.cloud_custom_data)
ctx.quote_media_refs = await self._extract_media_refs_from_transcript(ctx)
await next_fn()
@@ -2436,11 +2467,6 @@ class MediaResolveMiddleware(InboundMiddleware):
cls._put_cached_resource(resource_id, local_path, mime)
return local_path, mime
@classmethod
async def _resolve_by_resource_id(cls, adapter, resource_id: str) -> str:
"""Exchange a Yuanbao ``resourceId`` for a short-lived direct download URL. Raises on failure."""
return await cls._fetch_resource_url(adapter, resource_id)
@classmethod
async def _resolve_media_urls(
cls, adapter, media_refs: List[Dict[str, str]]
@@ -2456,6 +2482,7 @@ class MediaResolveMiddleware(InboundMiddleware):
for ref in media_refs:
kind = str(ref.get("kind") or "").strip().lower()
url = str(ref.get("url") or "").strip()
filename = str(ref.get("name") or "").strip()
if kind not in _RESOLVABLE_MEDIA_KINDS or not url:
continue
@@ -2475,7 +2502,7 @@ class MediaResolveMiddleware(InboundMiddleware):
adapter,
fetch_url=fetch_url,
kind=kind,
file_name=str(ref.get("name") or "").strip() or None,
file_name=filename or None,
log_tag=f"placeholder_url={url[:80]}",
resource_id=rid,
)
@@ -2487,6 +2514,44 @@ class MediaResolveMiddleware(InboundMiddleware):
return media_urls, media_types
@classmethod
async def _resolve_ybres_refs(
cls,
adapter,
refs: List[Tuple[str, str, str]],
*,
log_prefix: str,
) -> Tuple[List[str], List[str]]:
"""Resolve a list of ``(rid, kind, filename)`` ybres tuples to local paths.
"""
media_paths: List[str] = []
mimes: List[str] = []
for rid, kind, filename in refs:
if kind not in _RESOLVABLE_MEDIA_KINDS:
continue
try:
fresh_url = await cls._fetch_resource_url(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] %s resolve failed: rid=%s kind=%s err=%s",
adapter.name, log_prefix, rid, kind, exc,
)
continue
cached = await cls._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"{log_prefix} rid={rid}",
resource_id=rid,
)
if cached is None:
continue
path, mime = cached
media_paths.append(path)
mimes.append(mime)
return media_paths, mimes
@classmethod
async def _collect_observed_media(
cls, adapter, source,
@@ -2533,42 +2598,178 @@ class MediaResolveMiddleware(InboundMiddleware):
if not order:
return [], []
media_paths: List[str] = []
return await cls._resolve_ybres_refs(
adapter, order, log_prefix="observed-media",
)
@classmethod
async def _resolve_quote_media(
cls, adapter, quote_media_refs: List[Tuple[str, str, str]],
) -> Tuple[List[str], List[str]]:
"""Resolve media anchors carried by the quoted message.
``quote_media_refs`` is a list of ``(rid, kind, filename)`` tuples
produced by :class:`QuoteContextMiddleware` from the transcript.
"""
return await cls._resolve_ybres_refs(
adapter, quote_media_refs, log_prefix="quote",
)
@staticmethod
def _collect_quote_local_media(ctx: InboundContext) -> Tuple[List[str], List[str]]:
"""Private-chat fallback for recovering already-local quoted media.
Only already-local media is handled here: by the time a turn is cached,
``PatchAnchorsMiddleware`` has rewritten resolved ``|ybres:`` anchors to
``[image: /path]`` / ``[file: name /path]``. Unresolved anchors are an
original-turn resolution failure and belong to that turn's handling, not
this quote fallback so no re-download happens here.
Returns ``(local_paths, mimes)`` for media already downloaded to the
local cache on its original turn, ready to inject as-is.
"""
paths: List[str] = []
mimes: List[str] = []
for rid, kind, filename in order:
try:
fresh_url = await cls._resolve_by_resource_id(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] observed-media resolve failed: rid=%s kind=%s err=%s",
adapter.name, rid, kind, exc,
)
rid_key = ctx.reply_to_message_id
if not rid_key:
return paths, mimes
cache = getattr(ctx.adapter, "_msg_content_cache", None)
if not cache:
return paths, mimes
text = cache.get(rid_key)
if not isinstance(text, str) or not text:
return paths, mimes
# Already-local media paths written by PatchAnchorsMiddleware. The
# generic anchor regex covers every kind _patch emits (image/file today,
# video/audio if they later become resolvable) without per-kind upkeep.
seen: set = set()
for m in _YB_LOCAL_MEDIA_RE.finditer(text):
kind = (m.group(1) or "").strip().lower()
path = (m.group(2) or "").strip()
if not path or path in seen:
continue
cached = await cls._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"rid={rid}",
resource_id=rid,
if not os.path.exists(path):
continue
seen.add(path)
mime = guess_mime_type(os.path.basename(path)) or (
"image/jpeg" if kind == "image" else "application/octet-stream"
)
if cached is None:
continue
path, mime = cached
media_paths.append(path)
paths.append(path)
mimes.append(mime)
return media_paths, mimes
return paths, mimes
async def handle(self, ctx: InboundContext, next_fn) -> None:
# NOTE: Reaching this middleware in a group chat implies the message has
# @-mentioned the bot (or is an owner command). GroupAtGuardMiddleware
# short-circuits non-@bot group messages earlier in the pipeline, so we
# don't need to re-check @bot status here before downloading media.
adapter = ctx.adapter
ctx.media_urls, ctx.media_types = await self._resolve_media_urls(adapter, ctx.media_refs)
# Re-check placeholder after media resolution
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, len(ctx.media_urls)):
urls: List[str] = []
types: List[str] = []
seen: set = set()
def _add_unique_pairs(pair_lists: Tuple[List[str], List[str]]) -> None:
u_list, m_list = pair_lists
for u, m in zip(u_list, m_list):
if not u or u in seen:
continue
seen.add(u)
urls.append(u)
types.append(m)
# 1) Media carried by the current message itself.
own_pairs = await self._resolve_media_urls(adapter, ctx.media_refs)
own_count = sum(1 for u in own_pairs[0] if u)
_add_unique_pairs(own_pairs)
# 2) Second source — quoted media takes priority; otherwise fall back
# to observed-media backfill in groups only (DMs already had their
# media resolved on the turn it was sent).
if ctx.reply_to_message_id is not None:
if ctx.quote_media_refs:
_add_unique_pairs(await self._resolve_quote_media(adapter, ctx.quote_media_refs))
else:
# DM quote fallback: no transcript message_id match (DM user rows
# carry no platform message_id), so recover already-local media
# from the adapter msg cache. Patched on its original turn — no
# re-download needed, inject as-is.
_add_unique_pairs(self._collect_quote_local_media(ctx))
elif ctx.chat_type == "group":
# Group chats: only @-bot turns reach this middleware
# (see GroupAtGuardMiddleware note at top of handle()),
# so unconditional observed-media hydration is safe here.
try:
_add_unique_pairs(await self._collect_observed_media(adapter, ctx.source))
except Exception as exc:
logger.warning(
"[%s] observed-image hydration raised, continuing anyway: %s",
adapter.name, exc,
)
ctx.media_urls = urls
ctx.media_types = types
# Re-check placeholder after media resolution.
# Use ``own_count`` (not ``len(urls)``) to preserve the original
# semantics: a placeholder text accompanied only by quote/observed
# media (i.e. no fresh attachment of its own) is still skippable.
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, own_count):
logger.debug("[%s] Skip placeholder after media download: %r", adapter.name, ctx.raw_text)
return # Stop pipeline
await next_fn()
class PatchAnchorsMiddleware(InboundMiddleware):
"""Replace ``[kind|ybres:RID]`` anchors in ``ctx.raw_text`` with local paths.
Runs after :class:`MediaResolveMiddleware` so that ``ctx.media_urls`` /
``ctx.media_types`` are already populated with downloaded resources
(own media + quote media or group-observed media). The transcript
written downstream then records usable local paths for the model
instead of opaque ``ybres:`` references.
Only resolved media (paths starting with ``/``) are substituted; any
anchor without a corresponding local resource is left untouched.
"""
name = "patch-anchors"
@staticmethod
def _patch(text: str, urls: List[str], types: List[str]) -> str:
if not text or not urls:
return text
patched = text
for u, m in zip(urls, types):
if not u.startswith("/"):
continue
anchor_match = _YB_RES_REF_RE.search(patched)
if not anchor_match:
break
head = anchor_match.group(1)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind == "image" and m.startswith("image/"):
replacement = f"[image: {u}]"
elif kind == "file":
label = filename.strip() or os.path.basename(u)
replacement = f"[file: {label}{u}]"
else:
continue
patched = (
patched[: anchor_match.start()]
+ replacement
+ patched[anchor_match.end():]
)
return patched
async def handle(self, ctx: InboundContext, next_fn) -> None:
ctx.raw_text = self._patch(ctx.raw_text, ctx.media_urls, ctx.media_types)
await next_fn()
class DispatchMiddleware(InboundMiddleware):
"""Build MessageEvent and dispatch to AI handler."""
@@ -2584,124 +2785,18 @@ class DispatchMiddleware(InboundMiddleware):
)
async def _dispatch_inbound_event() -> None:
media_urls = list(ctx.media_urls)
media_types = list(ctx.media_types)
# If user quoted a message (reply_to_message_id is set), resolve only
# quote_media_refs to avoid injecting unrelated history media.
# Otherwise, backfill observed media from recent transcript history.
if ctx.reply_to_message_id is not None:
# Fallback: if desc didn't contain ybres refs, look up transcript
if not ctx.quote_media_refs:
try:
store = getattr(adapter, "_session_store", None)
if store:
session_entry = store.get_or_create_session(ctx.source)
history = store.load_transcript(session_entry.session_id)
for msg in reversed(history or []):
mid = msg.get("message_id", "")
if mid and mid == ctx.reply_to_message_id:
_content = msg.get("content", "")
if isinstance(_content, str) and "|ybres:" in _content:
for m in _YB_RES_REF_RE.finditer(_content):
head = m.group(1)
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind in _RESOLVABLE_MEDIA_KINDS:
ctx.quote_media_refs.append((rid, kind, filename.strip()))
break
except Exception as exc:
logger.warning(
"[%s] quote transcript lookup failed: %s",
adapter.name, exc,
)
# User quoted a message — resolve only media from the quote
for rid, kind, filename in ctx.quote_media_refs:
if kind not in _RESOLVABLE_MEDIA_KINDS:
continue
try:
fresh_url = await MediaResolveMiddleware._resolve_by_resource_id(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] quote media resolve failed: rid=%s kind=%s err=%s",
adapter.name, rid, kind, exc,
)
continue
cached = await MediaResolveMiddleware._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"quote rid={rid}",
resource_id=rid,
)
if cached is None:
continue
path, mime = cached
# Avoid duplicates
if path not in media_urls:
media_urls.append(path)
media_types.append(mime)
else:
# No quote — backfill observed media from recent transcript history
extra_img_urls: List[str] = []
extra_img_mimes: List[str] = []
try:
extra_img_urls, extra_img_mimes = await MediaResolveMiddleware._collect_observed_media(
adapter, ctx.source,
)
except Exception as exc:
logger.warning(
"[%s] observed-image hydration raised, continuing anyway: %s",
adapter.name, exc,
)
if extra_img_urls:
current = set(media_urls)
for u, m in zip(extra_img_urls, extra_img_mimes):
if u in current:
continue
media_urls.append(u)
media_types.append(m)
current.add(u)
# Replace [kind|ybres:xxx] anchors with local cache paths so
# the transcript records usable paths for the model.
_patched_event_text = ctx.raw_text
for u, m in zip(media_urls, media_types):
if not u.startswith("/"):
continue
anchor_match = _YB_RES_REF_RE.search(_patched_event_text)
if not anchor_match:
continue
head = anchor_match.group(1)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind == "image" and m.startswith("image/"):
replacement = f"[image: {u}]"
elif kind == "file":
label = filename.strip() or os.path.basename(u)
replacement = f"[file: {label}{u}]"
else:
continue
_patched_event_text = (
_patched_event_text[:anchor_match.start()]
+ replacement
+ _patched_event_text[anchor_match.end():]
)
event = MessageEvent(
text=_patched_event_text,
text=ctx.raw_text,
message_type=(
MessageType.DOCUMENT
if any(mt.startswith(("application/", "text/")) for mt in media_types)
if any(mt.startswith(("application/", "text/")) for mt in ctx.media_types)
else ctx.msg_type
),
source=ctx.source,
message_id=ctx.msg_id or None,
raw_message=ctx.push,
media_urls=media_urls,
media_types=media_types,
media_urls=list(ctx.media_urls),
media_types=list(ctx.media_types),
reply_to_message_id=ctx.reply_to_message_id,
reply_to_text=ctx.reply_to_text,
channel_prompt=ctx.channel_prompt,
@@ -2795,6 +2890,7 @@ class InboundPipelineBuilder:
ClassifyMessageTypeMiddleware,
QuoteContextMiddleware,
MediaResolveMiddleware,
PatchAnchorsMiddleware,
DispatchMiddleware,
]
+71 -16
View File
@@ -688,7 +688,18 @@ def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any:
# ordinary outputs. Only tools that intentionally create deliverable media
# artifacts should be eligible for automatic append when the model omits them
# from the final gateway reply.
_AUTO_APPEND_MEDIA_TOOL_NAMES = {"text_to_speech", "text_to_speech_tool"}
_AUTO_APPEND_MEDIA_TOOL_NAMES = {
"text_to_speech",
"text_to_speech_tool",
"image_generate",
}
# Tools in this set return their deliverable artifact as a JSON payload with a
# local-file path field rather than a literal ``MEDIA:`` tag (e.g. image_generate
# returns ``{"success": true, "image": "/abs/path.png"}``). The auto-append path
# extracts the path from these fields so delivery is deterministic and does not
# depend on the model restating the path in its final reply.
_JSON_MEDIA_TOOL_PATH_FIELDS = ("host_image", "image", "agent_visible_image")
# Extension-anchored MEDIA: matcher for tool results. Mirrors the dispatch-site
@@ -755,10 +766,28 @@ def _collect_auto_append_media_tags(
if tool_name_by_call_id.get(call_id) not in _AUTO_APPEND_MEDIA_TOOL_NAMES:
continue
content = str(msg.get("content") or "")
tool_name = tool_name_by_call_id.get(call_id)
# JSON-payload tools (image_generate) return a local-file path in a
# known field rather than a MEDIA: tag. Extract it so delivery is
# deterministic even when the model omits the path from its reply.
if tool_name == "image_generate" and "MEDIA:" not in content:
try:
payload = json.loads(content)
except Exception:
payload = None
if isinstance(payload, dict) and payload.get("success"):
for field in _JSON_MEDIA_TOOL_PATH_FIELDS:
path = payload.get(field)
if (isinstance(path, str)
and _TOOL_MEDIA_RE.fullmatch(f"MEDIA:{path}")
and path not in history_media_paths):
media_tags.append(f"MEDIA:{path}")
break
continue
if "MEDIA:" not in content:
continue
for match in _TOOL_MEDIA_RE.finditer(content):
path = match.group(1).strip().rstrip('\",}')
path = match.group(1).strip().rstrip('",}')
if path and path not in history_media_paths:
media_tags.append(f"MEDIA:{path}")
if "[[audio_as_voice]]" in content:
@@ -12972,13 +13001,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
from agent.display import get_tool_emoji
emoji = get_tool_emoji(tool_name, default="⚙️")
# Markdown-capable platforms render a terminal command as a native
# ```bash fenced block (full command, no quotes, no label, no
# truncation) instead of the noisy `terminal: "cmd…"` line. Gated
# on the adapter's ``supports_code_blocks`` capability so every
# markdown-rendering platform (and plugin adapters that opt in) gets
# it, while plain-text platforms keep the compact line.
_bash_block = None
# Markdown-capable platforms render a terminal command as a fenced
# code block instead of the compact `terminal: "cmd…"` preview.
# Gated on the adapter's ``supports_code_blocks`` capability so
# plain-text platforms keep the short line. No language tag is
# emitted — Slack mrkdwn renders the tag as a literal first code
# line ("bash"), and a bare fence renders correctly everywhere
# that supports blocks.
#
# Verbose mode shows the FULL command. Non-verbose ("all"/"new")
# modes still wrap in a fence but truncate to a single line capped
# at ``tool_preview_length`` (default 40) so a long or multi-line
# command doesn't render as a huge block — matching the budget the
# non-terminal preview path already applies (#42634).
_code_block_full = None
_code_block_short = None
try:
_progress_adapter = self.adapters.get(source.platform)
except Exception:
@@ -12990,13 +13027,27 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
and isinstance(args.get("command"), str)
and args["command"].strip()
):
_bash_block = f"```bash\n{args['command'].rstrip()}\n```"
from agent.display import get_tool_preview_max_len
_cmd_full = args["command"].rstrip()
_code_block_full = f"{emoji} {tool_name}\n```\n{_cmd_full}\n```"
# Single-line, capped preview for non-verbose modes.
_pl = get_tool_preview_max_len()
_cap = _pl if _pl > 0 else 40
_lines = _cmd_full.splitlines()
_cmd_short = _lines[0] if _lines else _cmd_full
_multiline = len(_lines) > 1
if len(_cmd_short) > _cap:
_cmd_short = _cmd_short[:_cap - 3] + "..."
elif _multiline:
_cmd_short = _cmd_short + " ..."
_code_block_short = f"{emoji} {tool_name}\n```\n{_cmd_short}\n```"
# Verbose mode: show detailed arguments, respects tool_preview_length
if progress_mode == "verbose":
if _bash_block is not None:
msg = _bash_block
elif args:
if _code_block_full is not None:
progress_queue.put(_code_block_full)
return
if args:
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
args_str = json.dumps(args, ensure_ascii=False, default=str)
@@ -13016,8 +13067,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# "all" / "new" modes: short preview, respects tool_preview_length
# config (defaults to 40 chars when unset to keep gateway messages
# compact — unlike CLI spinners, these persist as permanent messages).
if _bash_block is not None:
msg = _bash_block
# Terminal commands on markdown platforms get a single-line capped
# fenced block (built above) instead of the truncated preview.
if _code_block_short is not None:
msg = _code_block_short
elif preview:
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
@@ -13130,6 +13183,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"message_id": message_id,
"content": content,
}
if getattr(adapter, "REQUIRES_EDIT_FINALIZE", False):
kwargs["finalize"] = True
if _edit_accepts_metadata:
kwargs["metadata"] = _progress_metadata
return await adapter.edit_message(**kwargs)
+3
View File
@@ -106,6 +106,7 @@ def set_session_vars(
user_id: str = "",
user_name: str = "",
session_key: str = "",
session_id: str = "",
message_id: str = "",
cwd: str = "",
) -> list:
@@ -127,6 +128,7 @@ def set_session_vars(
_SESSION_USER_ID.set(user_id),
_SESSION_USER_NAME.set(user_name),
_SESSION_KEY.set(session_key),
_SESSION_ID.set(session_id),
_SESSION_MESSAGE_ID.set(message_id),
]
try:
@@ -157,6 +159,7 @@ def clear_session_vars(tokens: list) -> None:
_SESSION_USER_ID,
_SESSION_USER_NAME,
_SESSION_KEY,
_SESSION_ID,
_SESSION_MESSAGE_ID,
):
var.set("")
+92 -30
View File
@@ -13,6 +13,7 @@ This module provides:
"""
import copy
import json
import logging
import os
import platform
@@ -5152,6 +5153,94 @@ def load_config_readonly() -> Dict[str, Any]:
return _load_config_impl(want_deepcopy=False)
TERMINAL_CONFIG_ENV_MAP = {
"backend": "TERMINAL_ENV",
"modal_mode": "TERMINAL_MODAL_MODE",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"docker_env": "TERMINAL_DOCKER_ENV",
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS",
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
def _terminal_env_value(value: Any) -> str:
if isinstance(value, (list, dict)):
return json.dumps(value)
return str(value)
def terminal_config_env_var_for_key(key: str) -> Optional[str]:
"""Return the env var mirrored by a ``terminal.*`` config key."""
prefix = "terminal."
if not key.startswith(prefix):
return None
return TERMINAL_CONFIG_ENV_MAP.get(key[len(prefix):])
def apply_terminal_config_to_env(
*,
env: Optional[Dict[str, str]] = None,
config: Optional[Dict[str, Any]] = None,
override: Optional[bool] = None,
) -> Dict[str, str]:
"""Bridge ``terminal.*`` config into the env vars terminal tools read.
``tools.terminal_tool`` is intentionally environment-driven because it also
runs in child processes (TUI, dashboard PTY, gateway workers). This helper
gives those child-process launch paths the same config bridge as classic
CLI without importing ``cli.py`` and paying for its startup side effects.
When the user config contains a ``terminal`` section, config.yaml is
authoritative and overrides existing env values. Otherwise defaults only
backfill missing env vars so exported/.env values keep working.
"""
target = os.environ if env is None else env
raw_config = read_raw_config()
file_has_terminal_config = isinstance(raw_config.get("terminal"), dict)
should_override = file_has_terminal_config if override is None else override
cfg = config if config is not None else load_config_readonly()
terminal_cfg = cfg.get("terminal", {}) if isinstance(cfg, dict) else {}
if not isinstance(terminal_cfg, dict):
return target
for cfg_key, env_var in TERMINAL_CONFIG_ENV_MAP.items():
if cfg_key not in terminal_cfg:
continue
value = terminal_cfg[cfg_key]
if cfg_key == "cwd":
raw_cwd = str(value or "").strip()
if raw_cwd in {".", "auto", "cwd"}:
continue
if isinstance(value, str):
value = os.path.expanduser(value)
if should_override or env_var not in target:
target[env_var] = _terminal_env_value(value)
return target
def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
with _CONFIG_LOCK:
ensure_hermes_home()
@@ -6040,36 +6129,9 @@ def set_config_value(key: str, value: str):
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
_config_to_env_sync = {
"terminal.backend": "TERMINAL_ENV",
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
# JSON-valued keys (terminal_tool parses these via json.loads). The user
# passes JSON on the CLI, so str(value) below already yields valid JSON —
# same as terminal.docker_env. cli.py and gateway/run.py bridge these too.
"terminal.docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"terminal.docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
# terminal.cwd intentionally excluded — CLI resolves at runtime,
# gateway bridges it in gateway/run.py. Persisting to .env causes
# stale values to poison child processes.
"terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
"terminal.container_cpu": "TERMINAL_CONTAINER_CPU",
"terminal.container_memory": "TERMINAL_CONTAINER_MEMORY",
"terminal.container_disk": "TERMINAL_CONTAINER_DISK",
"terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
}
if key in _config_to_env_sync:
save_env_value(_config_to_env_sync[key], str(value))
env_var = terminal_config_env_var_for_key(key)
if env_var and key != "terminal.cwd":
save_env_value(env_var, _terminal_env_value(value))
print(f"✓ Set {key} = {value} in {config_path}")
+139 -12
View File
@@ -94,19 +94,36 @@ def _register_self_hosted_client(
*,
access_token: str,
portal_base_url: str,
name: str,
name: Optional[str],
custom_redirect_uri: Optional[str],
existing_client_id: Optional[str] = None,
timeout: float = 15.0,
) -> dict:
"""POST to the portal's self-hosted-client endpoint and return the JSON body.
When ``existing_client_id`` is provided (the client_id this install
persisted on a prior run), it is sent so the portal updates that existing
dashboard record in place instead of minting a duplicate this is what
makes re-running ``hermes dashboard register`` idempotent. The portal
falls back to creating a fresh client if the id no longer resolves to a row
in the caller's org (stale/deleted), so passing it is always safe.
``name`` may be ``None`` on the idempotent update path (re-run without an
explicit ``--name``): omitting it tells the portal to keep the name it
already stored rather than overwriting it. It is required on the create
path; the caller guarantees a value there.
Raises RuntimeError with a user-facing message on any non-2xx response or
transport failure.
"""
url = f"{portal_base_url.rstrip('/')}/api/oauth/self-hosted-client"
body: dict[str, str] = {"name": name}
body: dict[str, str] = {}
if name:
body["name"] = name
if custom_redirect_uri:
body["custom_redirect_uri"] = custom_redirect_uri
if existing_client_id:
body["client_id"] = existing_client_id
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
@@ -165,16 +182,20 @@ def _print_post_register_hint(
portal_base_url: str,
custom_redirect_uri: Optional[str],
wrote_portal_url: bool,
public_url: str = "",
) -> None:
"""Print the success summary + the gate-engagement caveat."""
from hermes_cli.config import get_env_path
env_path = get_env_path()
_cid = client_id
print()
print(f" Wrote to {env_path}:")
print(f" HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
print(" HERMES_DASHBOARD_OAUTH_CLIENT_ID=" + str(_cid))
if wrote_portal_url:
print(f" HERMES_DASHBOARD_PORTAL_URL={portal_base_url}")
print(" HERMES_DASHBOARD_PORTAL_URL=" + str(portal_base_url))
if public_url:
print(" HERMES_DASHBOARD_PUBLIC_URL=" + str(public_url))
print()
print(
" Heads up — Nous login only *engages* on a non-loopback bind. A plain\n"
@@ -240,12 +261,48 @@ def cmd_dashboard_register(args) -> None:
# Portal override: explicit --portal-url flag wins, else the
# HERMES_DASHBOARD_PORTAL_URL env var, else the stored login's portal.
#
# We track whether a custom URL was *explicitly supplied* (flag or env)
# separately from the resolved value. An explicit custom URL is an
# intentional choice the user wants to persist (and update in place if it
# already exists in .env); a portal merely inferred from the stored login
# keeps the older, more conservative write-only-if-absent behaviour so we
# don't clutter .env for the common production case.
portal_override = getattr(args, "portal_url", None) or os.environ.get(
"HERMES_DASHBOARD_PORTAL_URL"
)
custom_portal_supplied = bool(
isinstance(portal_override, str) and portal_override.strip()
)
portal_base_url = _resolve_portal_base_url(portal_override)
name = getattr(args, "name", None) or _generate_dashboard_name()
# Idempotency: if this install already registered a dashboard, we hold its
# client_id locally (HERMES_DASHBOARD_OAUTH_CLIENT_ID). Re-send it so the
# portal UPDATES that existing record instead of creating a duplicate. No
# stored client_id -> this is a first registration -> create a fresh one
# (the original behavior). This mirrors the portal's rule: no client id =
# new dashboard; client id present = the stable key of the row to modify.
existing_client_id = None
try:
existing_client_id = get_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID")
except Exception:
existing_client_id = None
if isinstance(existing_client_id, str):
existing_client_id = existing_client_id.strip() or None
else:
existing_client_id = None
explicit_name = getattr(args, "name", None)
# Auto-generate a random name ONLY for a first registration. On a re-run
# (we hold a client_id) without an explicit --name, keep the name the
# portal already stored rather than churning it to a new random value
# every time — so leave `name` unset and let the portal preserve it.
if explicit_name:
name = explicit_name
elif existing_client_id:
name = None
else:
name = _generate_dashboard_name()
custom_redirect_uri = getattr(args, "redirect_uri", None)
# 2. Register with the portal.
@@ -255,20 +312,26 @@ def cmd_dashboard_register(args) -> None:
portal_base_url=portal_base_url,
name=name,
custom_redirect_uri=custom_redirect_uri,
existing_client_id=existing_client_id,
)
except RuntimeError as exc:
print(f"✗ Registration failed: {exc}")
sys.exit(1)
client_id = str(result["client_id"])
registered_name = str(result.get("name") or name)
registered_name = str(result.get("name") or name or "")
print(f'✓ Registered dashboard "{registered_name}"')
# Distinguish create vs update for the user: the portal echoes back the
# same client_id we sent when it updated in place.
updated_existing = bool(
existing_client_id and client_id == existing_client_id
)
if updated_existing:
print(f'✓ Updated dashboard "{registered_name}"')
else:
print(f'✓ Registered dashboard "{registered_name}"')
# 3. Write env vars idempotently. Always set the client_id. Only set the
# portal URL when it isn't already configured (env or config) AND differs
# from the production default, so we don't clutter .env for the common case
# but DO persist a non-default portal (e.g. a preview deploy used in dev).
# 3. Write env vars idempotently. Always set the client_id.
try:
save_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID", client_id)
except Exception as exc:
@@ -276,6 +339,18 @@ def cmd_dashboard_register(args) -> None:
print(f" Set it manually: HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
sys.exit(1)
# Persist the portal URL. Two cases:
# a) The user explicitly supplied a custom portal (--portal-url flag or
# HERMES_DASHBOARD_PORTAL_URL env). That's an intentional choice we
# always persist so it survives across sessions — overwriting any
# existing entry in place (save_env_value updates a matching key
# rather than appending a duplicate). This is true even when it equals
# the production default: the user asked for it explicitly.
# b) No custom portal was supplied. Keep the older conservative behaviour:
# only write a portal inferred from the stored login when it isn't
# already configured AND differs from the production default, so we
# don't clutter .env for the common production case and don't alter an
# existing entry unexpectedly.
wrote_portal_url = False
default_portal = "https://portal.nousresearch.com"
existing_portal = None
@@ -283,7 +358,15 @@ def cmd_dashboard_register(args) -> None:
existing_portal = get_env_value("HERMES_DASHBOARD_PORTAL_URL")
except Exception:
existing_portal = None
if not existing_portal and portal_base_url.rstrip("/") != default_portal:
if custom_portal_supplied:
should_write_portal = existing_portal != portal_base_url
else:
should_write_portal = (
not existing_portal and portal_base_url.rstrip("/") != default_portal
)
if should_write_portal:
try:
save_env_value("HERMES_DASHBOARD_PORTAL_URL", portal_base_url)
wrote_portal_url = True
@@ -291,10 +374,54 @@ def cmd_dashboard_register(args) -> None:
# Non-fatal: the client_id is the load-bearing value.
pass
# Persist the dashboard public URL derived from the OAuth redirect URI.
#
# --redirect-uri is the full public HTTPS callback the user registered with
# the portal, e.g. https://hermes.example.com/auth/callback. At serve time
# the dashboard auth layer (dashboard_auth/routes._redirect_uri) reconstructs
# that same callback by taking HERMES_DASHBOARD_PUBLIC_URL and appending
# "/auth/callback" verbatim. So the value the runtime actually consumes is
# the ORIGIN (scheme://host[:port]), not the full callback path — persisting
# the raw redirect URI would double up the path. We derive the origin from
# the supplied redirect URI and persist it as HERMES_DASHBOARD_PUBLIC_URL so
# the operator doesn't have to re-supply it and the public-URL override is
# actually wired (the gate engages and the callback round-trips correctly).
#
# Like the portal URL, an explicitly supplied value is always written
# (updating an existing entry in place rather than appending a duplicate),
# a no-op when it already matches, and never written on a localhost-only
# install (no --redirect-uri).
wrote_public_url = False
public_url = ""
if custom_redirect_uri:
try:
from urllib.parse import urlparse
parsed = urlparse(custom_redirect_uri)
if parsed.scheme in ("http", "https") and parsed.netloc:
public_url = f"{parsed.scheme}://{parsed.netloc}"
except Exception:
public_url = ""
if public_url:
existing_public_url = None
try:
existing_public_url = get_env_value("HERMES_DASHBOARD_PUBLIC_URL")
except Exception:
existing_public_url = None
if existing_public_url != public_url:
try:
save_env_value("HERMES_DASHBOARD_PUBLIC_URL", public_url)
wrote_public_url = True
except Exception:
# Non-fatal: the client_id is the load-bearing value.
pass
# 4. Hint.
_print_post_register_hint(
client_id=client_id,
portal_base_url=portal_base_url,
custom_redirect_uri=custom_redirect_uri,
wrote_portal_url=wrote_portal_url,
public_url=public_url if wrote_public_url else "",
)
+12 -4
View File
@@ -738,11 +738,14 @@ def run_doctor(args):
issues,
)
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them.
# Vendor/model slugs are valid on aggregator-style providers and on any custom
# provider — bare "custom" or a named "custom:<name>" that fronts an OpenAI-compatible
# aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.
provider_for_policy = runtime_provider or catalog_provider
provider_policy_id = str(provider_for_policy or "").strip().lower()
providers_accepting_vendor_slugs = {
"openrouter",
"custom",
"auto",
"kilocode",
"opencode-zen",
@@ -750,11 +753,16 @@ def run_doctor(args):
"lmstudio",
"nous",
}
provider_accepts_vendor_slug = (
provider_policy_id in providers_accepting_vendor_slugs
or provider_policy_id == "custom"
or provider_policy_id.startswith("custom:")
)
if (
default_model
and "/" in default_model
and provider_for_policy
and provider_for_policy not in providers_accepting_vendor_slugs
and provider_policy_id
and not provider_accepts_vendor_slug
):
check_warn(
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
+50
View File
@@ -1825,6 +1825,11 @@ def _launch_tui(
import tempfile
env = os.environ.copy()
try:
from hermes_cli.config import apply_terminal_config_to_env
apply_terminal_config_to_env(env=env)
except Exception:
logger.debug("Failed to apply terminal config bridge for TUI launch", exc_info=True)
active_session_fd, active_session_file = tempfile.mkstemp(
prefix="hermes-tui-active-session-", suffix=".json"
)
@@ -5221,12 +5226,31 @@ def cmd_gui(args: argparse.Namespace):
# is still locked by a running instance; stop it before retry.
_stop_desktop_processes_locking_build(desktop_dir)
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if build_result.returncode != 0 and not source_mode and not env.get("ELECTRON_MIRROR"):
# Still failing and the user hasn't pinned a mirror: GitHub's
# Electron release host is likely blocked/throttled (the repeating
# "retrying" download log). Retry once via npmmirror.com — the
# de-facto Electron community mirror (Alibaba). @electron/get
# SHASUM-checks the download, but the SHASUMS come from the same
# mirror, so that guards against a corrupt/partial download, NOT
# a compromised mirror: reaching for it is an explicit trust
# trade-off we only make AFTER the canonical GitHub download has
# failed, and we never override a user-pinned ELECTRON_MIRROR.
print(" ⚠ Desktop build still failing; the Electron download from "
"GitHub looks blocked. Retrying once via a public mirror "
"(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)")
mirror_env = dict(env)
mirror_env["ELECTRON_MIRROR"] = "https://npmmirror.com/mirrors/electron/"
_stop_desktop_processes_locking_build(desktop_dir)
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False)
if build_result.returncode != 0:
print("✗ Desktop GUI build failed")
print(f" Run manually: cd apps/desktop && npm run {build_script}")
if sys.platform == "win32":
print(" If this says \"Access is denied\" on Hermes.exe, close any")
print(" running Hermes desktop window and retry.")
print(" If the log shows Electron download retries, rebuild via a mirror:")
print(" ELECTRON_MIRROR=<mirror-base-url> hermes desktop --force-build")
sys.exit(build_result.returncode or 1)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if not source_mode:
@@ -5806,6 +5830,16 @@ def _update_via_zip(args):
except Exception:
pass
# Seed the model-catalog disk cache from the freshly-unpacked checkout
# (same rationale as the git-pull path in _cmd_update_impl). Non-fatal.
try:
from hermes_cli.model_catalog import seed_cache_from_checkout
if seed_cache_from_checkout(PROJECT_ROOT):
print(" ✓ Model catalog cache refreshed from checkout")
except Exception as e:
logger.debug("Model catalog seed during zip update failed: %s", e)
print()
print("✓ Update complete!")
try:
@@ -8365,6 +8399,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
print()
print("✓ Code updated!")
# Seed the model-catalog disk cache from the freshly-pulled checkout.
# The repo ships the canonical catalog at
# website/static/api/model-catalog.json, and `git pull` just made it
# current — so copy it straight over ~/.hermes/cache/model_catalog.json
# instead of waiting on a network fetch (which can be bot-gated or hit a
# Portal hiccup). Keeps the model picker's curated/free lists in sync
# with the version the user just installed. Non-fatal on failure: the
# normal network refresh still applies on the next picker open.
try:
from hermes_cli.model_catalog import seed_cache_from_checkout
if seed_cache_from_checkout(PROJECT_ROOT):
print(" ✓ Model catalog cache refreshed from checkout")
except Exception as e:
logger.debug("Model catalog seed during update failed: %s", e)
# After git pull, source files on disk are newer than cached Python
# modules in this process. Reload hermes_constants so that any lazy
# import executed below (skills sync, gateway restart) sees new
+31
View File
@@ -356,6 +356,37 @@ def get_curated_nous_models() -> list[str] | None:
return out or None
def seed_cache_from_checkout(project_root: "Path | str") -> bool:
"""Overwrite the disk cache with the catalog shipped in a local checkout.
``hermes update`` pulls the latest repo, so the freshly-pulled
``website/static/api/model-catalog.json`` IS the newest catalog no
network round-trip needed. Copying it straight over the disk cache keeps
the model picker current even when the remote manifest fetch is bot-gated
or the Portal hiccups.
Reads the shipped manifest, validates it against the schema, and writes it
to ``~/.hermes/cache/model_catalog.json`` via the same atomic writer the
network path uses. Returns ``True`` on success, ``False`` if the file is
missing, malformed, or fails validation (caller should treat a ``False``
as non-fatal the network fetch path still applies on the next picker
open).
"""
src = Path(project_root) / "website" / "static" / "api" / "model-catalog.json"
try:
with open(src, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
logger.debug("model catalog seed from checkout skipped (%s): %s", src, exc)
return False
if not _validate_manifest(data):
logger.debug("model catalog seed from checkout skipped: invalid manifest at %s", src)
return False
_write_disk_cache(data)
reset_cache() # drop the in-process copy so the next read picks up the seed
return True
def reset_cache() -> None:
"""Clear the in-process cache. Used by tests and ``hermes model --refresh``."""
global _catalog_cache, _catalog_cache_source_mtime
+102 -6
View File
@@ -33,6 +33,7 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
# Anthropic
("anthropic/claude-fable-5", ""),
("anthropic/claude-opus-4.8", ""),
("anthropic/claude-opus-4.8-fast", "2x price, higher output speed"),
("anthropic/claude-sonnet-4.6", ""),
@@ -73,8 +74,10 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
# Free tier
("openrouter/elephant-alpha", "free"),
("openrouter/owl-alpha", "free"),
("poolside/laguna-m.1:free", "free"),
("tencent/hy3-preview:free", "free"),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
("nvidia/nemotron-3-ultra-550b-a55b:free", "free"),
("inclusionai/ring-2.6-1t:free", "free"),
]
@@ -152,6 +155,7 @@ def _xai_curated_models() -> list[str]:
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
# Anthropic
"anthropic/claude-fable-5",
"anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
@@ -322,6 +326,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"MiniMax-M2",
],
"anthropic": [
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
@@ -764,6 +769,64 @@ _NOUS_RECOMMENDED_CACHE_TTL: int = 600 # seconds (10 minutes)
_nous_recommended_cache: dict[str, tuple[dict[str, Any], float]] = {}
def _nous_recommended_disk_path() -> "Path":
"""Disk path for the persisted recommended-models cache."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "nous_recommended_cache.json"
def _read_nous_recommended_disk(base: str) -> dict[str, Any] | None:
"""Return the last-known-good payload for ``base`` from disk, or None.
The disk file is a JSON object keyed by portal base URL so staging and
prod don't collide:
``{"<base>": {"data": {...}, "ts": <epoch_seconds>}}``.
"""
try:
with open(_nous_recommended_disk_path(), encoding="utf-8") as fh:
blob = json.load(fh)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(blob, dict):
return None
entry = blob.get(base)
if not isinstance(entry, dict):
return None
data = entry.get("data")
return data if isinstance(data, dict) and data else None
def _write_nous_recommended_disk(base: str, data: dict[str, Any]) -> None:
"""Persist ``data`` as the last-known-good payload for ``base``.
Merges into any existing per-base map, then writes atomically. Failures
are non-fatal (logged at debug) the in-process cache still works.
"""
if not data:
return
path = _nous_recommended_disk_path()
try:
try:
with open(path, encoding="utf-8") as fh:
blob = json.load(fh)
if not isinstance(blob, dict):
blob = {}
except (OSError, json.JSONDecodeError):
blob = {}
blob[base] = {"data": data, "ts": time.time()}
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(blob, fh, indent=2)
fh.write("\n")
os.replace(tmp, path)
except OSError as exc:
import logging
logging.getLogger(__name__).debug(
"nous recommended-models disk cache write failed: %s", exc
)
def fetch_nous_recommended_models(
portal_base_url: str = "",
timeout: float = 5.0,
@@ -774,12 +837,19 @@ def fetch_nous_recommended_models(
Hits ``<portal>/api/nous/recommended-models``. The endpoint is public
no auth is required. Results are cached per portal URL for
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds; pass ``force_refresh=True`` to
bypass the cache.
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds in process; pass
``force_refresh=True`` to bypass the in-process cache.
Returns the parsed JSON dict on success, or ``{}`` on any failure
(network, parse, non-2xx). Callers must treat missing/null fields as
"no recommendation" and fall back to their own default.
A successful live fetch is also persisted to a per-base disk cache
(``$HERMES_HOME/cache/nous_recommended_cache.json``) as last-known-good.
When the live fetch fails (network, parse, non-2xx) and the in-process
cache is empty, the disk copy is returned instead of ``{}`` so a
transient Portal hiccup no longer silently drops the free/paid model
recommendations from the picker. Self-heals on the next successful fetch.
Returns the parsed JSON dict, or ``{}`` only when neither the network nor
any cache layer can supply data. Callers must treat missing/null fields
as "no recommendation" and fall back to their own default.
"""
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
now = time.monotonic()
@@ -802,6 +872,19 @@ def fetch_nous_recommended_models(
except Exception:
data = {}
if data:
# Live fetch succeeded — refresh both cache layers.
_nous_recommended_cache[base] = (data, now)
_write_nous_recommended_disk(base, data)
return data
# Live fetch failed. Fall back to the last-known-good disk copy so a
# transient Portal hiccup doesn't drop the recommendations entirely.
disk = _read_nous_recommended_disk(base)
if disk:
_nous_recommended_cache[base] = (disk, now)
return disk
_nous_recommended_cache[base] = (data, now)
return data
@@ -2137,7 +2220,20 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
if normalized == "anthropic":
live = _fetch_anthropic_models()
if live:
return live
# The live /v1/models dump lags newly-routed curated aliases
# (e.g. claude-fable-5, which is reachable on Anthropic before it
# is enumerated by the models endpoint). Surface curated entries
# first, then append any live-only models, so a fresh curated
# model never disappears just because the API hasn't listed it yet.
curated = list(_PROVIDER_MODELS.get("anthropic", []))
merged = list(curated)
merged_lower = {m.lower() for m in curated}
for m in live:
if m.lower() not in merged_lower:
merged.append(m)
merged_lower.add(m.lower())
return merged
return list(_PROVIDER_MODELS.get("anthropic", []))
if normalized == "ollama-cloud":
live = fetch_ollama_cloud_models(force_refresh=force_refresh)
if live:
+5
View File
@@ -1010,6 +1010,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
print(f"✓ Removed {wrapper_path}")
# 4. Remove profile directory
remove_error: Exception | None = None
try:
def _make_writable(func, path, exc):
"""onexc/onerror handler: add +w on PermissionError so rmtree can proceed.
@@ -1056,6 +1057,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
print(f"✓ Removed {profile_dir}")
except Exception as e:
print(f"⚠ Could not remove {profile_dir}: {e}")
remove_error = e
# 5. Clear active_profile if it pointed to this profile
try:
@@ -1066,6 +1068,9 @@ def delete_profile(name: str, yes: bool = False) -> Path:
except Exception:
pass
if remove_error is not None:
raise RuntimeError(f"Could not remove profile directory {profile_dir}: {remove_error}") from remove_error
print(f"\nProfile '{canon}' deleted.")
return profile_dir
+4 -1
View File
@@ -492,7 +492,10 @@ def get_label(provider_id: str) -> str:
def is_aggregator(provider: str) -> bool:
"""Return True when the provider is a multi-model aggregator."""
pdef = get_provider(provider)
provider_norm = normalize_provider(provider or "")
if provider_norm.startswith("custom:"):
return True
pdef = get_provider(provider_norm)
return pdef.is_aggregator if pdef else False
+5
View File
@@ -8573,6 +8573,11 @@ def _resolve_chat_argv(
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
env = os.environ.copy()
try:
from hermes_cli.config import apply_terminal_config_to_env
apply_terminal_config_to_env(env=env)
except Exception:
_log.debug("Failed to apply terminal config bridge for dashboard chat", exc_info=True)
env.setdefault("NODE_ENV", "production")
# Browser-embedded chat should prefer stable wheel-based scrollback over
# native terminal mouse tracking. When mouse tracking is enabled, wheel
+1 -3
View File
@@ -152,14 +152,12 @@ in
fi
# Check if lockfile changed (either from the npm i above or from an
# external edit). Runs npm ci + fix-lockfiles if so.
# external edit). Runs npm ci if so.
LOCK_STAMP="$STAMP_DIR/root-lockfile"
LOCK_STAMP_VALUE=$(sha256sum "$REPO_ROOT/package-lock.json" 2>/dev/null | awk '{print $1}')
if [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$LOCK_STAMP_VALUE" ]; then
echo "npm: package-lock.json changed, running npm ci..."
( cd "$REPO_ROOT" && CI=true ${pkgs.lib.getExe' nodejs "npm"} ci --silent --no-fund --no-audit 2>/dev/null )
echo "npm: updating nix hash..."
${fixLockfilesExe} || echo "npm: warning: fix-lockfiles failed, run it manually" >&2
mkdir -p "$STAMP_DIR"
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
fi
+4
View File
@@ -86,6 +86,7 @@ class AudioBridge:
["pactl", "unload-module", str(mod_id)],
check=False,
capture_output=True,
stdin=subprocess.DEVNULL,
)
except Exception:
# Best-effort teardown — never raise from here.
@@ -111,6 +112,7 @@ class AudioBridge:
check=True,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError as exc:
raise RuntimeError(
@@ -135,6 +137,7 @@ class AudioBridge:
check=True,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
except subprocess.CalledProcessError as exc:
# Roll back the null-sink we just created so we don't leak it.
@@ -142,6 +145,7 @@ class AudioBridge:
["pactl", "unload-module", str(sink_mod_id)],
check=False,
capture_output=True,
stdin=subprocess.DEVNULL,
)
raise RuntimeError(
f"pactl load-module virtual-source failed: {exc.stderr or exc}"
+1
View File
@@ -94,6 +94,7 @@ def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT,
result = subprocess.run(
cmd, capture_output=True, text=True,
timeout=timeout, cwd=effective_cwd, env=env,
stdin=subprocess.DEVNULL,
)
stdout = result.stdout.strip()
stderr = result.stderr.strip()
+2
View File
@@ -695,6 +695,7 @@ class HindsightMemoryProvider(MemoryProvider):
subprocess.run(
[uv_path, "pip", "install", "--python", sys.executable, "--quiet", "--upgrade"] + deps_to_install,
check=True, timeout=120, capture_output=True,
stdin=subprocess.DEVNULL,
)
print(" ✓ Dependencies up to date")
except Exception as e:
@@ -1101,6 +1102,7 @@ class HindsightMemoryProvider(MemoryProvider):
[uv_path, "pip", "install", "--python", sys.executable,
"--quiet", "--upgrade", f"hindsight-client>={_MIN_CLIENT_VERSION}"],
check=True, timeout=120, capture_output=True,
stdin=subprocess.DEVNULL,
)
logger.info("hindsight-client upgraded to >=%s", _MIN_CLIENT_VERSION)
except Exception as e:
+1
View File
@@ -416,6 +416,7 @@ def _ensure_sdk_installed() -> bool:
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
print(" Installed.\n")
+1
View File
@@ -628,6 +628,7 @@ class HonchoClientConfig:
root = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, cwd=cwd, timeout=5,
stdin=subprocess.DEVNULL,
)
if root.returncode == 0:
return Path(root.stdout.strip()).name
+51 -1
View File
@@ -10,6 +10,39 @@ logger = logging.getLogger(__name__)
_CACHE: list[str] | None = None
# Anthropic model families that still accept an explicit "disable thinking"
# request (the manual ``thinking: {type: "disabled"}`` form OpenRouter emits
# for ``reasoning: {enabled: false}``). Everything Claude 4.6 and newer —
# including future date-stamped / named models (fable, mythos-class, …) —
# mandates reasoning and returns HTTP 400 on any disable form. We therefore
# default *unknown* Anthropic models to "cannot disable" (the modern contract)
# and keep only this explicit legacy allowlist of models that can. Mirrors the
# default-to-newest philosophy in agent/anthropic_adapter._get_anthropic_max_output.
_ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS = (
"claude-3", # 3, 3.5, 3.7
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
"claude-sonnet-4-0", "claude-sonnet-4.0",
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
"claude-opus-4-5", "claude-opus-4.5",
"claude-sonnet-4-5", "claude-sonnet-4.5",
"claude-haiku-4-5", "claude-haiku-4.5",
)
def _anthropic_reasoning_is_mandatory(model: str | None) -> bool:
"""Return True for Anthropic models that reject any disable-thinking form.
Claude 4.6+ (adaptive thinking) and newer named models have no "off"
switch sending ``reasoning: {enabled: false}`` makes OpenRouter emit
``thinking: {type: "disabled"}``, which these models 400 on. Unknown /
new Anthropic model names default to mandatory so the next un-numbered
release doesn't reintroduce the 400.
"""
m = (model or "").lower()
if not m.startswith(("anthropic/", "claude")) and "claude" not in m:
return False
return not any(sub in m for sub in _ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS)
class OpenRouterProfile(ProviderProfile):
"""OpenRouter aggregator — provider preferences, reasoning config passthrough."""
@@ -84,7 +117,24 @@ class OpenRouterProfile(ProviderProfile):
"""
extra_body: dict[str, Any] = {}
if supports_reasoning:
if reasoning_config is not None:
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
# future named models) use *adaptive* thinking: the model decides
# how much to think, and OpenRouter ignores ``reasoning.effort`` for
# them entirely. Sending any ``reasoning`` field is therefore both
# pointless and actively harmful:
# - ``{enabled: false}`` → OpenRouter emits Anthropic's manual
# ``thinking: {type: "disabled"}``, which these models 400 on.
# - any enabled form, on a tool-continuation turn whose prior
# assistant tool_call carries no thinking block (chat_completions
# never replays signed thinking blocks), ALSO makes OpenRouter
# emit ``thinking: {type: "disabled"}`` → the same 400 on every
# turn after the first tool call.
# The only reliable behavior is to omit ``reasoning`` and let the
# model default to adaptive. See hermes-agent#42991 (disable case)
# and the tool-replay follow-up.
if _anthropic_reasoning_is_mandatory(model):
pass # omit reasoning entirely → adaptive default
elif reasoning_config is not None:
extra_body["reasoning"] = dict(reasoning_config)
else:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
+116 -57
View File
@@ -9,6 +9,7 @@ import logging
import os
import threading
import tomllib
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
@@ -284,6 +285,43 @@ class _Runtime:
and callable(getattr(getattr(self.nemo_relay, "tools", None), "execute", None))
)
def _run_managed_with_downstream_preservation(
self,
next_call: Callable[[Any], Any],
normalize_payload: Callable[[Any], Any],
shape_response: Callable[[Any], Any],
make_managed_execute: Callable[[Callable[[Any], Any]], Any],
) -> Any:
# NeMo Relay's native managed execution may wrap a failing callback as an
# internal runtime error, hiding the real downstream provider/tool
# exception. Capture the original here and re-raise it after managed
# execution so Hermes retry classification still sees it. The LLM and tool
# paths share this scaffolding; they differ only in payload normalization,
# response shaping, and the Relay call itself.
raw_response: dict[str, Any] = {"set": False, "value": None}
callback_error: Exception | None = None
downstream_error: BaseException | None = None
def _impl(next_payload: Any) -> Any:
nonlocal callback_error, downstream_error
try:
raw = next_call(normalize_payload(next_payload))
except Exception as exc:
callback_error = exc
downstream_error = _original_downstream_error(exc)
raise
raw_response["set"] = True
raw_response["value"] = raw
return shape_response(raw)
try:
managed_result = _resolve_awaitable(make_managed_execute(_impl))
except Exception as exc:
if downstream_error is not None and _is_relay_wrapped_callback_error(exc, callback_error):
raise downstream_error
raise
return raw_response["value"] if raw_response["set"] else managed_result
def execute_llm(self, kwargs: dict[str, Any]) -> Any:
state = self.ensure_session(kwargs)
request_body = _jsonable(kwargs.get("request") or {})
@@ -292,38 +330,37 @@ class _Runtime:
if not callable(next_call):
return request_body
raw_response: dict[str, Any] = {"set": False, "value": None}
def _impl(next_request: Any) -> Any:
def _normalize(next_request: Any) -> Any:
next_body = getattr(next_request, "content", next_request)
raw = next_call(next_body if isinstance(next_body, dict) else request_body)
raw_response["set"] = True
raw_response["value"] = raw
return _llm_response_payload(raw)
return next_body if isinstance(next_body, dict) else request_body
async def _managed_execute() -> Any:
result = self.nemo_relay.llm.execute(
str(kwargs.get("provider") or "llm"),
request,
_impl,
handle=state.handle,
data=_jsonable(
{
"turn_id": kwargs.get("turn_id"),
"api_request_id": kwargs.get("api_request_id"),
"api_call_count": kwargs.get("api_call_count"),
"mode": self.settings.adaptive_mode,
}
),
metadata=_metadata(kwargs),
model_name=str(kwargs.get("model") or ""),
)
if inspect.isawaitable(result):
return await result
return result
def _make_managed(impl: Callable[[Any], Any]) -> Any:
async def _managed_execute() -> Any:
result = self.nemo_relay.llm.execute(
str(kwargs.get("provider") or "llm"),
request,
impl,
handle=state.handle,
data=_jsonable(
{
"turn_id": kwargs.get("turn_id"),
"api_request_id": kwargs.get("api_request_id"),
"api_call_count": kwargs.get("api_call_count"),
"mode": self.settings.adaptive_mode,
}
),
metadata=_metadata(kwargs),
model_name=str(kwargs.get("model") or ""),
)
if inspect.isawaitable(result):
return await result
return result
managed_result = _resolve_awaitable(_managed_execute())
return raw_response["value"] if raw_response["set"] else managed_result
return _managed_execute()
return self._run_managed_with_downstream_preservation(
next_call, _normalize, _llm_response_payload, _make_managed
)
def execute_tool(self, kwargs: dict[str, Any]) -> Any:
state = self.ensure_session(kwargs)
@@ -333,37 +370,35 @@ class _Runtime:
if not callable(next_call):
return args
raw_response: dict[str, Any] = {"set": False, "value": None}
def _normalize(next_args: Any) -> Any:
return next_args if isinstance(next_args, dict) else args
def _impl(next_args: Any) -> Any:
effective_args = next_args if isinstance(next_args, dict) else args
raw = next_call(effective_args)
raw_response["set"] = True
raw_response["value"] = raw
return _jsonable(raw)
def _make_managed(impl: Callable[[Any], Any]) -> Any:
async def _managed_execute() -> Any:
result = self.nemo_relay.tools.execute(
tool_name,
args,
impl,
handle=state.handle,
data=_jsonable(
{
"turn_id": kwargs.get("turn_id"),
"api_request_id": kwargs.get("api_request_id"),
"tool_call_id": kwargs.get("tool_call_id"),
"mode": self.settings.adaptive_mode,
}
),
metadata=_metadata(kwargs),
)
if inspect.isawaitable(result):
return await result
return result
async def _managed_execute() -> Any:
result = self.nemo_relay.tools.execute(
tool_name,
args,
_impl,
handle=state.handle,
data=_jsonable(
{
"turn_id": kwargs.get("turn_id"),
"api_request_id": kwargs.get("api_request_id"),
"tool_call_id": kwargs.get("tool_call_id"),
"mode": self.settings.adaptive_mode,
}
),
metadata=_metadata(kwargs),
)
if inspect.isawaitable(result):
return await result
return result
return _managed_execute()
managed_result = _resolve_awaitable(_managed_execute())
return raw_response["value"] if raw_response["set"] else managed_result
return self._run_managed_with_downstream_preservation(
next_call, _normalize, _jsonable, _make_managed
)
def register(ctx) -> None:
@@ -806,6 +841,30 @@ def _value(obj: Any, key: str, default: Any = None) -> Any:
return getattr(obj, key, default)
def _original_downstream_error(exc: Exception) -> BaseException:
# Hermes wraps downstream execution failures in a local/private exception
# class, so detect the wrapper by shape instead of importing it here.
original = getattr(exc, "original", None)
if exc.__class__.__name__ == "_DownstreamExecutionError" and isinstance(original, BaseException):
return original
return exc
def _is_relay_wrapped_callback_error(exc: Exception, callback_error: Exception | None) -> bool:
# NeMo Relay re-wraps a failing callback as ``RuntimeError("internal error:
# <ClassName>: <message>")``. Match by prefix rather than exact equality so a
# trailing traceback/suffix in a future Relay version doesn't silently defeat
# the unwrap; the class-name + message prefix still discriminates the real
# downstream failure from unrelated Relay-internal errors. If Relay drops the
# leading ``internal error:`` shape entirely, this returns False and Hermes
# falls back to surfacing Relay's error (the pre-fix behavior) rather than
# masking it.
if callback_error is None or not isinstance(exc, RuntimeError):
return False
expected = f"internal error: {callback_error.__class__.__name__}: {callback_error}"
return str(exc).startswith(expected)
def _llm_response_payload(response: Any) -> Any:
"""Return the LLM response shape NeMo Relay's ATIF conversion expects."""
payload = _jsonable(response)
+1
View File
@@ -520,6 +520,7 @@ class VoiceReceiver:
],
check=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
finally:
try:
+1
View File
@@ -320,6 +320,7 @@ def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]:
],
capture_output=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
logger.warning("decode_to_pcm failed for %s: %s", path, e)
+95 -81
View File
@@ -1,123 +1,137 @@
# Photon iMessage platform plugin
This plugin connects Hermes Agent to iMessage (and WhatsApp Business +
future Spectrum interfaces) through [Photon][photon] — a managed
service that handles the iMessage line allocation, delivery, and
abuse-prevention layer so users don't have to run their own Mac
relay.
This plugin connects Hermes Agent to iMessage (and other Spectrum
interfaces) through [Photon][photon] — a managed service that handles
iMessage line allocation, delivery, and abuse-prevention so users don't
have to run their own Mac relay.
The free tier uses Photon's shared iMessage line pool (`type: shared`)
and is the path we recommend for everyone who doesn't already pay for a
dedicated number.
The free tier uses Photon's shared iMessage line pool and is the path we
recommend for everyone who doesn't already pay for a dedicated number.
## Architecture
Like Discord and Slack, Photon is a **persistent-connection** channel — no
public URL, no webhook, no signing secret. The `spectrum-ts` SDK holds a
long-lived **gRPC stream** to Photon for both directions. Because the SDK is
TypeScript-only, Hermes runs it inside a small supervised Node sidecar and
talks to it over loopback.
```
┌─────────────────────────┐ HMAC-signed POSTs ┌──────────────────┐
│ Photon Spectrum cloud │ ──────────────────────► │ Hermes Agent │
(iMessage line owner) │ │ (Python)
└─────────────────────────┘ JSON over loopback │
◄────────────────────── │ PhotonAdapter │
│ │ + aiohttp recv │
│ spectrum-ts
│ SDK (Node) │ spawns + super- │
vises ▼
┌─────────────────────────┐ ├──────────────────┤
│ Node sidecar │ ◄──── X-Hermes- ─ │ Node sidecar │
│ (plugins/.../sidecar) │ Sidecar-Token │ child process │
└─────────────────────────┘ └──────────────────┘
gRPC (spectrum-ts)
┌─────────────────────────┐ ◄───────────────► ┌──────────────────────┐
Photon Spectrum cloud app.messages │ Node sidecar
│ (iMessage line owner) │ space.send() (plugins/…/sidecar)
└─────────────────────────┘ ─────────────────────
GET /inbound (NDJSON) │ ▲ POST /send
inbound events /typing
┌──────────────────────┐
PhotonAdapter
│ (Python, in gateway) │
└──────────────────────┘
```
Inbound traffic is webhook-only — Hermes runs an aiohttp listener
that verifies `X-Spectrum-Signature` and dedupes on `message.id`.
Outbound traffic goes through a tiny Node sidecar that runs the
`spectrum-ts` SDK. Photon does not currently expose an HTTP
send-message endpoint; their own docs say:
> Pass `space.id` to `Space.send(...)` from a separate `spectrum-ts`
> SDK instance to reply. **No public HTTP send endpoint exists today.**
> — https://photon.codes/docs/webhooks/events
When Photon ships an HTTP send endpoint, `_sidecar_send` is the one
function that swaps and the sidecar disappears. The rest of the
plugin stays the same.
- **Inbound**: the sidecar consumes the SDK's `app.messages` gRPC stream,
normalizes each message, and streams it to the adapter over a loopback
`GET /inbound` (NDJSON). The adapter dedupes on `messageId` and dispatches
a `MessageEvent` to the gateway. It reconnects automatically if the stream
drops; the sidecar owns the gRPC reconnect to Photon.
- **Outbound**: `send` / `send_typing` are loopback POSTs to the sidecar,
authenticated with a shared `X-Hermes-Sidecar-Token`.
## First-time setup
```bash
# 1. One-shot setup: device login (opens browser) + project + user + sidecar deps
# One-shot setup: device login (opens browser) + project + user + sidecar deps
hermes photon setup --phone +15551234567
# 2. Expose your webhook URL to the public internet
# (cloudflared, ngrok, your gateway's public hostname, etc.)
# Then register it with Photon:
hermes photon webhook register https://your-host.example.com/photon/webhook
# 3. Save the signing secret it prints to ~/.hermes/.env
# as PHOTON_WEBHOOK_SECRET=...
# Photon only returns it ONCE.
# 4. Start the gateway
# Start the gateway
hermes gateway start --platform photon
```
`hermes photon setup` runs the RFC 8628 device-code login as its first
step — it opens `https://app.photon.codes/` for approval, then
provisions the Spectrum project + iMessage line. There is no separate
`login` command; like every other Hermes channel, onboarding goes
through one setup surface. Re-running `setup` reuses an existing token
and project, so it's safe to run again to finish a partial setup.
`hermes photon setup` does, in order:
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.
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
agent.
6. **Install the sidecar deps** (`spectrum-ts`).
There is no separate `login` command; like every other Hermes channel,
onboarding goes through one setup surface. Re-running `setup` reuses an
existing token/project, so it's safe to run again to finish a partial setup.
Run `hermes photon status` to see what's configured.
## Credentials
Stored in `~/.hermes/auth.json` under `credential_pool`:
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_SECRET=<projectSecret>
```
Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
```jsonc
{
"credential_pool": {
"photon": [
{ "access_token": "<dashboard-bearer>", "issued_at": ... }
{ "access_token": "<device-bearer>", "issued_at": ... }
],
"photon_project": [
{ "project_id": "...", "project_secret": "...", "name": "Hermes Agent" }
{
"dashboard_project_id": "<dashboard id>",
"spectrum_project_id": "<spectrumProjectId>",
"project_secret": "<projectSecret>",
"name": "Hermes Agent"
}
]
}
}
```
The per-URL webhook signing secret is treated like an API key and
lives in `~/.hermes/.env` as `PHOTON_WEBHOOK_SECRET`.
> **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.
## Configuration knobs
All env vars are documented in `plugin.yaml`. The most important are:
All env vars are documented in `plugin.yaml`. The most important:
| Env var | Default | Meaning |
|--------------------------|--------------------|-----------------------------------------|
| `PHOTON_PROJECT_ID` | from auth.json | Spectrum project ID |
| `PHOTON_PROJECT_SECRET` | from auth.json | Spectrum project secret (HTTP Basic) |
| `PHOTON_WEBHOOK_SECRET` | (unset) | Signing secret returned at register |
| `PHOTON_WEBHOOK_PORT` | 8788 | Local port for the aiohttp listener |
| `PHOTON_WEBHOOK_PATH` | /photon/webhook | Path under which the listener mounts |
| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for sidecar control |
| `PHOTON_HOME_CHANNEL` | (unset) | Default space ID for cron delivery |
| `PHOTON_ALLOWED_USERS` | (unset) | Comma-separated E.164 allowlist |
| Env var | Default | Meaning |
|---------------------------|----------------------------|--------------------------------------|
| `PHOTON_PROJECT_ID` | from .env / auth.json | Spectrum project id (SDK `projectId`)|
| `PHOTON_PROJECT_SECRET` | from .env / auth.json | Project secret |
| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for the sidecar |
| `PHOTON_SIDECAR_AUTOSTART`| true | Spawn the sidecar on connect |
| `PHOTON_DASHBOARD_HOST` | https://app.photon.codes | Dashboard API host |
| `PHOTON_SPECTRUM_HOST` | https://spectrum.photon.codes | Spectrum API host |
| `PHOTON_HOME_CHANNEL` | your number (set by setup) | Default space for cron delivery — a space id, or a bare E.164 number (resolved to a DM) |
| `PHOTON_ALLOWED_USERS` | your number (set by setup) | Comma-separated E.164 allowlist |
| `PHOTON_REQUIRE_MENTION` | false | Gate group chats on a wake word |
| `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` | 20 MB | Max inbound attachment size the sidecar reads & inlines |
## Limitations (current Photon API)
## Attachments & limitations
- **Inbound attachments are metadata only.** Inbound webhooks include the
filename + MIME type but no download URL. The plugin surfaces a
text marker (`[Photon attachment received: …]`) so the agent knows
something arrived, but cannot read the bytes. Photon's docs note
an attachment retrieval endpoint is on the roadmap.
- **Outbound attachments are supported.** Images, voice notes, video,
and documents are sent via `space.send(attachment(...))` /
- **Inbound attachments and voice notes are downloaded.** The sidecar reads
the bytes (`content.read()`) and base64-inlines them on the NDJSON event; the
adapter caches them to the shared media cache and populates `media_urls` /
`media_types`, so the agent sees the real image/file or can transcribe the
voice note — parity with the BlueBubbles iMessage channel. Media larger than
`PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or any byte read that
fails, falls back to a text marker (`[Photon attachment received: …]` or
`[Photon voice received: …]`) so the agent still knows something arrived.
- **Outbound attachments are supported.** Images, voice notes, video, and
documents are sent via `space.send(attachment(...))` /
`space.send(voice(...))` through the sidecar's `/send-attachment`
endpoint. A caption is delivered as a separate text bubble after the
media.
- **Reactions, message effects, polls** — not exposed yet; the
`spectrum-ts` SDK supports them, and the sidecar is the natural
place to add them when the agent has reason to use them.
endpoint; a caption is delivered as a separate text bubble after the media.
- **Reactions, message effects, polls** — supported by `spectrum-ts` but not
yet exposed; the sidecar is the natural place to add them.
[photon]: https://photon.codes/
+392 -254
View File
@@ -1,32 +1,30 @@
"""
Photon Spectrum (iMessage) platform adapter for Hermes Agent.
Both directions of traffic flow through a small supervised Node sidecar
(see ``sidecar/index.mjs``) that runs the ``spectrum-ts`` SDK the SDK is
TypeScript-only and there is no public HTTP message API, so a sidecar is
unavoidable.
Inbound:
Photon delivers signed JSON ``POST``s to a URL we register. The
adapter spins up an aiohttp server on ``PHOTON_WEBHOOK_PORT``,
verifies ``X-Spectrum-Signature`` (HMAC-SHA256 of
``v0:{timestamp}:{body}`` keyed by the per-URL signing secret),
rejects deliveries with a timestamp drift > 5 minutes, dedupes on
``message.id``, and dispatches a normalized ``MessageEvent`` to the
gateway runner via ``BasePlatformAdapter.handle_message``.
The SDK's ``app.messages`` is a long-lived **gRPC** stream. The sidecar
serializes each message to a normalized JSON event and streams it to this
adapter over a loopback ``GET /inbound`` (NDJSON). A background task here
consumes that stream, dedupes on ``messageId``, and dispatches a
``MessageEvent`` to the gateway via ``BasePlatformAdapter.handle_message``.
No webhook, no public URL, no signing secret.
Outbound:
Photon does not currently expose a public HTTP send-message
endpoint, so the adapter spawns a small Node sidecar (see
``sidecar/index.mjs``) that runs the ``spectrum-ts`` SDK. Each
``send`` / ``send_typing`` / attachment call from Hermes is a
loopback POST to the sidecar with a shared bearer token. Outbound
media (images, voice notes, video, documents) goes through
spectrum-ts' ``attachment()`` / ``voice()`` content builders.
When Photon ships an HTTP send endpoint we can collapse the sidecar
into ``_send_via_http`` and drop the Node dependency entirely.
``send`` / ``send_typing`` are loopback POSTs to the sidecar's control
endpoints, authenticated with a shared bearer token. Outbound media
(images, voice notes, video, documents) goes through spectrum-ts'
``attachment()`` / ``voice()`` content builders via the sidecar's
``/send-attachment`` endpoint.
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import base64
import json
import logging
import os
@@ -39,21 +37,21 @@ import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, List, Optional
try:
if TYPE_CHECKING:
# Type checkers see ``httpx`` as the always-imported module, so every use
# site type-checks cleanly. The runtime fallback below keeps the optional
# dependency truly optional (each use site is guarded by HTTPX_AVAILABLE).
import httpx
HTTPX_AVAILABLE = True
except ImportError: # pragma: no cover - httpx is already a Hermes dep
HTTPX_AVAILABLE = False
httpx = None # type: ignore[assignment]
try:
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
web = None # type: ignore[assignment]
else:
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError: # pragma: no cover - httpx is already a Hermes dep
HTTPX_AVAILABLE = False
httpx = None
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
@@ -62,22 +60,15 @@ from gateway.platforms.base import (
MessageType,
SendResult,
)
from gateway.platforms.helpers import strip_markdown
from .auth import (
DEFAULT_SPECTRUM_HOST,
load_project_credentials,
_spectrum_host,
)
from .auth import load_project_credentials
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
_DEFAULT_WEBHOOK_PORT = 8788
_DEFAULT_WEBHOOK_PATH = "/photon/webhook"
_DEFAULT_WEBHOOK_BIND = "0.0.0.0"
_DEFAULT_SIDECAR_PORT = 8789
_DEFAULT_SIDECAR_BIND = "127.0.0.1"
@@ -86,11 +77,8 @@ _DEFAULT_SIDECAR_BIND = "127.0.0.1"
# size to ~16 KB. Keep a conservative cap that matches BlueBubbles.
_MAX_MESSAGE_LENGTH = 8000
# Spec says reject deliveries older than ~5 minutes for replay protection.
_TIMESTAMP_DRIFT_SECONDS = 300
# Dedup parameters — keep at least 1k IDs for ~48h per Photon's
# at-least-once guidance.
# Dedup parameters — the gRPC stream is at-least-once, and a sidecar
# reconnect can replay, so keep at least 1k ids for ~48h.
_DEDUP_MAX_SIZE = 4000
_DEDUP_WINDOW_SECONDS = 48 * 3600
@@ -118,7 +106,7 @@ def _coerce_port(value: Any, default: int) -> int:
def check_requirements() -> bool:
"""Return True when both Python deps and the Node sidecar are available."""
if not HTTPX_AVAILABLE or not AIOHTTP_AVAILABLE:
if not HTTPX_AVAILABLE:
return False
if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"):
return False
@@ -146,61 +134,33 @@ def is_connected(cfg: PlatformConfig) -> bool:
def _env_enablement() -> Optional[dict]:
"""Seed PlatformConfig.extra from env so env-only setups appear in status."""
"""Seed PlatformConfig.extra from env so env-only setups appear in status.
The special ``home_channel`` key is handled by the core plugin hook and
becomes a proper ``HomeChannel`` on ``PlatformConfig``.
"""
project_id, project_secret = load_project_credentials()
if not (project_id and project_secret):
return None
return {
"project_id": project_id,
"project_secret": project_secret,
"webhook_port": _coerce_port(os.getenv("PHOTON_WEBHOOK_PORT"), _DEFAULT_WEBHOOK_PORT),
"webhook_path": os.getenv("PHOTON_WEBHOOK_PATH") or _DEFAULT_WEBHOOK_PATH,
}
# ---------------------------------------------------------------------------
# Signature verification
def verify_signature(
*,
body: bytes,
timestamp_header: str,
signature_header: str,
signing_secret: str,
now: Optional[float] = None,
drift: int = _TIMESTAMP_DRIFT_SECONDS,
) -> bool:
"""Constant-time verify a Photon webhook signature.
Returns True iff the timestamp is within ``drift`` of *now* AND
``signature_header == "v0=" + hmac_sha256(secret, "v0:{ts}:{body}")``.
Exposed at module scope so tests can exercise it without an adapter
instance.
"""
if not timestamp_header or not signature_header or not signing_secret:
return False
try:
ts = int(timestamp_header)
except ValueError:
return False
if abs((now or time.time()) - ts) > drift:
return False
if not signature_header.startswith("v0="):
return False
expected = hmac.new(
signing_secret.encode("utf-8"),
f"v0:{ts}:".encode("utf-8") + body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header[3:])
seed = {"project_id": project_id, "project_secret": project_secret}
home = os.getenv("PHOTON_HOME_CHANNEL", "").strip()
if home:
seed["home_channel"] = {
"chat_id": home,
"name": os.getenv("PHOTON_HOME_CHANNEL_NAME", "Home"),
}
return seed
# ---------------------------------------------------------------------------
# Adapter
class PhotonAdapter(BasePlatformAdapter):
"""Inbound: signed webhook on aiohttp. Outbound: Node sidecar via loopback HTTP."""
"""Bidirectional bridge to Photon Spectrum via the Node spectrum-ts sidecar.
Inbound: consume the sidecar's ``/inbound`` gRPC stream.
Outbound: loopback POSTs to the sidecar's control channel.
"""
MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
@@ -209,6 +169,8 @@ class PhotonAdapter(BasePlatformAdapter):
extra = config.extra or {}
# Project credentials (env wins, then config.extra, then auth.json).
# ``project_id`` here is the project's spectrumProjectId — the value
# the spectrum-ts SDK authenticates with.
stored_id, stored_sec = load_project_credentials()
self._project_id: str = (
os.getenv("PHOTON_PROJECT_ID")
@@ -223,27 +185,6 @@ class PhotonAdapter(BasePlatformAdapter):
or ""
)
# Webhook receiver
self._webhook_port = _coerce_port(
extra.get("webhook_port") or os.getenv("PHOTON_WEBHOOK_PORT"),
_DEFAULT_WEBHOOK_PORT,
)
self._webhook_path = (
extra.get("webhook_path")
or os.getenv("PHOTON_WEBHOOK_PATH")
or _DEFAULT_WEBHOOK_PATH
)
self._webhook_bind = (
extra.get("webhook_bind")
or os.getenv("PHOTON_WEBHOOK_BIND")
or _DEFAULT_WEBHOOK_BIND
)
self._webhook_secret: str = (
os.getenv("PHOTON_WEBHOOK_SECRET")
or extra.get("webhook_secret")
or ""
)
# Sidecar
self._sidecar_port = _coerce_port(
extra.get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"),
@@ -259,12 +200,13 @@ class PhotonAdapter(BasePlatformAdapter):
self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node"
# Runtime state
self._runner: Optional["web.AppRunner"] = None
self._sidecar_proc: Optional[subprocess.Popen] = None
self._sidecar_supervisor_task: Optional[asyncio.Task] = None
self._inbound_task: Optional[asyncio.Task] = None
self._inbound_running = False
self._http_client: Optional["httpx.AsyncClient"] = None
# Lightweight in-memory dedup. Photon's at-least-once guarantee
# means we WILL see the same message.id more than once.
# Lightweight in-memory dedup. The gRPC stream is at-least-once, so we
# may see the same messageId more than once (e.g. after a reconnect).
self._seen_messages: Dict[str, float] = {}
# Group-chat mention gating (parity with BlueBubbles). When enabled,
@@ -345,13 +287,6 @@ class PhotonAdapter(BasePlatformAdapter):
# -- Connection lifecycle ---------------------------------------------
async def connect(self) -> bool:
if not AIOHTTP_AVAILABLE:
self._set_fatal_error(
"MISSING_DEP",
"aiohttp not installed. Run: pip install aiohttp",
retryable=False,
)
return False
if not HTTPX_AVAILABLE:
self._set_fatal_error(
"MISSING_DEP", "httpx not installed", retryable=False
@@ -366,19 +301,11 @@ class PhotonAdapter(BasePlatformAdapter):
)
return False
# Start the aiohttp receiver first; without it the sidecar would
# be able to forward inbound traffic to a closed port.
try:
await self._start_webhook_server()
except OSError as e:
self._set_fatal_error(
"PORT_IN_USE",
f"webhook port {self._webhook_port} unavailable: {e}",
retryable=True,
)
return False
client = httpx.AsyncClient(timeout=30.0)
self._http_client = client
# Spin up the Node sidecar (required for outbound).
# The sidecar holds the gRPC stream for BOTH directions, so it is
# required now (not just for outbound).
if self._autostart_sidecar:
try:
await self._start_sidecar()
@@ -388,23 +315,39 @@ class PhotonAdapter(BasePlatformAdapter):
f"failed to start Photon sidecar: {e}",
retryable=True,
)
await self._stop_webhook_server()
await client.aclose()
self._http_client = None
return False
else:
logger.info("[photon] sidecar autostart disabled — outbound will fail")
logger.warning(
"[photon] sidecar autostart disabled — inbound + outbound will fail"
)
# Start consuming the inbound gRPC stream from the sidecar.
self._inbound_running = True
self._inbound_task = asyncio.get_event_loop().create_task(
self._inbound_loop()
)
self._http_client = httpx.AsyncClient(timeout=30.0)
self._mark_connected()
logger.info(
"[photon] connected — webhook at %s:%d%s, sidecar on %s:%d",
self._webhook_bind, self._webhook_port, self._webhook_path,
"[photon] connected — sidecar on %s:%d, streaming inbound over gRPC",
self._sidecar_bind, self._sidecar_port,
)
return True
async def disconnect(self) -> None:
self._inbound_running = False
if self._inbound_task is not None:
self._inbound_task.cancel()
try:
await self._inbound_task
except asyncio.CancelledError:
pass
except Exception:
pass
self._inbound_task = None
await self._stop_sidecar()
await self._stop_webhook_server()
if self._http_client is not None:
try:
await self._http_client.aclose()
@@ -413,119 +356,167 @@ class PhotonAdapter(BasePlatformAdapter):
self._http_client = None
self._mark_disconnected()
# -- Webhook server ----------------------------------------------------
# -- Inbound stream consumer ------------------------------------------
async def _start_webhook_server(self) -> None:
app = web.Application()
app.router.add_post(self._webhook_path, self._handle_webhook)
app.router.add_get("/healthz", lambda _: web.Response(text="ok"))
self._runner = web.AppRunner(app)
await self._runner.setup()
site = web.TCPSite(self._runner, self._webhook_bind, self._webhook_port)
await site.start()
async def _inbound_loop(self) -> None:
"""Consume the sidecar's ``/inbound`` NDJSON stream, with reconnect.
async def _stop_webhook_server(self) -> None:
if self._runner is not None:
The sidecar owns the gRPC reconnect/heartbeat to Photon; this loop
only has to re-open the loopback HTTP stream if it drops (e.g. the
sidecar restarts).
"""
client = self._http_client
if client is None:
return
url = f"http://{self._sidecar_bind}:{self._sidecar_port}/inbound"
headers = {"X-Hermes-Sidecar-Token": self._sidecar_token}
backoff = 1.0
while self._inbound_running:
try:
await self._runner.cleanup()
except Exception:
pass
self._runner = None
async def _handle_webhook(self, request: "web.Request") -> "web.Response":
body = await request.read()
if self._webhook_secret:
ts = request.headers.get("X-Spectrum-Timestamp", "")
sig = request.headers.get("X-Spectrum-Signature", "")
if not verify_signature(
body=body,
timestamp_header=ts,
signature_header=sig,
signing_secret=self._webhook_secret,
):
logger.warning("[photon] rejected webhook with bad signature")
return web.Response(status=401, text="invalid signature")
else:
logger.warning(
"[photon] PHOTON_WEBHOOK_SECRET unset — accepting unsigned "
"deliveries. Set the per-URL signing secret returned by "
"register-webhook to enable verification."
)
async with client.stream(
"GET", url, headers=headers, timeout=None,
) as resp:
if resp.status_code != 200:
raise RuntimeError(f"/inbound returned {resp.status_code}")
backoff = 1.0 # reset on a successful connect
async for line in resp.aiter_lines():
if not self._inbound_running:
break
line = line.strip()
if not line:
continue # heartbeat
await self._on_inbound_line(line)
except asyncio.CancelledError:
raise
except Exception as e:
if not self._inbound_running:
break
logger.warning(
"[photon] inbound stream dropped (%s); reconnecting in %.1fs",
e, backoff,
)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30.0)
async def _on_inbound_line(self, line: str) -> None:
try:
payload = json.loads(body or b"{}")
event = json.loads(line)
except json.JSONDecodeError:
return web.Response(status=400, text="invalid json")
if payload.get("event") != "messages":
# Photon currently emits only `messages`; any future event
# types are ack'd 200 so they don't retry.
return web.Response(text="ok")
msg = payload.get("message") or {}
msg_id = msg.get("id")
if not msg_id:
return web.Response(status=400, text="missing message.id")
if self._is_duplicate(msg_id):
return web.Response(text="ok (dup)")
logger.debug("[photon] skipping non-JSON inbound line")
return
msg_id = event.get("messageId")
if msg_id and self._is_duplicate(msg_id):
return
try:
await self._dispatch_inbound(payload)
await self._dispatch_inbound(event)
except Exception:
logger.exception("[photon] inbound dispatch failed")
# 200 anyway — we own the dedup; failing here would cause
# Photon to retry the same id.
return web.Response(text="ok")
def _is_duplicate(self, msg_id: str) -> bool:
now = time.time()
if len(self._seen_messages) > _DEDUP_MAX_SIZE:
cutoff = now - _DEDUP_WINDOW_SECONDS
self._seen_messages = {
k: v for k, v in self._seen_messages.items() if v > cutoff
}
if msg_id in self._seen_messages:
return True
self._seen_messages[msg_id] = now
seen = self._seen_messages
t = seen.get(msg_id)
if t is not None and now - t < _DEDUP_WINDOW_SECONDS:
return True # seen, unexpired
# New or expired: record and enforce a HARD size bound (evict oldest,
# insertion-order) so a burst of unique ids within the window can't grow
# the dict without limit — not just the expired-only prune.
if msg_id in seen:
del seen[msg_id] # refresh insertion order
seen[msg_id] = now
if len(seen) > _DEDUP_MAX_SIZE:
for old in list(seen.keys())[: len(seen) - _DEDUP_MAX_SIZE]:
del seen[old]
return False
async def _dispatch_inbound(self, payload: Dict[str, Any]) -> None:
msg = payload.get("message") or {}
space = msg.get("space") or payload.get("space") or {}
sender = msg.get("sender") or {}
content = msg.get("content") or {}
async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
"""Normalize a sidecar inbound event and dispatch it to the gateway.
Event shape (from ``sidecar/index.mjs``)::
{
"messageId": "...",
"platform": "iMessage",
"space": {"id": "...", "type": "dm"|"group", "phone": "+E164"},
"sender": {"id": "+E164"},
"content": {"type": "text", "text": "..."}
| {"type": "attachment"|"voice", "id", "name",
"mimeType", "size", "duration"?, "data"?,
"encoding"?},
"timestamp": "2026-05-14T19:06:32.000Z"
Attachment and voice content carry the bytes inline as base64 ``data``
(with ``encoding == "base64"``) when the sidecar could read them
within its size cap; otherwise only metadata is present and we surface
a marker.
}
"""
space = event.get("space") or {}
sender = event.get("sender") or {}
content = event.get("content") or {}
space_id = space.get("id") or ""
sender_id = sender.get("id") or ""
if not space_id:
logger.warning("[photon] inbound missing space.id")
return
# Space type — Photon documents iMessage DM ids as `any;-;+E164`
# and group ids as `any;+;<chat-guid>`. Use that as the
# heuristic; everything else is treated as DM.
chat_type = "group" if ";+;" in space_id else "dm"
# iMessage spaces carry their type directly — no id string-sniffing.
chat_type = "group" if space.get("type") == "group" else "dm"
sender_id = sender.get("id") or space.get("phone") or space_id
# Timestamp — ISO 8601 from the platform.
ts_str = msg.get("timestamp") or ""
ts_str = event.get("timestamp") or ""
try:
timestamp = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
timestamp = (
datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
if ts_str
else datetime.now(tz=timezone.utc)
)
except ValueError:
timestamp = datetime.now(tz=timezone.utc)
# Content normalization. Spectrum is a discriminated union;
# text vs attachment metadata. Attachments are metadata-only
# today (no download URL) — log + carry the name so the agent
# at least knows something was sent.
if content.get("type") == "text":
# Media attachments (local cached paths) handed to the agent via the
# gateway's image-routing path, exactly like the BlueBubbles channel.
media_urls: List[str] = []
media_types: List[str] = []
ctype = content.get("type")
if ctype == "text":
text = content.get("text") or ""
mtype = MessageType.TEXT
elif content.get("type") == "attachment":
name = content.get("name") or "(unnamed)"
elif ctype in {"attachment", "voice"}:
is_voice = ctype == "voice"
name = content.get("name") or ("voice" if is_voice else "(unnamed)")
mime = content.get("mimeType") or ""
text = f"[Photon attachment received: {name} ({mime}) — no download URL yet]"
mtype = _attachment_message_type(mime)
mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime)
cached = _cache_inbound_attachment(
content, name, mime, force_audio=is_voice
)
if cached:
media_urls.append(cached)
media_types.append(
mime or ("audio/mp4" if is_voice else "application/octet-stream")
)
# The real bytes are attached, so the agent sees the media
# itself — a short marker is enough text, and it keeps group
# mention-gating consistent with plain messages.
text = "(voice)" if is_voice else "(attachment)"
else:
# No bytes (over the sidecar cap, a failed read, or a caching
# failure) — fall back to a metadata marker so the agent still
# knows something arrived.
label = "voice" if is_voice else "attachment"
duration = content.get("duration")
duration_text = (
f", duration: {duration}s"
if isinstance(duration, (int, float))
else ""
)
text = (
f"[Photon {label} received: {name} "
f"({mime or 'unknown MIME'}{duration_text})]"
)
else:
text = f"[Photon content type not handled: {content.get('type')}]"
text = f"[Photon content type not handled: {ctype}]"
mtype = MessageType.TEXT
# Group-mention gating (parity with BlueBubbles). In group chats with
@@ -545,18 +536,20 @@ class PhotonAdapter(BasePlatformAdapter):
chat_id=space_id,
chat_name=space_id,
chat_type=chat_type,
user_id=sender_id or space_id,
user_id=sender_id,
user_name=sender_id or None,
)
event = MessageEvent(
message_event = MessageEvent(
text=text,
message_type=mtype,
source=source,
message_id=msg.get("id"),
raw_message=payload,
message_id=event.get("messageId"),
raw_message=event,
timestamp=timestamp,
media_urls=media_urls,
media_types=media_types,
)
await self.handle_message(event)
await self.handle_message(message_event)
# -- Sidecar lifecycle -------------------------------------------------
@@ -670,7 +663,7 @@ class PhotonAdapter(BasePlatformAdapter):
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
return await self._sidecar_send(chat_id, content, reply_to=reply_to)
return await self._sidecar_send(chat_id, self.format_message(content))
# -- Outbound media (parity with the BlueBubbles iMessage channel) -----
#
@@ -696,7 +689,7 @@ class PhotonAdapter(BasePlatformAdapter):
# Couldn't fetch the URL — fall back to sending it as text.
return await super().send_image(chat_id, image_url, caption, reply_to)
return await self._sidecar_send_attachment(
chat_id, local_path, caption=caption, reply_to=reply_to,
chat_id, local_path, caption=caption,
)
async def send_image_file(
@@ -709,7 +702,7 @@ class PhotonAdapter(BasePlatformAdapter):
**kwargs,
) -> SendResult:
return await self._sidecar_send_attachment(
chat_id, image_path, caption=caption, reply_to=reply_to,
chat_id, image_path, caption=caption,
)
async def send_voice(
@@ -722,7 +715,7 @@ class PhotonAdapter(BasePlatformAdapter):
**kwargs,
) -> SendResult:
return await self._sidecar_send_attachment(
chat_id, audio_path, caption=caption, reply_to=reply_to, kind="voice",
chat_id, audio_path, caption=caption, kind="voice",
)
async def send_video(
@@ -735,7 +728,7 @@ class PhotonAdapter(BasePlatformAdapter):
**kwargs,
) -> SendResult:
return await self._sidecar_send_attachment(
chat_id, video_path, caption=caption, reply_to=reply_to,
chat_id, video_path, caption=caption,
)
async def send_document(
@@ -749,7 +742,7 @@ class PhotonAdapter(BasePlatformAdapter):
**kwargs,
) -> SendResult:
return await self._sidecar_send_attachment(
chat_id, file_path, name=file_name, caption=caption, reply_to=reply_to,
chat_id, file_path, name=file_name, caption=caption,
)
async def send_animation(
@@ -767,23 +760,97 @@ class PhotonAdapter(BasePlatformAdapter):
async def send_typing(self, chat_id: str, metadata=None) -> None:
try:
await self._sidecar_call("/typing", {"spaceId": chat_id})
await self._sidecar_call(
"/typing", {"spaceId": chat_id, "state": "start"}
)
except Exception as e:
logger.debug("[photon] send_typing failed: %s", e)
async def stop_typing(self, chat_id: str) -> None:
try:
await self._sidecar_call(
"/typing", {"spaceId": chat_id, "state": "stop"}
)
except Exception as e:
logger.debug("[photon] stop_typing failed: %s", e)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Return whatever we know about a Spectrum space id.
Photon's `space.id` is opaque (`any;-;+E164` for DMs,
`any;+;<guid>` for groups). We surface that shape directly so
the gateway has something to show in session pickers / logs.
Photon's ``space.id`` is opaque; the inbound event also carries the
DM/group type, but here we only have the id, so infer conservatively.
"""
chat_type = "group" if ";+;" in chat_id else "dm"
return {"name": chat_id, "type": chat_type, "id": chat_id}
return {"name": chat_id, "type": "dm", "id": chat_id}
async def _sidecar_send(
self, space_id: str, text: str, *, reply_to: Optional[str] = None,
def format_message(self, content: str) -> str:
return strip_markdown(content)
async def _send_with_retry(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Any = None,
max_retries: int = 2,
base_delay: float = 2.0,
) -> SendResult:
"""Photon/iMessage is plain text, so never show the generic Markdown banner."""
text = self.format_message(content)
result = await self.send(
chat_id=chat_id,
content=text,
reply_to=reply_to,
metadata=metadata,
)
if result.success:
return result
error_str = result.error or ""
is_network = result.retryable or self._is_retryable_error(error_str)
if not is_network and self._is_timeout_error(error_str):
return result
if is_network:
for attempt in range(1, max_retries + 1):
delay = base_delay * (2 ** (attempt - 1))
logger.warning(
"[photon] Send failed (attempt %d/%d, retrying in %.1fs): %s",
attempt, max_retries, delay, error_str,
)
await asyncio.sleep(delay)
result = await self.send(
chat_id=chat_id,
content=text,
reply_to=reply_to,
metadata=metadata,
)
if result.success:
return result
error_str = result.error or ""
if not (result.retryable or self._is_retryable_error(error_str)):
break
else:
logger.error(
"[photon] Failed to deliver response after %d retries: %s",
max_retries, error_str,
)
return result
logger.warning(
"[photon] Send failed: %s - retrying plain-text message",
error_str,
)
fallback_result = await self.send(
chat_id=chat_id,
content=text[: self.MAX_MESSAGE_LENGTH],
reply_to=reply_to,
metadata=metadata,
)
if not fallback_result.success:
logger.error("[photon] Plain-text retry also failed: %s", fallback_result.error)
return fallback_result
async def _sidecar_send(self, space_id: str, text: str) -> SendResult:
if len(text) > self.MAX_MESSAGE_LENGTH:
logger.warning(
"[photon] truncating outbound from %d to %d chars",
@@ -791,8 +858,6 @@ class PhotonAdapter(BasePlatformAdapter):
)
text = text[: self.MAX_MESSAGE_LENGTH]
body: Dict[str, Any] = {"spaceId": space_id, "text": text}
if reply_to:
body["replyTo"] = reply_to
try:
data = await self._sidecar_call("/send", body)
except Exception as e:
@@ -807,7 +872,6 @@ class PhotonAdapter(BasePlatformAdapter):
name: Optional[str] = None,
mime_type: Optional[str] = None,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
kind: str = "attachment",
) -> SendResult:
"""POST a local file to the sidecar's ``/send-attachment`` endpoint.
@@ -842,8 +906,6 @@ class PhotonAdapter(BasePlatformAdapter):
body["mimeType"] = mime_type
if caption:
body["caption"] = caption
if reply_to:
body["replyTo"] = reply_to
try:
data = await self._sidecar_call("/send-attachment", body)
except Exception as e:
@@ -887,11 +949,87 @@ def _attachment_message_type(mime: str) -> MessageType:
return MessageType.DOCUMENT
# MIME → file-extension maps for caching inbound attachment bytes. These mirror
# the BlueBubbles iMessage channel so both adapters name cached media the same.
_IMAGE_EXT_BY_MIME = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/heic": ".jpg",
"image/heif": ".jpg",
"image/tiff": ".jpg",
}
_AUDIO_EXT_BY_MIME = {
"audio/mp3": ".mp3",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"audio/x-caf": ".mp3",
"audio/mp4": ".m4a",
"audio/aac": ".m4a",
}
def _cache_inbound_attachment(
content: Dict[str, Any],
name: str,
mime: str,
*,
force_audio: bool = False,
) -> Optional[str]:
"""Decode a base64-inlined inbound attachment and cache it locally.
The sidecar inlines the attachment bytes as ``content["data"]`` (base64).
We decode them and route to the shared media cache by MIME type, returning
the cached absolute path so the caller can populate ``media_urls`` (which
the gateway then hands to the model). Returns ``None`` when there are no
bytes (over the sidecar's inline cap or a failed read) or when caching
fails, so the caller can fall back to a text marker.
"""
data_b64 = content.get("data")
if not data_b64:
return None
try:
raw = base64.b64decode(data_b64)
except (ValueError, TypeError) as exc:
logger.warning("[photon] failed to decode inbound attachment bytes: %s", exc)
return None
from gateway.platforms.base import (
cache_audio_from_bytes,
cache_document_from_bytes,
cache_image_from_bytes,
)
mime = (mime or "").lower()
# Prefer the real extension from the filename; fall back to the MIME map.
suffix = Path(name).suffix if name else ""
try:
if mime.startswith("image/"):
ext = suffix or _IMAGE_EXT_BY_MIME.get(mime, ".jpg")
try:
return cache_image_from_bytes(raw, ext)
except ValueError:
# Bytes don't look like a supported image (e.g. HEIC magic) —
# still deliver them as a document rather than dropping them.
return cache_document_from_bytes(raw, name)
if force_audio or mime.startswith("audio/"):
ext = suffix or _AUDIO_EXT_BY_MIME.get(
mime, ".m4a" if force_audio else ".mp3"
)
return cache_audio_from_bytes(raw, ext)
# Video, application/*, and everything else → document cache.
return cache_document_from_bytes(raw, name)
except Exception as exc:
logger.warning("[photon] failed to cache inbound attachment %s: %s", name, exc)
return None
# ---------------------------------------------------------------------------
# Standalone (out-of-process) send for cron deliveries when the gateway
# is not co-resident. Spins up an ephemeral sidecar call by spawning
# the existing sidecar binary one-shot; if a live sidecar is already
# listening on the configured port we reuse it.
# is not co-resident. Reuses a live sidecar already listening on the
# configured port (cron processes cannot spawn the sidecar themselves).
async def _standalone_send(
pconfig: PlatformConfig,
@@ -980,7 +1118,7 @@ def register(ctx) -> None:
ctx.register_platform(
name="photon",
label="Photon iMessage",
label="iMessage via Photon",
adapter_factory=lambda cfg: PhotonAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
@@ -1011,7 +1149,7 @@ def register(ctx) -> None:
"Treat replies like regular text messages — short, friendly, no "
"markdown rendering. Recipient identifiers are E.164 phone "
"numbers; never expose them in responses unless the user asked. "
"Attachments arrive as metadata only (no download URL yet)."
"Attachments arrive as metadata only."
),
)
File diff suppressed because it is too large Load Diff
+179 -132
View File
@@ -7,25 +7,26 @@ Subcommands:
setup full first-time setup (device login + project + user + sidecar)
status show login + project + sidecar dep state
install-sidecar npm install inside plugins/platforms/photon/sidecar/
webhook register register the local webhook URL with Photon
webhook list list registered webhooks
webhook delete delete a webhook by id
The device-code login runs automatically as the first step of ``setup``;
there is no standalone ``login`` verb (matching how every other Hermes
gateway channel onboards through a single setup surface).
Photon uses the spectrum-ts gRPC stream for inbound there is no webhook
to register, so there are no webhook subcommands.
"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from hermes_cli.colors import Colors, color
from . import auth as photon_auth
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
@@ -38,9 +39,14 @@ def register_cli(parser: argparse.ArgumentParser) -> None:
"""Wire up `hermes photon ...` subcommands."""
subs = parser.add_subparsers(dest="photon_command", required=False)
p_setup = subs.add_parser("setup", help="First-time setup (device login + project + user + sidecar)")
p_setup.add_argument("--project-name", default=None, help="Project name (default: 'Hermes Agent')")
p_setup.add_argument("--phone", default=None, help="Your E.164 phone number (e.g. +15551234567)")
p_setup = subs.add_parser(
"setup",
help="First-time setup (device login + project + user + sidecar)",
)
p_setup.add_argument("--project-name", default=None,
help="Project name (default: 'Hermes Agent')")
p_setup.add_argument("--phone", default=None,
help="Your E.164 phone number (e.g. +15551234567)")
p_setup.add_argument("--first-name", default=None)
p_setup.add_argument("--last-name", default=None)
p_setup.add_argument("--email", default=None)
@@ -52,14 +58,6 @@ def register_cli(parser: argparse.ArgumentParser) -> None:
subs.add_parser("status", help="Show login + project + sidecar dep state")
subs.add_parser("install-sidecar", help="Run npm install inside the sidecar directory")
p_hook = subs.add_parser("webhook", help="Manage Photon webhook registrations")
hook_subs = p_hook.add_subparsers(dest="photon_webhook_command", required=True)
p_hook_reg = hook_subs.add_parser("register", help="Register a webhook URL")
p_hook_reg.add_argument("url", help="Publicly reachable URL Photon should POST to")
hook_subs.add_parser("list", help="List registered webhooks for the current project")
p_hook_del = hook_subs.add_parser("delete", help="Delete a webhook by id")
p_hook_del.add_argument("webhook_id")
parser.set_defaults(func=dispatch)
@@ -77,8 +75,6 @@ def dispatch(args: argparse.Namespace) -> int:
return _cmd_status(args)
if sub == "install-sidecar":
return _cmd_install_sidecar(args)
if sub == "webhook":
return _cmd_webhook(args)
print(f"unknown subcommand: {sub}", file=sys.stderr)
return 2
@@ -122,7 +118,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
# 1. Login (skip if we already have a token).
token = photon_auth.load_photon_token()
if not token:
print("[1/4] No Photon token found — running device login...")
print("[1/5] No Photon token found — running device login...")
rc = _run_device_login(args)
if rc != 0:
return rc
@@ -131,85 +127,178 @@ def _cmd_setup(args: argparse.Namespace) -> int:
print("login completed but token was not stored", file=sys.stderr)
return 1
else:
print("[1/4] Reusing existing Photon token")
print("[1/5] Reusing existing Photon token")
# 2. Create (or surface existing) project.
existing_id, existing_secret = photon_auth.load_project_credentials()
project_id: str
project_secret: str
if existing_id and existing_secret:
project_id, project_secret = existing_id, existing_secret
# `project_id` is a Photon-assigned UUID, not a secret — but we
# keep the print terse to avoid CodeQL flow noise.
print("[2/4] Reusing existing Photon project")
else:
name = args.project_name or "Hermes Agent"
print(f"[2/4] Creating Photon project '{name}' (spectrum=true, imessage)...")
try:
data = photon_auth.create_project(token, name=name)
except Exception as e:
print(f"create-project failed: {e}", file=sys.stderr)
return 1
project_id = data.get("spectrumProjectId") or data.get("id") or ""
project_secret = data.get("projectSecret") or ""
if not project_id or not project_secret:
print(
"create-project did not return spectrumProjectId + "
"projectSecret. Re-run after enabling Spectrum on the "
"project, or open https://app.photon.codes/ to fetch the "
"secret manually.",
file=sys.stderr,
)
return 1
photon_auth.store_project_credentials(project_id, project_secret, name=name)
print(" ✓ project provisioned (run `hermes photon status` to see the id)")
# 2. Find or create the "Hermes Agent" project.
name = args.project_name or photon_auth.DEFAULT_PROJECT_NAME
dashboard_id = photon_auth.load_dashboard_project_id()
try:
if dashboard_id:
print("[2/5] Reusing configured Photon project")
else:
existing = photon_auth.find_project_by_name(token, name)
if existing and existing.get("id"):
dashboard_id = existing["id"]
print(f"[2/5] Found existing project '{name}'")
else:
print(f"[2/5] Creating Photon project '{name}'...")
created = photon_auth.create_project(token, name=name)
dashboard_id = created.get("id")
print("project created")
except Exception as e:
print(f"project setup failed: {e}", file=sys.stderr)
return 1
if not dashboard_id:
print("could not resolve a Photon project id", file=sys.stderr)
return 1
# 3. Create a Spectrum user for the operator.
# 3. Enable Spectrum, fetch the spectrum project id, rotate the secret,
# and persist both (runtime creds -> ~/.hermes/.env, ids -> auth.json).
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)
secret = photon_auth.regenerate_project_secret(token, dashboard_id)
photon_auth.store_project_credentials(
spectrum_project_id=spectrum_id,
project_secret=secret,
dashboard_project_id=dashboard_id,
name=name,
)
# spectrum_id is an opaque non-secret id; safe to show.
print(f" ✓ Spectrum enabled (project id {spectrum_id}) — secret saved")
except Exception as e:
print(f"spectrum provisioning failed: {e}", file=sys.stderr)
return 1
# 4. Register the operator's phone number as a Spectrum user (idempotent).
phone = args.phone or _prompt(
"Your iMessage phone number (E.164, e.g. +15551234567): "
color(
"[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ",
Colors.CYAN,
)
)
agent_number = None
registered_phone = None
registered_user_id = None
if not phone:
print("[3/4] Skipped user creation (no phone given). Re-run with --phone later.")
print(" Skipped user registration (no phone given). Re-run with --phone later.")
else:
print("[3/4] Creating shared Spectrum user...")
# Name/email are optional and never prompted for — pass --first-name /
# --email if you want them sent to the dashboard.
first_name = args.first_name
email = args.email
try:
photon_auth.create_user(
project_id, project_secret,
user, created = photon_auth.register_user_if_absent(
spectrum_id, secret,
phone_number=phone,
first_name=args.first_name,
first_name=first_name,
last_name=args.last_name,
email=args.email,
email=email,
)
except ValueError as e:
print(f" invalid phone number: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f" user registration failed: {e}", file=sys.stderr)
return 1
print(" ✓ phone registered" if created else " ✓ phone already registered")
registered_phone = phone
registered_user_id = user.get("id")
# The number to text the agent is the user's assigned iMessage line
# (the dashboard's "TEXTS ON" column). On shared-number plans there is
# no dedicated entry in /lines, so this per-user field is the source of
# truth — and we already have it from the (reused) user object.
agent_number = photon_auth.user_assigned_line(user)
# Allowlist the operator and make their DM the cron home channel —
# otherwise the gateway denies their own inbound messages
# ("Unauthorized user") and has no default space for cron delivery.
_autoconfigure_access(phone)
# 5. Surface the agent's iMessage number (the number to text the agent).
if not agent_number:
# No per-user assignment — fall back to a dedicated line if the project
# has one provisioned in its line inventory.
try:
line = photon_auth.get_imessage_line(token, dashboard_id)
if line:
agent_number = line.get("phoneNumber")
except Exception as e:
print(f" (could not fetch the assigned line: {e})", file=sys.stderr)
if agent_number:
print()
print(color("┌─ Your agent's iMessage number ───────────────────────────────", Colors.GREEN))
print(
color("│ 📱 ", Colors.GREEN)
+ color(str(agent_number), Colors.GREEN, Colors.BOLD)
)
print(color("│ Text this number from your phone to talk to your agent.", Colors.GREEN))
print(color("└──────────────────────────────────────────────────────────────", Colors.GREEN))
else:
print(" No iMessage line assigned yet — check the Photon dashboard.")
if registered_phone:
try:
photon_auth.store_user_numbers(
phone_number=registered_phone,
assigned_phone_number=agent_number,
user_id=str(registered_user_id) if registered_user_id else None,
dashboard_project_id=dashboard_id,
)
except Exception as e:
print(f"create-user failed: {e}", file=sys.stderr)
return 1
print(" ✓ user created — check `hermes photon status` or the dashboard for the assigned iMessage line")
print(f" (could not save Photon status metadata: {e})", file=sys.stderr)
# 4. Sidecar deps.
# 6. Sidecar deps (spectrum-ts).
if args.skip_sidecar_install:
print("[4/4] Skipping sidecar npm install (--skip-sidecar-install)")
print("[5/5] Skipping sidecar npm install (--skip-sidecar-install)")
else:
print("[4/4] Installing Node sidecar deps (spectrum-ts)...")
print("[5/5] Installing Node sidecar deps (spectrum-ts)...")
rc = _install_sidecar()
if rc != 0:
return rc
print()
print("✓ Photon setup complete.")
print(" Next: register a webhook URL Photon can reach:")
print(" hermes photon webhook register https://YOUR-PUBLIC-URL/photon/webhook")
print(" Then start the gateway:")
print(" hermes gateway start --platform photon")
print(" Start the gateway: hermes gateway start --platform photon")
return 0
def _autoconfigure_access(phone: str) -> None:
"""Allowlist the operator and set their DM as the cron home channel.
Writes ``PHOTON_ALLOWED_USERS`` (so the gateway authorizes the operator's
own inbound messages instead of denying them) and ``PHOTON_HOME_CHANNEL``
(the default space for cron delivery) to the operator's E.164 number. Each
is only filled when unset, so a hand-tuned allowlist / home channel is
never clobbered on a re-run.
"""
try:
from hermes_cli.config import get_env_value, save_env_value
except ImportError:
return
for key, label in (
("PHOTON_ALLOWED_USERS", "allowlisted your number"),
("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel"),
):
try:
if get_env_value(key):
print(f" {key} already set — leaving it as-is.")
continue
save_env_value(key, phone)
print(f"{label} ({key})")
except Exception as e:
print(f" could not set {key}: {e}", file=sys.stderr)
def _cmd_status(_args: argparse.Namespace) -> int:
# Defer the whole table to auth.print_credential_summary — its emit
_refresh_status_numbers()
# Defer the credential rows to auth.print_credential_summary — its emit
# callback is the only sink that sees credential-derived strings, so
# cli.py keeps zero taint flow according to CodeQL.
photon_auth.print_credential_summary(print)
# The two non-credential rows live here so the helper stays purely
# about credentials.
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
sidecar_installed = (_SIDECAR_DIR / "node_modules").exists()
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
@@ -217,9 +306,21 @@ def _cmd_status(_args: argparse.Namespace) -> int:
return 0
def _refresh_status_numbers() -> None:
phone, assigned = photon_auth.load_user_numbers()
if phone and assigned:
return
spectrum_id, project_secret = photon_auth.load_project_credentials()
if not spectrum_id or not project_secret:
return
try:
photon_auth.refresh_user_numbers(spectrum_id, project_secret)
except Exception as e:
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
rc = _install_sidecar()
return rc
return _install_sidecar()
def _install_sidecar() -> int:
@@ -231,9 +332,13 @@ def _install_sidecar() -> int:
file=sys.stderr,
)
return 1
print(f" $ cd {_SIDECAR_DIR} && {npm} install")
# Always pull the newest published spectrum-ts so every setup runs against
# the latest SDK. `spectrum-ts@latest` bumps package.json + package-lock.json
# to the current release before installing — a plain `npm install` would
# stay pinned to whatever the committed lockfile already resolved.
print(f" $ cd {_SIDECAR_DIR} && {npm} install spectrum-ts@latest")
proc = subprocess.run( # noqa: S603
[npm, "install"],
[npm, "install", "spectrum-ts@latest"],
cwd=str(_SIDECAR_DIR),
check=False,
)
@@ -242,64 +347,6 @@ def _install_sidecar() -> int:
return proc.returncode
def _cmd_webhook(args: argparse.Namespace) -> int:
sub = getattr(args, "photon_webhook_command", None)
project_id, project_secret = photon_auth.load_project_credentials()
if not (project_id and project_secret):
print(
"no Photon project configured — run `hermes photon setup` first",
file=sys.stderr,
)
return 1
if sub == "register":
try:
data = photon_auth.register_webhook(
project_id, project_secret, webhook_url=args.url
)
except Exception as e:
print(f"register failed: {e}", file=sys.stderr)
return 1
# The helper does all the formatting + writing; cli.py never
# touches the signing-secret value, the path it was written
# to, or even the redacted-response dict. on_summary is a
# plain printer callback.
ok = photon_auth.persist_webhook_signing_secret(data, on_summary=print)
if not ok:
print(
"‼ Photon returned no signing secret in the response, "
"or the file write failed. Inspect your home directory "
"permissions and re-run; do not retry without first "
"deleting the orphaned webhook from the Photon dashboard.",
file=sys.stderr,
)
return 1
return 0
if sub == "list":
try:
data = photon_auth.list_webhooks(project_id, project_secret)
except Exception as e:
print(f"list failed: {e}", file=sys.stderr)
return 1
print(json.dumps(data, indent=2))
return 0
if sub == "delete":
try:
photon_auth.delete_webhook(
project_id, project_secret, webhook_id=args.webhook_id
)
except Exception as e:
print(f"delete failed: {e}", file=sys.stderr)
return 1
print(f"deleted webhook {args.webhook_id}")
return 0
print(f"unknown webhook subcommand: {sub}", file=sys.stderr)
return 2
# ---------------------------------------------------------------------------
# Gateway-setup entry point
#
+25 -40
View File
@@ -1,52 +1,37 @@
name: photon-platform
label: Photon iMessage
label: iMessage via Photon
kind: platform
version: 0.1.0
version: 0.2.0
description: >
Photon Spectrum gateway adapter for Hermes Agent.
Connects to iMessage (and other Spectrum interfaces) through Photon's
managed Spectrum platform. Inbound messages arrive as signed webhooks
on a local aiohttp server; outbound messages are sent via a small
supervised Node sidecar that runs the `spectrum-ts` SDK (Photon does
not currently expose a public HTTP send endpoint).
managed Spectrum platform. Both directions run over the `spectrum-ts`
SDK's long-lived gRPC stream via a small supervised Node sidecar —
inbound messages arrive on the SDK's `app.messages` stream (no webhook,
no public URL, no signing secret), and outbound messages are sent over
the same sidecar.
The plugin ships with a `hermes photon` CLI for the one-time login
+ project + user setup, persists Spectrum credentials to
``~/.hermes/auth.json`` under ``credential_pool.photon`` (token) and
``credential_pool.photon_project`` (project id + secret), and exposes
Photon's free shared-line model so users can get started without a
paid plan.
The plugin ships with a `hermes photon` CLI for the one-time device
login + project + user setup. Runtime credentials are written to
``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` = the Spectrum project id,
``PHOTON_PROJECT_SECRET``) like every other channel, with management
metadata (device token, dashboard project id) in ``~/.hermes/auth.json``.
Photon's free shared-line model lets users get started without a paid plan.
author: NousResearch
requires_env:
- name: PHOTON_PROJECT_ID
description: "Spectrum project ID (set by `hermes photon setup`)"
prompt: "Photon Spectrum project ID"
description: "Spectrum project id (the project's spectrumProjectId; set by `hermes photon setup`)"
prompt: "Photon Spectrum project id"
url: "https://app.photon.codes/"
password: false
- name: PHOTON_PROJECT_SECRET
description: "Spectrum project secret (set by `hermes photon setup`)"
prompt: "Photon Spectrum project secret"
description: "Project secret paired with the Spectrum project id (set by `hermes photon setup`)"
prompt: "Photon project secret"
url: "https://app.photon.codes/"
password: true
optional_env:
- name: PHOTON_WEBHOOK_SECRET
description: "Per-URL HMAC-SHA256 signing secret returned at webhook registration"
prompt: "Photon webhook signing secret"
password: true
- name: PHOTON_WEBHOOK_PORT
description: "Local port the webhook receiver listens on (default 8788)"
prompt: "Webhook receiver port"
password: false
- name: PHOTON_WEBHOOK_PATH
description: "Path the webhook receiver listens on (default /photon/webhook)"
prompt: "Webhook receiver path"
password: false
- name: PHOTON_WEBHOOK_BIND
description: "Bind address for the webhook receiver (default 0.0.0.0)"
prompt: "Webhook bind address"
password: false
- name: PHOTON_SIDECAR_PORT
description: "Loopback port for the Node sidecar control channel (default 8789)"
description: "Loopback port for the Node sidecar control + inbound channel (default 8789)"
prompt: "Sidecar control port"
password: false
- name: PHOTON_SIDECAR_AUTOSTART
@@ -57,14 +42,14 @@ optional_env:
description: "Path to the node binary (default: shutil.which('node'))"
prompt: "Node executable path"
password: false
- name: PHOTON_API_HOST
description: "Spectrum management API host (default https://spectrum.photon.codes)"
prompt: "Spectrum API host"
password: false
- name: PHOTON_DASHBOARD_HOST
description: "Dashboard API host (default https://app.photon.codes)"
description: "Photon Dashboard API host (default https://app.photon.codes)"
prompt: "Dashboard host"
password: false
- name: PHOTON_SPECTRUM_HOST
description: "Photon Spectrum API host (default https://spectrum.photon.codes)"
prompt: "Spectrum API host"
password: false
- name: PHOTON_ALLOWED_USERS
description: "Comma-separated E.164 phone numbers allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
@@ -82,8 +67,8 @@ optional_env:
prompt: "Group mention patterns"
password: false
- name: PHOTON_HOME_CHANNEL
description: "Default Spectrum space ID for cron / notification delivery"
prompt: "Home space ID"
description: "Default Photon target for cron / notification delivery: Spectrum space id, DM GUID, or bare E.164 phone number"
prompt: "Home Photon target"
password: false
- name: PHOTON_HOME_CHANNEL_NAME
description: "Human label for the home channel"
+349 -75
View File
@@ -1,40 +1,47 @@
// Hermes Agent — Photon Spectrum sidecar
//
// Spawned by `plugins/platforms/photon/adapter.py` to bridge outbound
// messaging to Photon's Spectrum platform. Inbound messages go directly
// from Photon's webhook to Hermes' Python aiohttp receiver — this
// sidecar handles ONLY outbound calls (which require the spectrum-ts
// SDK because Photon has no public HTTP send endpoint today).
// Spawned by `plugins/platforms/photon/adapter.py` to bridge BOTH directions
// of messaging to Photon's Spectrum platform via the `spectrum-ts` SDK (the
// SDK is TypeScript-only, so a Node sidecar is unavoidable — there is no
// Python SDK and no public HTTP message API).
//
// Protocol:
// - The sidecar listens on http://127.0.0.1:${PORT} (loopback only)
// - Each request must include `X-Hermes-Sidecar-Token: ${TOKEN}`
// - POST /healthz -> {"ok": true}
// - POST /send -> {"ok": true, "messageId": "..."}
// body: {"spaceId": "...", "text": "...", "replyTo": "..." | null}
// - POST /send-attachment -> {"ok": true, "messageId": "..."}
// Inbound (gRPC -> Hermes): the SDK's `app.messages` async iterator is a
// long-lived gRPC stream. We serialize each `[space, message]` to a
// normalized JSON event and stream it to the Python adapter over a
// loopback `GET /inbound` (NDJSON). We pause pulling from the stream while
// no consumer is attached so a backlog isn't pulled-and-lost before the
// gateway connects.
// Outbound (Hermes -> gRPC): `/send` drives `space.send(...)`; `/typing`
// sends the documented `typing("start" | "stop")` content builder.
//
// Protocol (all requests require `X-Hermes-Sidecar-Token: ${TOKEN}`):
// - GET /inbound -> 200 NDJSON stream; one JSON event per line, blank
// lines are heartbeats. One consumer at a time.
// - POST /healthz -> {"ok": true}
// - POST /send -> {"ok": true, "messageId": "..."}
// body: {"spaceId": "...", "text": "..."}
// - POST /send-attachment -> {"ok": true, "messageId": "..."}
// body: {"spaceId": "...", "path": "...", "name": "..." | null,
// "mimeType": "..." | null, "caption": "..." | null,
// "kind": "attachment" | "voice", "replyTo": "..." | null}
// - POST /typing -> {"ok": true}
// body: {"spaceId": "..."}
// - POST /shutdown -> {"ok": true}; then process exits
// "kind": "attachment" | "voice"}
// - POST /typing -> {"ok": true}
// body: {"spaceId": "...", "state": "start" | "stop"}
// - POST /shutdown -> {"ok": true}; then process exits
//
// On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before
// exiting. Errors are logged to stderr; Python supervises restart.
// exiting. Logs go to stderr; Python supervises restart.
//
// Env vars (all required):
// PHOTON_PROJECT_ID
// Env vars (required):
// PHOTON_PROJECT_ID (== the project's spectrumProjectId)
// PHOTON_PROJECT_SECRET
// PHOTON_SIDECAR_PORT
// PHOTON_SIDECAR_TOKEN
//
// Optional:
// PHOTON_SIDECAR_BIND (default 127.0.0.1)
// PHOTON_API_HOST (passed through to spectrum-ts if its config
// honours it)
// PHOTON_SIDECAR_BIND (default 127.0.0.1)
import http from "node:http";
import crypto from "node:crypto";
import { once } from "node:events";
const projectId = process.env.PHOTON_PROJECT_ID;
const projectSecret = process.env.PHOTON_PROJECT_SECRET;
@@ -42,6 +49,17 @@ const port = parseInt(process.env.PHOTON_SIDECAR_PORT || "8789", 10);
const bind = process.env.PHOTON_SIDECAR_BIND || "127.0.0.1";
const sharedToken = process.env.PHOTON_SIDECAR_TOKEN;
// Inbound binary content is read into memory and base64-inlined on the NDJSON
// event so the Python adapter can cache the real bytes (and the agent can see
// images / transcribe voice). Cap the size we inline — above it we forward
// metadata only and the adapter surfaces a text marker, so one large clip can't
// balloon a single NDJSON line. Override via PHOTON_MAX_INLINE_ATTACHMENT_BYTES.
const MAX_INLINE_ATTACHMENT_BYTES =
Number(process.env.PHOTON_MAX_INLINE_ATTACHMENT_BYTES) || 20 * 1024 * 1024;
const DM_CHAT_GUID_RE = /^any;-;(\+\d{6,})$/;
const E164_RE = /^\+\d{6,}$/;
const MAX_KNOWN_SPACES = 2048;
if (!projectId || !projectSecret || !sharedToken) {
console.error(
"photon-sidecar: PHOTON_PROJECT_ID, PHOTON_PROJECT_SECRET and " +
@@ -52,9 +70,15 @@ if (!projectId || !projectSecret || !sharedToken) {
// Lazy-load spectrum-ts so a missing install fails with a clear message
// instead of a cryptic module-resolution error during import.
let Spectrum, imessage, attachment, voice;
let Spectrum, imessage, attachment, voice, spectrumText, spectrumTyping;
try {
({ Spectrum, attachment, voice } = await import("spectrum-ts"));
({
Spectrum,
attachment,
voice,
text: spectrumText,
typing: spectrumTyping,
} = await import("spectrum-ts"));
({ imessage } = await import("spectrum-ts/providers/imessage"));
} catch (e) {
console.error(
@@ -69,31 +93,219 @@ const app = await Spectrum({
projectId,
projectSecret,
providers: [imessage.config()],
options: { flattenGroups: true },
});
// Drain the inbound stream — Photon's webhook is the canonical inbound
// path, but we still consume `app.messages` so spectrum-ts' internal
// reconnect/heartbeat logic keeps running. Each event is logged at
// debug level; everything else is a no-op here.
(async () => {
try {
for await (const [, message] of app.messages) {
// ---------------------------------------------------------------------------
// Inbound: forward `app.messages` (gRPC stream) to the Python consumer.
// At most one Python consumer is attached at a time (the gateway adapter).
let consumerRes = null;
let consumerWaiters = [];
const knownSpaces = new Map();
function rememberKnownSpace(id, space) {
if (!id || typeof id !== "string" || !space) return;
if (knownSpaces.has(id)) knownSpaces.delete(id);
knownSpaces.set(id, space);
if (knownSpaces.size > MAX_KNOWN_SPACES) {
const oldest = knownSpaces.keys().next().value;
if (oldest) knownSpaces.delete(oldest);
}
}
function phoneTargetFromSpaceId(spaceId) {
if (typeof spaceId !== "string") return null;
if (E164_RE.test(spaceId)) return spaceId;
const dmGuid = spaceId.match(DM_CHAT_GUID_RE);
return dmGuid ? dmGuid[1] : null;
}
function rememberInboundSpace(space, message) {
const msgSpace = message?.space || {};
const ids = [space?.id, msgSpace.id];
for (const id of ids) {
rememberKnownSpace(id, space);
const phone = phoneTargetFromSpaceId(id);
if (phone) rememberKnownSpace(phone, space);
}
}
function waitForConsumer() {
if (consumerRes) return Promise.resolve();
return new Promise((resolve) => consumerWaiters.push(resolve));
}
function setConsumer(res) {
consumerRes = res;
const waiters = consumerWaiters;
consumerWaiters = [];
for (const resolve of waiters) resolve();
}
function clearConsumer(res) {
if (consumerRes === res) consumerRes = null;
}
// Write one NDJSON line to the active consumer. Blocks until a consumer is
// connected; if the write fails (consumer vanished mid-flight) we wait for a
// new consumer and retry, so a message is never silently dropped here.
async function deliver(line) {
for (;;) {
await waitForConsumer();
const res = consumerRes;
if (!res) continue;
try {
const flushed = res.write(line + "\n");
if (!flushed) await once(res, "drain");
return;
} catch {
clearConsumer(res);
}
}
}
async function normalizeBinaryContent(content) {
const meta = {
type: content.type,
id: content.id ?? null,
name: content.name ?? null,
mimeType: content.mimeType ?? null,
size: typeof content.size === "number" ? content.size : null,
};
if (content.type === "voice" && typeof content.duration === "number") {
meta.duration = content.duration;
}
// Read the bytes eagerly and base64-inline them as `data` so the Python
// adapter can cache the real file (the agent then sees images and can run
// STT on voice notes). Spectrum content objects may not outlive this stream
// iteration, so a lazy/on-demand fetch isn't safe. Over-cap content (when
// size is known up front) is forwarded as metadata only and the adapter falls
// back to a text marker. A read failure must never break the inbound loop.
const label = `${content.type} ${meta.name ?? meta.id ?? "(unnamed)"}`;
if (meta.size !== null && meta.size > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: ${label} (${meta.size} bytes) ` +
`exceeds inline cap ${MAX_INLINE_ATTACHMENT_BYTES}; forwarding metadata only`
);
return meta;
}
if (typeof content.read === "function") {
try {
const buf = await content.read();
// Guard the case where size was unknown but the bytes turn out to be
// over the cap.
if (buf && buf.length > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: ${label} (${buf.length} bytes) ` +
`exceeds inline cap after read; forwarding metadata only`
);
return meta;
}
meta.data = Buffer.from(buf).toString("base64");
meta.encoding = "base64";
} catch (e) {
console.error(
`photon-sidecar: drained inbound from ${message.platform} ` +
`space=${message.space?.id}`
`photon-sidecar: failed to read ${content.type} bytes ` +
"(forwarding metadata only): " +
(e && e.stack ? e.stack : String(e))
);
}
}
return meta;
}
async function normalizeContent(content) {
if (!content || typeof content !== "object") {
return { type: "unknown" };
}
if (content.type === "text") {
return { type: "text", text: content.text || "" };
}
if (content.type === "attachment" || content.type === "voice") {
return await normalizeBinaryContent(content);
}
return { type: content.type || "unknown" };
}
async function normalizeEvent(space, message) {
try {
const msgSpace = message.space || {};
const ts = message.timestamp;
return {
messageId: message.id ?? null,
platform: message.platform || space.__platform || "iMessage",
space: {
id: space.id ?? msgSpace.id ?? null,
// iMessage spaces carry `type` ("dm"|"group") and `phone` directly.
type: space.type ?? msgSpace.type ?? "dm",
phone: space.phone ?? msgSpace.phone ?? null,
},
sender: { id: message.sender ? message.sender.id : null },
content: await normalizeContent(message.content),
timestamp:
ts instanceof Date ? ts.toISOString() : ts ? String(ts) : null,
};
} catch (e) {
console.error(
"photon-sidecar: inbound stream errored: " +
(e && e.stack ? e.stack : String(e))
"photon-sidecar: failed to normalize inbound message: " + String(e)
);
return null;
}
}
// spectrum-ts handles in-session gRPC reconnects internally, but if the async
// iterator itself throws or ends, this consumer would stop forever. Wrap it in
// a re-subscribe loop with capped exponential backoff + jitter so inbound
// always recovers (the adapter dedupes any catch-up replay).
(async () => {
let backoff = 1000;
for (;;) {
try {
for await (const [space, message] of app.messages) {
backoff = 1000; // healthy traffic — reset
// Only forward inbound messages (ignore our own outbound echoes).
if (message && message.direction && message.direction !== "inbound") {
continue;
}
rememberInboundSpace(space, message);
const event = await normalizeEvent(space, message);
if (!event) continue;
await deliver(JSON.stringify(event));
}
console.error("photon-sidecar: inbound stream ended — re-subscribing");
} catch (e) {
console.error(
"photon-sidecar: inbound stream errored — restarting: " +
(e && e.message ? e.message : String(e))
);
}
await new Promise((r) =>
setTimeout(r, backoff + Math.random() * backoff * 0.2)
);
backoff = Math.min(backoff * 2, 30000);
}
})();
// ---------------------------------------------------------------------------
// HTTP control + inbound server (loopback only).
// Control-message bodies are tiny; cap the body so a compromised local peer
// can't OOM the sidecar by streaming an unbounded request (defence-in-depth on
// the loopback channel).
const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MiB
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
req.destroy();
throw new Error("request body too large");
}
chunks.push(chunk);
}
const raw = Buffer.concat(chunks).toString("utf-8");
if (!raw) return {};
try {
@@ -130,34 +342,92 @@ function ok(res, data) {
res.end(JSON.stringify({ ok: true, ...data }));
}
async function resolveSpace(spaceId) {
// spectrum-ts exposes the same Space methods via `app.space(spaceId)` /
// narrowed helpers; we fall back through a few accessor shapes to
// tolerate small SDK API drift.
if (typeof app.space === "function") {
return await app.space(spaceId);
}
if (app.spaces && typeof app.spaces.get === "function") {
return await app.spaces.get(spaceId);
}
// Last resort — the platform-narrowed helper.
if (imessage) {
const im = imessage(app);
if (typeof im.space === "function") {
try {
return await im.space({ id: spaceId });
} catch {
/* fall through */
}
function handleInbound(req, res) {
res.statusCode = 200;
res.setHeader("Content-Type", "application/x-ndjson");
res.setHeader("Cache-Control", "no-store");
res.setHeader("Connection", "keep-alive");
// One consumer at a time — a fresh connection (e.g. after a reconnect)
// supersedes the previous one.
if (consumerRes && consumerRes !== res) {
try {
consumerRes.end();
} catch {
/* ignore */
}
}
setConsumer(res);
// Heartbeat keeps the socket warm through idle periods and lets the Python
// side detect a dead pipe promptly.
const heartbeat = setInterval(() => {
try {
res.write("\n");
} catch {
/* ignore */
}
}, 25000);
const cleanup = () => {
clearInterval(heartbeat);
clearConsumer(res);
};
req.on("close", cleanup);
req.on("aborted", cleanup);
res.on("error", cleanup);
}
async function resolveSpace(spaceId) {
const cached = knownSpaces.get(spaceId);
if (cached) return cached;
const phoneTarget = phoneTargetFromSpaceId(spaceId);
// A bare E.164 phone number addresses a DM. Resolve the user, then the (DM)
// space — `imessage(app).user(phone)` -> `im.space(user)` — so callers can
// pass just "+1..." (e.g. PHOTON_HOME_CHANNEL for cron delivery) instead of
// an opaque inbound space id. Photon also represents DM chat ids as
// `any;-;+1...`; normalize those through the same path so replies to inbound
// DMs still resolve after Python stores the inbound `space.id`.
if (phoneTarget && imessage) {
try {
const im = imessage(app);
const user = await im.user(phoneTarget);
const space = await im.space(user);
rememberKnownSpace(spaceId, space);
rememberKnownSpace(phoneTarget, space);
rememberKnownSpace(space?.id, space);
return space;
} catch (e) {
console.error(
"photon-sidecar: phone->DM resolution failed: " +
(e && e.stack ? e.stack : String(e))
);
}
}
// No cache hit and not a phone/DM target. spectrum-ts exposes no API to
// rehydrate an arbitrary opaque space id: a Space is only obtained from the
// inbound `[space, message]` stream (cached above in `knownSpaces`) or
// reconstructed for a DM from its phone number. So a group space whose cache
// entry was lost — e.g. after a sidecar restart with no fresh inbound message
// in that group — cannot be resolved here; a new inbound message in the group
// re-warms the cache. DMs are unaffected (reconstructed from the phone).
throw new Error(`unable to resolve space id ${spaceId}`);
}
// Constant-time token comparison — don't leak the token via `!==` timing.
const _tokenBuf = Buffer.from(sharedToken);
function tokenOk(header) {
if (typeof header !== "string") return false;
const h = Buffer.from(header);
return h.length === _tokenBuf.length && crypto.timingSafeEqual(h, _tokenBuf);
}
const server = http.createServer(async (req, res) => {
if (req.headers["x-hermes-sidecar-token"] !== sharedToken) {
if (!tokenOk(req.headers["x-hermes-sidecar-token"])) {
return unauthorized(res);
}
// Long-lived inbound NDJSON stream.
if (req.method === "GET" && req.url === "/inbound") {
return handleInbound(req, res);
}
if (req.method !== "POST") {
res.statusCode = 405;
return res.end();
@@ -173,18 +443,16 @@ const server = http.createServer(async (req, res) => {
}
const body = await readBody(req);
if (req.url === "/send") {
const { spaceId, text, replyTo } = body || {};
const { spaceId, text } = body || {};
if (!spaceId || typeof text !== "string") {
return badRequest(res, "spaceId and text are required");
}
const space = await resolveSpace(spaceId);
const result = replyTo
? await space.send(text, { replyTo })
: await space.send(text);
return ok(res, { messageId: result?.id || result?.messageId || null });
const result = await space.send(spectrumText(text));
return ok(res, { messageId: result?.id || null });
}
if (req.url === "/send-attachment") {
const { spaceId, path, name, mimeType, caption, kind, replyTo } =
const { spaceId, path, name, mimeType, caption, kind } =
body || {};
if (!spaceId || typeof path !== "string" || !path) {
return badRequest(res, "spaceId and path are required");
@@ -202,16 +470,13 @@ const server = http.createServer(async (req, res) => {
? voice(path, Object.keys(opts).length ? opts : undefined)
: attachment(path, Object.keys(opts).length ? opts : undefined);
const sendOpts = replyTo ? { replyTo } : undefined;
const result = sendOpts
? await space.send(builder, sendOpts)
: await space.send(builder);
const result = await space.send(builder);
// iMessage delivers the caption as a separate bubble; send it
// after the media so the attachment renders first.
if (caption && typeof caption === "string") {
try {
await space.send(caption);
await space.send(spectrumText(caption));
} catch (e) {
console.error(
"photon-sidecar: attachment sent but caption failed: " +
@@ -219,17 +484,16 @@ const server = http.createServer(async (req, res) => {
);
}
}
return ok(res, { messageId: result?.id || result?.messageId || null });
return ok(res, { messageId: result?.id || null });
}
if (req.url === "/typing") {
const { spaceId } = body || {};
const { spaceId, state = "start" } = body || {};
if (!spaceId) return badRequest(res, "spaceId is required");
const space = await resolveSpace(spaceId);
if (typeof space.typing === "function") {
await space.typing();
} else if (typeof space.setTyping === "function") {
await space.setTyping(true);
if (state !== "start" && state !== "stop") {
return badRequest(res, "state must be start or stop");
}
const space = await resolveSpace(spaceId);
await space.send(spectrumTyping(state));
return ok(res, {});
}
res.statusCode = 404;
@@ -266,3 +530,13 @@ async function shutdown(signal) {
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
// Don't let a stray promise rejection take the process down silently — handlers
// catch their own errors, so log and keep serving (Python supervises restart on
// a real fatal exit).
process.on("unhandledRejection", (reason) => {
console.error(
"photon-sidecar: unhandledRejection: " +
(reason && reason.stack ? reason.stack : String(reason))
);
});
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
{
"name": "@hermes-agent/photon-sidecar",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.",
"type": "module",
"main": "index.mjs",
@@ -12,6 +12,13 @@
"node": ">=18.17"
},
"dependencies": {
"spectrum-ts": "^0.1.0"
"spectrum-ts": "^1.18.0"
},
"overrides": {
"protobufjs": "8.6.1",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
"@opentelemetry/exporter-logs-otlp-http": "0.218.0"
}
}
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -1,7 +1,7 @@
name: simplex-platform
label: SimpleX Chat
kind: platform
version: 1.0.0
version: 1.1.0
description: >
SimpleX Chat gateway adapter for Hermes Agent.
Connects to a local simplex-chat daemon via WebSocket and relays
@@ -9,7 +9,7 @@ description: >
SimpleX is decentralised and assigns no persistent user IDs —
every contact is an opaque internal ID generated at connection
time, making it one of the most private messengers available.
author: Mibayy
author: Mibayy, jooray
# ``requires_env`` and ``optional_env`` entries are surfaced in the
# ``hermes config`` UI via the platform-plugin env var injector in
# ``hermes_cli/config.py``.
@@ -27,6 +27,18 @@ optional_env:
description: "Allow any contact to talk to the bot (dev only — disables allowlist)"
prompt: "Allow all contacts? (true/false)"
password: false
- name: SIMPLEX_AUTO_ACCEPT
description: "Auto-accept incoming contact requests (default: true)"
prompt: "Auto-accept contact requests? (true/false)"
password: false
- name: SIMPLEX_GROUP_ALLOWED
description: >-
Comma-separated SimpleX group IDs the bot should participate in, or
'*' to allow any group. Omit to ignore group messages entirely
(safer default — a bot in a group otherwise processes every
member's traffic).
prompt: "Allowed group IDs (comma-separated, or '*' for any)"
password: false
- name: SIMPLEX_HOME_CHANNEL
description: "Default contact/group ID for cron / notification delivery"
prompt: "Home channel contact/group ID (or empty)"
@@ -35,3 +47,10 @@ optional_env:
description: "Human label for the home channel (defaults to the ID)"
prompt: "Home channel display name (or empty)"
password: false
- name: HERMES_SIMPLEX_TEXT_BATCH_DELAY
description: >-
Quiet-period seconds (default: 0.8) used to concatenate rapid-fire
inbound text messages into a single MessageEvent — same pattern as
Telegram's text batching.
prompt: "Text batch flush delay in seconds (default 0.8)"
password: false
+30 -3
View File
@@ -61,6 +61,16 @@ dependencies = [
"prompt_toolkit==3.0.52",
# Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
"croniter==6.0.0",
# ``packaging`` is imported directly on three production paths but was never
# declared, so it only reached users transitively (pip/uv pull it for other
# tools). The slim official Docker image ships without it, where the
# try/except-ImportError fallbacks silently degrade: Hindsight's
# ``_meets_minimum_version`` disables update_mode='append' (#40503),
# tools/lazy_deps.py treats every version constraint as satisfied, and
# hermes_cli/main.py drops to naive requirement parsing. Pure-Python
# py3-none-any wheel, no compiled extensions — safe to ship everywhere.
# Pinned to the version already resolved in uv.lock (no resolution churn).
"packaging==26.0",
# Markdown -> HTML conversion for rich message delivery (Matrix
# `formatted_body`, and the `send_message` tool's HTML path). Now on the
# DEFAULT delivery path, not matrix-specific: without it both
@@ -72,7 +82,10 @@ dependencies = [
# it out of the lazy-install path that exists only for the heavy matrix deps.
"Markdown==3.10.2",
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
"PyJWT[crypto]==2.12.1", # CVE-2026-32597
"PyJWT[crypto]==2.13.0", # PYSEC-2026-175/177/178/179
# urllib3 2.7.0 fixes GHSA-mf9v-mfxr-j63j (decompression-bomb bypass)
# and GHSA-qccp-gfcp-xxvc (header leak across origins).
"urllib3>=2.7.0,<3",
# Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo``
# (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone
# out of the box. ``tzdata`` ships the Olson database as a data package
@@ -272,6 +285,20 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector
# venv) drop the catalogs and gateway/CLI commands surface raw i18n keys like
# `gateway.reset.header_default` (#27632, #35374, #23943).
locales = ["locales/*.yaml"]
# Shipped MCP catalog (optional-mcps/<name>/manifest.yaml). Same bare-data-dir
# case as locales: data-files ships it in the wheel, `graft optional-mcps` in
# MANIFEST.in ships it in the sdist. Without this, `hermes mcp catalog` and the
# dashboard catalog screen come up empty on packaged installs even though the
# manifests exist in the repo (hermes_cli/mcp_catalog.py:_catalog_root resolves
# the packaged dir; list_catalog() returns [] when it's missing).
#
# data-files flattens every glob match into its single target dir, so each
# catalog entry needs its OWN target to preserve the per-entry directory the
# catalog iterates over (a shared `optional-mcps/*/*` glob would collapse all
# manifests into one colliding optional-mcps/manifest.yaml). One target per
# entry; tests/test_packaging_metadata.py enforces an entry per optional-mcps/<name>.
"optional-mcps/linear" = ["optional-mcps/linear/manifest.yaml"]
"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
@@ -300,12 +327,12 @@ markers = [
"integration: marks tests requiring external services (API keys, Modal, etc.)",
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
]
# pytest-timeout: per-test 30s hard cap with signal method.
# pytest-timeout: per-test 30s hard cap with cross-platform thread method.
# This is the fallback inside each per-file pytest subprocess (see
# scripts/run_tests_parallel.py). Per-file isolation gives every test
# file a fresh Python interpreter; pytest-timeout catches Python-level
# hangs within a file.
addopts = "-m 'not integration' --timeout=30 --timeout-method=signal"
addopts = "-m 'not integration' --timeout=30 --timeout-method=thread"
[tool.ty.environment]
python-version = "3.13"
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Check that subprocess calls in TUI-context code specify stdin=.
When Hermes runs in TUI mode, the gateway child process communicates with
the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that
inherit this fd can cause the gateway to exit with stdin EOF during tool
execution (issue #14036, PR #39257).
This script checks that all subprocess.run() and subprocess.Popen() calls
in TUI-context files (agent/, tools/, plugins/, tui_gateway/) explicitly
set stdin= to prevent fd inheritance.
Exit codes:
0 all calls are safe
1 violations found
2 script error
Usage:
python scripts/check_subprocess_stdin.py [--fix]
With --fix, prints the commands to add stdin=subprocess.DEVNULL to each
violation (does not modify files).
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
# Directories that run inside the TUI gateway child process.
TUI_CONTEXT_DIRS = [
"agent/",
"tools/",
"plugins/",
"tui_gateway/",
]
# Files with intentional stdin= override (e.g. input= creates a pipe).
# Format: "filepath:line" or just "filepath" to skip the whole file.
KNOWN_SAFE = {
"agent/shell_hooks.py", # uses input=stdin_json, creates a pipe
"plugins/security-guidance/patterns.py", # subprocess mentions are in reminder strings, not calls
}
# Inline marker that exempts a single subprocess call from this check.
# Put it in a comment on (or within) the call when the process MUST inherit
# stdin — e.g. an interactive login the user explicitly invokes. Travels with
# the line, so it survives edits that shift line numbers (unlike a pinned
# file:line entry).
EXEMPT_MARKER = "noqa: subprocess-stdin"
# Directories to skip entirely.
SKIP_DIRS = {
"tests/",
"scripts/",
"skills/",
"optional-skills/",
"hermes_cli/",
"gateway/",
"cron/",
}
def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
"""Find all subprocess.run/Popen calls missing stdin= in content."""
violations = []
lines = content.split("\n")
# Match only actual function calls — not comments, docstrings, or prose.
# The pattern requires an opening paren followed by an arg character
# (quote, bracket, letter, or closing paren for empty calls).
# This excludes ``subprocess.Popen(...)`` in docstrings and
# subprocess.run(...) in comments.
pattern = re.compile(r'subprocess\.(run|Popen)\s*\(["\'a-zA-Z_\[\(]')
for i, line in enumerate(lines):
# Skip comments.
stripped = line.lstrip()
if stripped.startswith("#"):
continue
# Skip lines where the match is inside backticks (docstring references).
if "``subprocess" in line:
continue
if not pattern.search(line):
continue
# Collect the full call (may span multiple lines).
call_start = i
paren_depth = 0
found_open = False
call_lines = []
for j in range(i, min(i + 30, len(lines))):
call_lines.append(lines[j])
for ch in lines[j]:
if ch == "(":
paren_depth += 1
found_open = True
elif ch == ")":
paren_depth -= 1
if found_open and paren_depth == 0:
call_text = "\n".join(call_lines)
# Already has stdin= → safe.
if "stdin=" in call_text:
break
# Has input= → creates a pipe, safe.
if "input=" in call_text:
break
# Inline exemption marker on the call itself or within
# the few comment lines immediately above it → the call
# intentionally inherits stdin.
window_start = max(0, i - 4)
preceding = "\n".join(lines[window_start:i])
if EXEMPT_MARKER in call_text or EXEMPT_MARKER in preceding:
break
violations.append({
"file": filepath,
"line": i + 1,
"snippet": line.strip()[:120],
})
break
else:
continue
break
return violations
def main() -> int:
fix_mode = "--fix" in sys.argv
repo_root = Path(__file__).resolve().parent.parent
os.chdir(repo_root)
all_violations = []
for tui_dir in TUI_CONTEXT_DIRS:
dirpath = repo_root / tui_dir
if not dirpath.exists():
continue
for py_file in dirpath.rglob("*.py"):
rel = str(py_file.relative_to(repo_root))
# Skip known-safe files.
if rel in KNOWN_SAFE:
continue
# Skip test files inside tools/ etc.
parts = py_file.parts
if any(skip.rstrip("/") in parts for skip in SKIP_DIRS):
continue
content = py_file.read_text()
violations = find_subprocess_calls(content, rel)
all_violations.extend(violations)
if all_violations:
print(f"{len(all_violations)} subprocess calls missing stdin=:")
for v in all_violations:
print(f" {v['file']}:{v['line']}: {v['snippet']}")
if fix_mode:
print("\nAdd stdin=subprocess.DEVNULL to each call above.")
return 1
else:
print("✅ All TUI-context subprocess calls have explicit stdin=")
return 0
if __name__ == "__main__":
sys.exit(main())
+30 -12
View File
@@ -1725,7 +1725,7 @@ function Write-BootstrapMarker {
function Copy-ConfigTemplates {
Write-Info "Setting up configuration files..."
# Create ~/.hermes directory structure
# Create the HERMES_HOME directory structure ($HermesHome, default %LOCALAPPDATA%\hermes)
New-Item -ItemType Directory -Force -Path "$HermesHome\cron" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\sessions" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\logs" | Out-Null
@@ -1743,13 +1743,13 @@ function Copy-ConfigTemplates {
$examplePath = "$InstallDir\.env.example"
if (Test-Path $examplePath) {
Copy-Item $examplePath $envPath
Write-Success "Created ~/.hermes/.env from template"
Write-Success "Created $envPath from template"
} else {
New-Item -ItemType File -Force -Path $envPath | Out-Null
Write-Success "Created ~/.hermes/.env"
Write-Success "Created $envPath"
}
} else {
Write-Info "~/.hermes/.env already exists, keeping it"
Write-Info "$envPath already exists, keeping it"
}
# Create config.yaml
@@ -1758,10 +1758,10 @@ function Copy-ConfigTemplates {
$examplePath = "$InstallDir\cli-config.yaml.example"
if (Test-Path $examplePath) {
Copy-Item $examplePath $configPath
Write-Success "Created ~/.hermes/config.yaml from template"
Write-Success "Created $configPath from template"
}
} else {
Write-Info "~/.hermes/config.yaml already exists, keeping it"
Write-Info "$configPath already exists, keeping it"
}
# Create SOUL.md if it doesn't exist (global persona file).
@@ -1794,25 +1794,25 @@ Delete the contents (or this file) to use the default personality.
"@
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom)
Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)"
Write-Success "Created $soulPath (edit to customize personality)"
}
Write-Success "Configuration directory ready: ~/.hermes/"
Write-Success "Configuration directory ready: $HermesHome"
# Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill)
Write-Info "Syncing bundled skills to ~/.hermes/skills/ ..."
# Seed bundled skills into $HermesHome\skills (manifest-based, one-time per skill)
Write-Info "Syncing bundled skills to $HermesHome\skills ..."
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
if (Test-Path $pythonExe) {
try {
& $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
Write-Success "Skills synced to ~/.hermes/skills/"
Write-Success "Skills synced to $HermesHome\skills"
} catch {
# Fallback: simple directory copy
$bundledSkills = "$InstallDir\skills"
$userSkills = "$HermesHome\skills"
if ((Test-Path $bundledSkills) -and -not (Get-ChildItem $userSkills -Exclude '.bundled_manifest' -ErrorAction SilentlyContinue)) {
Copy-Item -Path "$bundledSkills\*" -Destination $userSkills -Recurse -Force -ErrorAction SilentlyContinue
Write-Success "Skills copied to ~/.hermes/skills/"
Write-Success "Skills copied to $HermesHome\skills"
}
}
}
@@ -2248,6 +2248,24 @@ function Install-Desktop {
$code = $LASTEXITCODE
}
}
# Still failing and the user hasn't pinned their own mirror: GitHub's
# Electron release host is likely blocked/throttled (the repeating
# "retrying" log). Retry once via npmmirror.com — the de-facto Electron
# community mirror (Alibaba). @electron/get SHASUM-checks the download,
# but the SHASUMS come from the same mirror, so that guards against a
# corrupt/partial download, NOT a compromised mirror: an explicit trust
# trade-off we only make AFTER the canonical GitHub download has failed,
# and we never override a user-pinned ELECTRON_MIRROR.
if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) {
$prevMirror = $env:ELECTRON_MIRROR
$env:ELECTRON_MIRROR = "https://npmmirror.com/mirrors/electron/"
Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked."
Write-Warn "Retrying once via a public Electron mirror ($($env:ELECTRON_MIRROR)):"
Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
$code = $LASTEXITCODE
$env:ELECTRON_MIRROR = $prevMirror
}
$ErrorActionPreference = $prevEAP
if ($code -ne 0) {
$errText = Get-Content $buildLog -Raw -ErrorAction SilentlyContinue
+131 -9
View File
@@ -2281,6 +2281,93 @@ postinstall_mode() {
fi
}
# Clear the cached Electron download + any half-written unpacked output so the
# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in
# the per-user Electron download cache - most often a partial/resumed download
# that leaves concatenated junk - makes electron-builder's `unpack-electron`
# extract a tree MISSING the electron binary, so the `electron`->`Hermes` rename
# dies with ENOENT and every re-run repeats the broken extraction forever. This
# is the bash sibling of install.ps1's Clear-ElectronBuildCache and the Python
# _purge_electron_build_cache() used by `hermes desktop`; install.sh was the only
# build path lacking it. Echoes the removed paths (one per line); best-effort.
clear_electron_build_cache() {
local desktop_dir="$1"
local removed=""
# Per-user Electron download cache dirs, honoring the overrides @electron/get
# respects, then the platform defaults (macOS: ~/Library/Caches/electron,
# Linux: $XDG_CACHE_HOME/electron or ~/.cache/electron).
local cache_dirs=()
[ -n "${electron_config_cache:-}" ] && cache_dirs+=("$electron_config_cache")
[ -n "${ELECTRON_CACHE:-}" ] && cache_dirs+=("$ELECTRON_CACHE")
if [ "$OS" = "macos" ]; then
cache_dirs+=("$HOME/Library/Caches/electron")
else
[ -n "${XDG_CACHE_HOME:-}" ] && cache_dirs+=("$XDG_CACHE_HOME/electron")
cache_dirs+=("$HOME/.cache/electron")
fi
local dir zip
for dir in "${cache_dirs[@]}"; do
[ -d "$dir" ] || continue
# Recurse: the bad copy may be the top-level zip OR a copy inside an
# @electron/get hash subdir.
while IFS= read -r zip; do
[ -n "$zip" ] || continue
if rm -f "$zip" 2>/dev/null; then
removed="$removed$zip
"
fi
done <<EOF
$(find "$dir" -type f -name 'electron-*.zip' 2>/dev/null)
EOF
done
# A half-written unpacked dir from an interrupted prior pack poisons the
# rename even after the zip is fixed (mac-arm64-unpacked / linux-unpacked).
local release_dir="$desktop_dir/release"
if [ -d "$release_dir" ]; then
local unpacked
while IFS= read -r unpacked; do
[ -n "$unpacked" ] || continue
if rm -rf "$unpacked" 2>/dev/null; then
removed="$removed$unpacked
"
fi
done <<EOF
$(find "$release_dir" -maxdepth 1 -type d -name '*-unpacked' 2>/dev/null)
EOF
fi
printf '%s' "$removed"
}
# Run the desktop pack in $1 (the apps/desktop dir). `npm run pack` = tsc +
# vite build + electron-builder --dir, producing an unpacked app for the
# current OS. Signing auto-discovery is disabled so electron-builder falls back
# to an ad-hoc signature instead of grabbing an unrelated Developer ID from the
# keychain (a real signed/notarized .dmg needs Apple credentials — a separate
# release concern). Optional $2 = an ELECTRON_MIRROR base URL for this attempt,
# used as a fallback when the default GitHub release download is blocked.
_desktop_pack() {
local desktop_dir="$1"
local mirror="${2:-}"
if [ -n "$mirror" ]; then
( cd "$desktop_dir" && ELECTRON_MIRROR="$mirror" CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack )
else
( cd "$desktop_dir" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack )
fi
}
# Public Electron mirror used as a last-resort fallback when GitHub's release
# host is blocked/throttled (the repeating "retrying" symptom). npmmirror.com is
# the de-facto Electron community mirror (Alibaba). @electron/get SHASUM-checks
# the download, but the SHASUMS come from the same mirror — that guards against a
# corrupt/partial download, NOT a compromised mirror. Reaching for it is an
# explicit trust trade-off we only make AFTER the canonical GitHub download has
# failed, and we never override a user-pinned ELECTRON_MIRROR.
DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/"
# Build apps/desktop into a launchable native app. Mirrors install.ps1's
# Install-Desktop: a root-level npm install so the apps/* workspace resolves
# the desktop's own deps (Electron ~150MB), then `npm run pack`
@@ -2338,18 +2425,53 @@ install_desktop() {
}
log_success "Desktop workspace dependencies installed"
# 2. Build. `npm run pack` = tsc + vite build + electron-builder --dir,
# producing an unpacked app for the current OS. We disable signing
# auto-discovery so electron-builder falls back to an ad-hoc signature
# instead of grabbing an unrelated Developer ID from the keychain; a
# real signed/notarized .dmg needs Apple credentials and is a separate
# release concern.
# 2. Build, with up to three escalating attempts so a transient/blocked
# Electron download self-heals instead of failing the whole install:
# a) plain `npm run pack` (downloads Electron from GitHub),
# b) on failure, purge a corrupt cached zip + stale unpacked dir and
# retry (matches install.ps1 / `hermes desktop`),
# c) on still-failing, fall back to a public Electron mirror — this is
# the GitHub-blocked/throttled case (the repeating "retrying" log).
log_info "Building desktop app (this takes 1-3 minutes)..."
( cd "$desktop_dir" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack ) || {
local pack_ok=false
if _desktop_pack "$desktop_dir"; then
pack_ok=true
else
# (b) Corrupt cached Electron zip is the most common self-healable cause.
local purged
purged="$(clear_electron_build_cache "$desktop_dir")"
if [ -n "$purged" ]; then
log_warn "Desktop build failed; cleared cached Electron download and retrying once..."
if _desktop_pack "$desktop_dir"; then
pack_ok=true
fi
fi
fi
# (c) Still failing and the user hasn't pinned their own mirror: the GitHub
# release host is likely blocked/throttled. Retry once via a public
# Electron mirror (@electron/get still SHASUM-verifies the download).
if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then
log_warn "Desktop build still failing — the Electron download from GitHub looks blocked."
log_warn "Retrying once via a public Electron mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR)..."
log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then
pack_ok=true
fi
fi
if [ "$pack_ok" = false ]; then
log_error "Desktop app build failed"
log_info "Run manually: cd $desktop_dir && npm run pack"
# If the log shows repeated "retrying" lines fetching the Electron zip,
# the binary download is blocked/throttled (firewall, proxy, region) and
# the mirror fallback above also couldn't reach a host. Try a mirror you
# trust and rebuild (@electron/get honors ELECTRON_MIRROR):
log_info "If the log shows Electron download retries, rebuild via a reachable mirror:"
log_info " ELECTRON_MIRROR=<mirror-base-url> \\"
log_info " bash -c 'cd \"$desktop_dir\" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack'"
log_info "Otherwise build manually: cd $desktop_dir && npm run pack"
return 1
}
fi
local app=""
if [ "$OS" = "linux" ]; then
+9
View File
@@ -45,6 +45,9 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"philipadsouza@gmail.com": "PhilipAD",
"zhuhaoyu0909@icloud.com": "underthestars-zhy",
"raysun12142006@gmail.com": "yanxue06",
"alberto.regalado@ymail.com": "ARegalado1",
"alchemistchaos@protonmail.com": "AlchemistChaos", # co-author only
"gilad@smiti.ai": "giladbau",
@@ -56,12 +59,14 @@ AUTHOR_MAP = {
"thomas.paquette@gmail.com": "RyTsYdUp",
"techxacm@gmail.com": "ProgramCaiCai",
"266365592+bmoore210@users.noreply.github.com": "bmoore210",
"157839748+psionic73@users.noreply.github.com": "psionic73",
"manishbyatroy@gmail.com": "manishbyatroy",
"chilltulpa@gmail.com": "TheGardenGallery",
"al@randomsnowflake.me": "randomsnowflake",
"zakame@zakame.net": "zakame",
"152110621+jiangkoumo@users.noreply.github.com": "jiangkoumo",
"834740219@qq.com": "ViewWay",
"matt@vestigial.dev": "m4dni5",
"harjoth.khara@gmail.com": "harjothkhara",
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
@@ -100,6 +105,7 @@ AUTHOR_MAP = {
"metalclaudbot@gmail.com": "HashClawAI",
"tonybear55665566@gmail.com": "TonyPepeBear",
"kaspersniels@gmail.com": "nielskaspers",
"daxxpasquini@gmail.com": "bpasquini",
"kurobaryo@gmail.com": "kurobaryo",
"scubamount@users.noreply.github.com": "scubamount",
"251514042+youngstar-eth@users.noreply.github.com": "youngstar-eth",
@@ -971,6 +977,7 @@ AUTHOR_MAP = {
"draixagent@gmail.com": "draix",
"martin.alca@gmail.com": "draix",
"junminliu@gmail.com": "JimLiu",
"juraj@bednar.io": "jooray",
"jarvischer@gmail.com": "maxchernin",
"levantam.98.2324@gmail.com": "LVT382009",
"zhurongcheng@rcrai.com": "heykb",
@@ -1241,6 +1248,7 @@ AUTHOR_MAP = {
"charliekerfoot@gmail.com": "CharlieKerfoot", # PR #18951
# Debug share upload-time redaction (May 2026)
"dhuysamen@gmail.com": "GodsBoy", # PR #19318
"github@nadyahermes.anonaddy.com": "ruangraung", # PR #42308
"mrcoferland@gmail.com": "mrcoferland", # PR #19023
"chenlinfeng@ruije.com.cn": "noOne-list", # PR #19050
"briansu@Mac-mini.attlocal.net": "likejudy", # PR #19052
@@ -1469,6 +1477,7 @@ AUTHOR_MAP = {
# v0.15.0 additions
"glen@workmanfirearms.com": "sgtworkman",
"jorge.fuenmayort@gmail.com": "jfuenmayor",
"josh.dow@prepad.io": "joshuadow", # PR #43004 salvage (desktop WS session rebind)
"mordred@inaugust.com": "emonty",
"rodrigoeq@hotmail.com": "rodrigoeqnit",
"soliva.johnpaul@icloud.com": "jonpol01",
+67
View File
@@ -1213,6 +1213,73 @@ class TestBuildAnthropicKwargs:
assert _supports_fast_mode("claude-haiku-4-5") is False
assert _supports_fast_mode("") is False
def test_fable_class_models_route_as_adaptive_thinking(self):
"""Invariant: unknown/new Claude models default to the modern (4.7+)
contract adaptive thinking, xhigh-capable, sampling-params-forbidden
without any per-model code change. Named models (claude-fable-5) and
hypothetical future ones must all classify modern; only the explicit
legacy list stays on the manual path.
"""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
_get_anthropic_max_output,
)
# New / unknown Claude models → modern contract by default.
for m in (
"claude-fable-5",
"anthropic/claude-fable-5",
"claude-saga-2", # hypothetical future named model
"anthropic/claude-opus-9", # hypothetical future numbered model
):
assert _supports_adaptive_thinking(m) is True, m
assert _supports_xhigh_effort(m) is True, m
assert _forbids_sampling_params(m) is True, m
# 1M-context reasoning model → highest output ceiling.
assert _get_anthropic_max_output("anthropic/claude-fable-5") == 128_000
def test_legacy_claude_stays_on_manual_thinking(self):
"""Older Claude families keep the legacy manual-thinking contract."""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_forbids_sampling_params,
)
for m in (
"claude-3-5-sonnet",
"claude-3-7-sonnet",
"anthropic/claude-opus-4.5",
"anthropic/claude-sonnet-4.5",
"claude-haiku-4-5",
):
assert _supports_adaptive_thinking(m) is False, m
assert _forbids_sampling_params(m) is False, m
def test_claude_46_is_adaptive_but_not_xhigh_or_no_sampling(self):
"""4.6 is adaptive, but predates xhigh and still accepts sampling."""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
)
for m in ("claude-opus-4.6", "claude-sonnet-4-6"):
assert _supports_adaptive_thinking(m) is True, m
assert _supports_xhigh_effort(m) is False, m
assert _forbids_sampling_params(m) is False, m
def test_non_claude_anthropic_models_use_manual_path(self):
"""Non-Claude Anthropic-Messages models (minimax, qwen3, kimi) must not
be misclassified as adaptive by the default-to-modern rule."""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
)
for m in ("minimax-m2", "qwen3-max", "moonshotai/kimi-k2.5", "glm-4.6"):
assert _supports_adaptive_thinking(m) is False, m
assert _supports_xhigh_effort(m) is False, m
assert _forbids_sampling_params(m) is False, m
def test_fast_mode_omitted_for_unsupported_model(self):
"""fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s)."""
kwargs = build_anthropic_kwargs(
+53
View File
@@ -220,6 +220,59 @@ class TestDefaultContextLengths:
f"{model_id}: expected {expected_ctx}, got {actual}"
)
def test_openrouter_live_metadata_beats_hardcoded_catchall(self):
"""OpenRouter-routed slugs resolve via the live OR catalog before the
hardcoded family catch-all.
Regression for the claude-fable-5 under-report: a brand-new Anthropic
slug that is absent from models.dev but present in OpenRouter's live
catalog (with a 1M window) used to fall through to the generic
``"claude": 200000`` entry, because the step-6 OR fallback was gated on
``not effective_provider`` and ``effective_provider`` is "openrouter"
for any OpenRouter selection. The dedicated step-5 OR branch must read
the live value instead.
"""
from agent.model_metadata import get_model_context_length
from unittest.mock import patch as mock_patch
or_url = "https://openrouter.ai/api/v1"
live = {
"anthropic/claude-fable-5": {"context_length": 1_000_000},
"anthropic/claude-haiku-4.5": {"context_length": 200_000},
}
with mock_patch("agent.model_metadata.fetch_model_metadata", return_value=live), \
mock_patch("agent.model_metadata._query_ollama_api_show", return_value=None), \
mock_patch("agent.model_metadata.get_cached_context_length", return_value=None), \
mock_patch("agent.models_dev.lookup_models_dev_context", return_value=None):
# The bug: would have returned 200_000 via the "claude" catch-all.
assert get_model_context_length(
"anthropic/claude-fable-5", base_url=or_url, provider="openrouter"
) == 1_000_000
# A genuinely-200k model still resolves to its real OR value — the
# fix reads per-model context, it does not blanket-bump to 1M.
assert get_model_context_length(
"anthropic/claude-haiku-4.5", base_url=or_url, provider="openrouter"
) == 200_000
def test_openrouter_kimi_32k_underreport_still_guarded(self):
"""The live OR branch keeps the Kimi-family 32k underreport guard:
a bogus 32768 from OpenRouter for a Kimi slug must NOT win it falls
through to the hardcoded default instead.
"""
from agent.model_metadata import get_model_context_length
from unittest.mock import patch as mock_patch
or_url = "https://openrouter.ai/api/v1"
live = {"moonshotai/kimi-k2.6": {"context_length": 32768}}
with mock_patch("agent.model_metadata.fetch_model_metadata", return_value=live), \
mock_patch("agent.model_metadata._query_ollama_api_show", return_value=None), \
mock_patch("agent.model_metadata.get_cached_context_length", return_value=None), \
mock_patch("agent.models_dev.lookup_models_dev_context", return_value=None):
ctx = get_model_context_length(
"moonshotai/kimi-k2.6", base_url=or_url, provider="openrouter"
)
assert ctx != 32768, "Kimi 32k OR underreport must not be accepted"
# =========================================================================
# Codex OAuth context-window resolution (provider="openai-codex")
@@ -196,6 +196,40 @@ class TestRunTurn:
# turn_id propagated for downstream session-DB linkage
assert r.turn_id == "turn-fake-001"
def test_token_usage_notification_is_captured(self):
client = FakeClient()
client.queue_notification(
"thread/tokenUsage/updated",
threadId="thread-fake-001",
turnId="turn-fake-001",
tokenUsage={
"last": {
"totalTokens": 130,
"inputTokens": 80,
"cachedInputTokens": 20,
"outputTokens": 25,
"reasoningOutputTokens": 5,
},
"total": {
"totalTokens": 500,
"inputTokens": 300,
"cachedInputTokens": 75,
"outputTokens": 100,
"reasoningOutputTokens": 25,
},
"modelContextWindow": 200000,
},
)
client.queue_notification(
"turn/completed",
threadId="t",
turn={"id": "tu1", "status": "completed", "error": None},
)
r = make_session(client).run_turn("hi", turn_timeout=2.0)
assert r.token_usage_last["totalTokens"] == 130
assert r.token_usage_total["totalTokens"] == 500
assert r.model_context_window == 200000
def test_rich_content_turn_is_collapsed_to_text_payload(self):
client = FakeClient()
client.queue_notification(
+306 -193
View File
@@ -1,29 +1,31 @@
"""Regression tests for issue #30768 and #32383.
"""Regression tests for #30768, #32383, and #33961.
``_prompt_text_input_modal`` uses a queue-based modal that relies on
prompt_toolkit key bindings receiving keyboard events. On Windows the
prompt_toolkit input channel can deadlock when the modal is entered from
the ``process_loop`` daemon thread. The fix falls back to the simpler
``_prompt_text_input`` (stdin-based) prompt on Windows.
``_prompt_text_input_modal`` answers destructive-slash confirmations through a
queue-based modal driven by prompt_toolkit key bindings. When invoked from the
``process_loop`` daemon thread it sets the modal up on the app's event loop via
``call_soon_threadsafe``, so it is safe on every platform including native
Windows (#33961), where the earlier ``sys.platform == "win32"`` → raw ``input()``
fallback deadlocked the daemon thread against prompt_toolkit's stdin ownership.
These tests verify:
1. Windows detection triggers the stdin fallback
2. Non-Windows daemon threads still use the modal via the app loop
3. macOS/Linux main-thread path still uses the modal (no regression)
4. No-app path still uses the stdin fallback (existing behavior)
5. Empty choices returns None (existing behavior)
1. Daemon-thread confirm uses the modal via the app loop on Linux AND native
Windows (#33961) — never the raw stdin fallback, never a hang.
2. Main-thread confirm with a running app uses the modal.
3. The raw stdin fallback is kept ONLY for the safe cases: no running app, and
(on win32, off-thread) a scheduling failure degrades to a clean cancel.
4. Empty choices returns None.
"""
import queue
import sys
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
def _make_cli():
"""Minimal HermesCLI shell exposing prompt/modal helpers."""
"""Minimal HermesCLI shell exposing the prompt/modal helpers."""
import cli as cli_mod
obj = object.__new__(cli_mod.HermesCLI)
@@ -37,9 +39,6 @@ def _make_cli():
return obj
# ---------------------------------------------------------------------------
# Sample choices used across tests
# ---------------------------------------------------------------------------
_SAMPLE_CHOICES = [
("once", "Approve Once", "proceed this time only"),
("always", "Always Approve", "proceed and silence this prompt permanently"),
@@ -47,119 +46,106 @@ _SAMPLE_CHOICES = [
]
class TestModalWindowsFallback:
"""Windows dead-lock regression tests for _prompt_text_input_modal."""
def _answer_modal_when_open(cli, response, stop=None):
"""Push ``response`` onto the modal's response_queue once it opens.
def test_windows_falls_back_to_stdin(self):
"""On Windows, _prompt_text_input_modal should use _prompt_text_input."""
Gives up after ~2s, or early when ``stop`` is set (the modal will never open,
e.g. a scheduling failure) so degraded-path tests don't wait the full budget.
"""
for _ in range(100):
if stop is not None and stop.is_set():
return
state = cli._slash_confirm_state
if state and "response_queue" in state:
state["response_queue"].put(response)
return
time.sleep(0.02)
def _run_on_daemon(call, cli, *, platform, response, schedule=None):
"""Invoke ``call`` on a daemon thread — as the process_loop does — answering
the modal with ``response`` once it opens.
Returns ``{result, stdin_called, capture, restore}``. ``schedule`` overrides
the ``call_soon_threadsafe`` side effect (default: run the callback inline);
pass a raiser to simulate a scheduling failure. Fails if the worker hangs,
which is the deadlock canary for #33961.
"""
outcome = {"capture": [], "restore": [], "result": None, "stdin_called": False}
done = threading.Event()
def _worker():
try:
with patch.object(sys, "platform", platform), \
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=schedule or (lambda cb: cb())), \
patch.object(cli, "_prompt_text_input") as mock_stdin, \
patch.object(cli, "_invalidate"), \
patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: outcome["capture"].append(1)), \
patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: outcome["restore"].append(1)):
outcome["result"] = call()
outcome["stdin_called"] = mock_stdin.called
finally:
done.set()
worker = threading.Thread(target=_worker, daemon=True)
answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, response, done), daemon=True)
answerer.start()
worker.start()
worker.join(timeout=2.0)
answerer.join(timeout=2.0)
assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
return outcome
class TestModal:
"""Behaviour of _prompt_text_input_modal across platforms and threads."""
@pytest.mark.parametrize("platform", ["linux", "win32"])
def test_daemon_thread_uses_modal_via_app_loop(self, platform):
"""Off the process_loop daemon thread, the confirm uses the modal via
call_soon_threadsafe on every platform including native Windows, where
the old win32 early-return deadlocked on raw input() (#33961)."""
cli = _make_cli()
with patch.object(sys, "platform", "win32"), \
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin:
result = cli._prompt_text_input_modal(
title="⚠️ /new — destroys conversation state",
outcome = _run_on_daemon(
lambda: cli._prompt_text_input_modal(
title="⚠️ /reset",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
)
timeout=5,
),
cli,
platform=platform,
response="once",
)
assert outcome["stdin_called"] is False, "must use the modal, not raw input()"
assert outcome["result"] == "once"
assert outcome["capture"] == [1]
assert outcome["restore"] == [1]
assert cli._slash_confirm_state is None
# The stdin-based fallback was used, not the modal queue path.
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
assert result == "1"
def test_non_main_thread_uses_modal_via_app_loop(self):
"""Off the main thread on Linux, keep the modal path via app-loop setup."""
def test_main_thread_with_app_uses_modal(self):
"""On the main thread with a running app, the queue-based modal is used."""
cli = _make_cli()
result_holder = {}
setup_calls = []
teardown_calls = []
def _call_soon_threadsafe(callback):
callback()
def run_on_daemon():
with patch.object(sys, "platform", "linux"), \
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe), \
patch.object(cli, "_prompt_text_input") as mock_stdin, \
patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: setup_calls.append("capture")), \
patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: teardown_calls.append("restore")):
result_holder["result"] = cli._prompt_text_input_modal(
title="⚠️ /reset",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
)
result_holder["stdin_called"] = mock_stdin.called
def _submit_after_delay():
time.sleep(0.2)
state = cli._slash_confirm_state
if state and "response_queue" in state:
state["response_queue"].put("once")
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
t = threading.Thread(target=run_on_daemon, daemon=True)
submitter.start()
t.start()
t.join(timeout=2.0)
submitter.join(timeout=2.0)
assert not t.is_alive(), "daemon thread hung — modal deadlocked"
assert result_holder["stdin_called"] is False
assert result_holder["result"] == "once"
assert setup_calls == ["capture"]
assert teardown_calls == ["restore"]
def test_main_thread_non_windows_uses_modal(self):
"""On macOS/Linux main thread, the queue-based modal is still used."""
cli = _make_cli()
# We need to simulate the modal receiving a response. We'll patch
# the response_queue to immediately return a value.
with patch.object(sys, "platform", "darwin"), \
patch.object(cli, "_capture_modal_input_snapshot"), \
patch.object(cli, "_restore_modal_input_snapshot"), \
patch.object(cli, "_invalidate"):
# Start the modal in a way that it will receive a response
# immediately via the queue.
original_queue = queue.Queue
original_time = time.monotonic
patch.object(cli, "_invalidate"), \
patch.object(cli, "_prompt_text_input") as mock_stdin:
answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, "once"), daemon=True)
answerer.start()
result = cli._prompt_text_input_modal(
title="⚠️ /new",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
)
answerer.join(timeout=2.0)
def _fake_modal_flow(*args, **kwargs):
"""Simulate the modal flow: set state, put response, return."""
# We'll directly test that the modal path is entered by
# checking that _slash_confirm_state was set.
pass
# Since we can't easily mock the internal queue, let's test
# that the modal path is entered by checking that
# _prompt_text_input was NOT called.
with patch.object(cli, "_prompt_text_input") as mock_stdin:
# Set up a response that will be put into the queue
# after the modal starts waiting.
def _submit_after_delay():
time.sleep(0.2)
state = cli._slash_confirm_state
if state and "response_queue" in state:
state["response_queue"].put("once")
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
submitter.start()
result = cli._prompt_text_input_modal(
title="⚠️ /new",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
)
submitter.join(timeout=2.0)
# The stdin fallback should NOT have been called.
mock_stdin.assert_not_called()
# The result should be "once" from the simulated modal response.
assert result == "once"
mock_stdin.assert_not_called()
assert result == "once"
def test_no_app_falls_back_to_stdin(self):
"""Without a prompt_toolkit app, always use stdin fallback."""
"""Without a running app (oneshot / non-interactive), use the stdin prompt."""
cli = _make_cli()
cli._app = None
@@ -173,78 +159,102 @@ class TestModalWindowsFallback:
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
assert result == "3"
def test_empty_choices_returns_none(self):
"""Empty choices list should return None without prompting."""
cli = _make_cli()
with patch.object(cli, "_prompt_text_input") as mock_stdin:
result = cli._prompt_text_input_modal(
title="Test",
detail="Test",
choices=[],
)
mock_stdin.assert_not_called()
assert result is None
def test_windows_fallback_does_not_set_modal_state(self):
"""Verify Windows fallback doesn't leave _slash_confirm_state set."""
def test_windows_no_app_falls_back_to_stdin(self):
"""win32 without a running app keeps stdin — the only case where the raw
prompt is safe on Windows, since no app owns the console to deadlock."""
cli = _make_cli()
cli._app = None
with patch.object(sys, "platform", "win32"), \
patch.object(cli, "_prompt_text_input", return_value="1"):
cli._prompt_text_input_modal(
title="⚠️ /reset",
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin:
result = cli._prompt_text_input_modal(
title="⚠️ /new — destroys conversation state",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
)
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
assert result == "1"
def test_windows_scheduling_failure_clean_cancels(self):
"""win32 off the main thread: if marshaling onto the app loop fails, cancel
cleanly (None) rather than fall to raw input() (which deadlocks on native
Windows) or hang. Asserts the _stdin_fallback guard (#33961)."""
cli = _make_cli()
def _raise(_cb):
raise RuntimeError("loop closed")
outcome = _run_on_daemon(
lambda: cli._prompt_text_input_modal(
title="⚠️ /reset",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
),
cli,
platform="win32",
response="once",
schedule=_raise,
)
assert outcome["stdin_called"] is False, "win32 off-thread must NOT call raw input()"
assert outcome["result"] is None
assert cli._slash_confirm_state is None
def test_non_main_thread_modal_clears_state(self):
"""Verify daemon-thread modal teardown does not leave state behind."""
@pytest.mark.parametrize(
"platform, expect_stdin, expect_result",
[("win32", False, None), ("linux", True, "1")],
)
def test_daemon_thread_no_app_loop_uses_fallback(self, platform, expect_stdin, expect_result):
"""Off the daemon thread with no resolvable app loop (``self._app.loop``
is None / raises), the modal can never be scheduled, so the method short-
circuits at the app_loop-is-None site (cli.py ~7260) a distinct path
from a call_soon_threadsafe failure. win32 clean-cancels (None) instead of
deadlocking on raw input(); other platforms keep the stdin prompt."""
cli = _make_cli()
errors = []
cli._app.loop = None # forces app_loop is None, off the main thread
def _call_soon_threadsafe(callback):
callback()
outcome = {"result": None, "stdin_called": False}
done = threading.Event()
def run_on_daemon():
def _worker():
try:
with patch.object(sys, "platform", "linux"), \
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe):
def _submit_after_delay():
time.sleep(0.2)
state = cli._slash_confirm_state
if state and "response_queue" in state:
state["response_queue"].put("cancel")
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
submitter.start()
cli._prompt_text_input_modal(
title="⚠️ /new",
with patch.object(sys, "platform", platform), \
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin, \
patch.object(cli, "_invalidate"):
outcome["result"] = cli._prompt_text_input_modal(
title="⚠️ /reset",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
)
submitter.join(timeout=2.0)
if cli._slash_confirm_state is not None:
errors.append("_slash_confirm_state should be None")
except Exception as exc:
errors.append(str(exc))
outcome["stdin_called"] = mock_stdin.called
finally:
done.set()
t = threading.Thread(target=run_on_daemon, daemon=True)
t.start()
t.join(timeout=2.0)
assert not errors, f"unexpected errors: {errors}"
worker = threading.Thread(target=_worker, daemon=True)
worker.start()
worker.join(timeout=2.0)
assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
assert outcome["stdin_called"] is expect_stdin
assert outcome["result"] == expect_result
assert cli._slash_confirm_state is None
def test_empty_choices_returns_none(self):
"""Empty choices returns None without prompting."""
cli = _make_cli()
with patch.object(cli, "_prompt_text_input") as mock_stdin:
result = cli._prompt_text_input_modal(title="Test", detail="Test", choices=[])
mock_stdin.assert_not_called()
assert result is None
class TestConfirmDestructiveSlashWindows:
"""Integration-level tests for _confirm_destructive_slash on Windows."""
"""End-to-end _confirm_destructive_slash on the native-Windows daemon thread."""
def test_confirm_destructive_slash_bypasses_modal_on_windows(self):
"""_confirm_destructive_slash should work on Windows via stdin fallback."""
def _make_interactive_cli(self):
cli = _make_cli()
cli.model = "test-model"
cli._agent_running = False
@@ -255,37 +265,140 @@ class TestConfirmDestructiveSlashWindows:
cli._pending_tool_info = {}
cli._tool_start_time = 0.0
cli._last_scrollback_tool = ""
return cli
with patch.object(sys, "platform", "win32"), \
patch.object(cli, "_prompt_text_input", return_value="1"), \
patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
result = cli._confirm_destructive_slash(
"new",
"This starts a fresh session.\nThe current conversation history will be discarded.",
@pytest.mark.parametrize(
"response, expected",
[("once", "once"), ("cancel", None)],
)
def test_confirm_destructive_slash_uses_modal_on_windows(self, response, expected):
"""On native Windows, the bare /new confirm drives the modal (not stdin)
and returns the chosen outcome the bug #33961 froze this path."""
cli = self._make_interactive_cli()
with patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
outcome = _run_on_daemon(
lambda: cli._confirm_destructive_slash(
"new",
"This starts a fresh session.\nThe current conversation history will be discarded.",
),
cli,
platform="win32",
response=response,
)
assert result == "once"
assert outcome["stdin_called"] is False
assert outcome["result"] == expected
def test_confirm_destructive_slash_cancelled_on_windows(self):
"""Cancellation via stdin fallback works on Windows."""
class TestNativeWindowsNoRawInputDeadlock:
"""Anti-regression guard exercising the REAL ``_prompt_text_input``.
Every other test here mocks ``_prompt_text_input`` away, so they only
assert *routing* (modal vs. stdin) they cannot observe the actual hang
that #33961 was. The historical regression was precisely that
``_prompt_text_input_modal`` delegated to the *real* ``_prompt_text_input``
on native Windows, which on a non-main thread runs a bare ``input()`` that
blocks forever against prompt_toolkit's stdin ownership.
These tests let the real ``_prompt_text_input`` run with a blocking
``input()`` and assert the worker thread never hangs. They fail on the
pre-#33961 code (win32 → ``_prompt_text_input`` → off-main ``input()``)
and pass once the modal path / clean-cancel fallback is in place.
"""
def test_win32_daemon_thread_never_blocks_on_real_input(self):
"""A blocking input() must NOT hang the daemon thread on win32.
Drives the genuine helper chain (no mock of ``_prompt_text_input``)
with ``builtins.input`` patched to block forever. The confirm must
resolve via the app-loop modal (answered on a background thread, as
the real key bindings would) and never sit in ``input()``. On the
pre-#33961 code the win32 early-return routed to the real
``_prompt_text_input`` off-main ``input()`` permanent hang.
"""
cli = _make_cli()
cli.model = "test-model"
cli._agent_running = False
cli._spinner_text = ""
cli._should_exit = False
cli._command_running = False
cli.session_id = "test-session"
cli._pending_tool_info = {}
cli._tool_start_time = 0.0
cli._last_scrollback_tool = ""
cli._app.loop.call_soon_threadsafe = lambda cb: cb()
with patch.object(sys, "platform", "win32"), \
patch.object(cli, "_prompt_text_input", return_value="3"), \
patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
result = cli._confirm_destructive_slash(
"reset",
"This starts a fresh session.\nThe current conversation history will be discarded.",
)
def _blocking_input(prompt=""): # stands in for "no line ever arrives"
time.sleep(30)
return "1"
# Choice "3" normalizes to "cancel", which returns None.
assert result is None
outcome = {}
done = threading.Event()
def _worker():
try:
with patch.object(sys, "platform", "win32"), \
patch("builtins.input", side_effect=_blocking_input), \
patch.object(cli, "_capture_modal_input_snapshot"), \
patch.object(cli, "_restore_modal_input_snapshot"), \
patch.object(cli, "_invalidate"):
outcome["result"] = cli._prompt_text_input_modal(
title="/new",
detail="destroys conversation state",
choices=_SAMPLE_CHOICES,
timeout=3,
)
finally:
done.set()
worker = threading.Thread(target=_worker, daemon=True)
answerer = threading.Thread(
target=_answer_modal_when_open, args=(cli, "cancel", done), daemon=True
)
answerer.start()
worker.start()
worker.join(timeout=5.0)
answerer.join(timeout=5.0)
assert not worker.is_alive(), (
"daemon thread hung in real input() — native-Windows confirm "
"deadlock regressed (#33961)"
)
# cancel → None; the point is it RETURNED rather than blocking forever.
assert outcome.get("result") in (None, "cancel")
def test_win32_scheduling_failure_cleanly_cancels_no_input(self):
"""If the modal can't be marshaled onto the app loop on native Windows
(scheduling failure) the off-main-thread path must cancel cleanly
NOT fall through to a blocking raw ``input()``.
This is the degraded branch the pre-#33961 code handled with
``return self._prompt_text_input(...)`` (which deadlocks); the fix
returns ``None`` instead.
"""
cli = _make_cli()
def _raise(cb): # call_soon_threadsafe scheduling failure
raise RuntimeError("event loop closed")
cli._app.loop.call_soon_threadsafe = _raise
input_called = {"n": 0}
def _tracking_input(prompt=""):
input_called["n"] += 1
time.sleep(30)
return "1"
outcome = {}
def _worker():
with patch.object(sys, "platform", "win32"), \
patch("builtins.input", side_effect=_tracking_input), \
patch.object(cli, "_invalidate"):
outcome["result"] = cli._prompt_text_input_modal(
title="/new",
detail="destroys conversation state",
choices=_SAMPLE_CHOICES,
timeout=3,
)
worker = threading.Thread(target=_worker, daemon=True)
worker.start()
worker.join(timeout=5.0)
assert not worker.is_alive(), (
"daemon thread hung — win32 scheduling-failure fallback used raw "
"input() instead of cleanly cancelling (#33961)"
)
assert input_called["n"] == 0, "win32 off-thread fallback must not call input()"
assert outcome.get("result") is None
+100 -1
View File
@@ -159,7 +159,106 @@ caption
tags, voice = _collect_auto_append_media_tags(messages, history_offset=0)
assert tags == ["MEDIA:/tmp/voice.ogg"]
assert voice is True
def test_gateway_auto_append_image_generate_json_path(self):
"""image_generate returns a local path in JSON (no MEDIA: tag); it is
auto-appended so delivery doesn't depend on the model restating it."""
from gateway.run import _collect_auto_append_media_tags
messages = [
{"role": "user", "content": "Make me a cat"},
{
"role": "assistant",
"tool_calls": [
{"id": "call_img", "function": {"name": "image_generate"}}
],
},
{
"role": "tool",
"tool_call_id": "call_img",
"content": '{"success": true, "image": "/tmp/gen/cat.png", "agent_visible_image": "/tmp/gen/cat.png"}',
},
{"role": "assistant", "content": "Here's your cat."},
]
tags, voice = _collect_auto_append_media_tags(messages, history_offset=0)
assert tags == ["MEDIA:/tmp/gen/cat.png"]
assert voice is False
def test_gateway_auto_append_image_generate_prefers_host_path(self):
"""When host and sandbox paths differ, the host-deliverable path wins."""
from gateway.run import _collect_auto_append_media_tags
messages = [
{"role": "user", "content": "Make me a dog"},
{
"role": "assistant",
"tool_calls": [
{"id": "call_img", "function": {"name": "image_generate"}}
],
},
{
"role": "tool",
"tool_call_id": "call_img",
"content": '{"success": true, "host_image": "/host/dog.jpg", "image": "/host/dog.jpg", "agent_visible_image": "/sandbox/dog.jpg"}',
},
]
tags, _ = _collect_auto_append_media_tags(messages, history_offset=0)
assert tags == ["MEDIA:/host/dog.jpg"]
def test_gateway_auto_append_image_generate_failure_and_url_ignored(self):
"""Failed generations and remote URLs are not auto-delivered."""
from gateway.run import _collect_auto_append_media_tags
def _img_msgs(content):
return [
{
"role": "assistant",
"tool_calls": [
{"id": "c", "function": {"name": "image_generate"}}
],
},
{"role": "tool", "tool_call_id": "c", "content": content},
]
# Failed generation
tags, _ = _collect_auto_append_media_tags(
_img_msgs('{"success": false, "image": null, "error": "boom"}'),
history_offset=0,
)
assert tags == []
# Remote URL is not a local file path
tags, _ = _collect_auto_append_media_tags(
_img_msgs('{"success": true, "image": "https://fal.media/x/cat.png"}'),
history_offset=0,
)
assert tags == []
def test_gateway_auto_append_image_generate_dedupes_history(self):
"""A generated image path already in history is not re-sent."""
from gateway.run import _collect_auto_append_media_tags
messages = [
{
"role": "assistant",
"tool_calls": [
{"id": "c", "function": {"name": "image_generate"}}
],
},
{
"role": "tool",
"tool_call_id": "c",
"content": '{"success": true, "image": "/tmp/gen/cat.png"}',
},
]
tags, _ = _collect_auto_append_media_tags(
messages, history_offset=0, history_media_paths={"/tmp/gen/cat.png"}
)
assert tags == []
def test_media_tags_not_extracted_from_history(self):
"""MEDIA tags from previous turns should NOT be extracted again."""
# Simulate conversation history with a TTS call from a previous turn
+175
View File
@@ -1264,3 +1264,178 @@ async def test_verbose_mode_respects_explicit_tool_preview_length(monkeypatch, t
assert VerboseAgent.LONG_CODE not in all_content
# But should still contain the truncated portion with "..."
assert "..." in all_content
class CodeBlockProgressAdapter(ProgressCaptureAdapter):
"""A markdown-capable progress adapter (declares supports_code_blocks)."""
supports_code_blocks = True
class TerminalCommandAgent:
"""Emits a terminal tool.started with a real, multi-line command arg."""
CMD = (
"set -euo pipefail\n"
"printf 'node: '; node --version\n"
"npm install -g hyperframes@latest"
)
def __init__(self, **kwargs):
self.tool_progress_callback = kwargs.get("tool_progress_callback")
self.tools = []
def run_conversation(self, message, conversation_history=None, task_id=None):
self.tool_progress_callback(
"tool.started", "terminal", self.CMD, {"command": self.CMD}
)
# Let the async progress task drain the queue and send before returning.
time.sleep(0.35)
return {"final_response": "done", "messages": [], "api_calls": 1}
@pytest.mark.asyncio
async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path):
"""Terminal progress on a markdown-capable (supports_code_blocks) gateway
renders a bare fenced code block no language tag (Slack mrkdwn would print
'bash' as a literal first code line). In non-verbose ("all"/"new") mode the
command is collapsed to a single line capped at tool_preview_length so a long
or multi-line command doesn't render as a huge block (#42634)."""
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
fake_dotenv = types.ModuleType("dotenv")
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = TerminalCommandAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
import tools.terminal_tool # noqa: F401 - register terminal emoji
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
runner = _make_runner(adapter)
gateway_run = importlib.import_module("gateway.run")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="12345",
chat_type="dm",
thread_id=None,
)
result = await runner._run_agent(
message="hello",
context_prompt="",
history=[],
source=source,
session_id="sess-terminal-code-block",
session_key="agent:main:telegram:dm:12345",
)
assert result["final_response"] == "done"
all_content = " ".join(call["content"] for call in adapter.sent)
all_content += " ".join(call["content"] for call in adapter.edits)
# Bare fenced block, no language tag (no '```bash').
assert "```" in all_content
assert "```bash" not in all_content
# Non-verbose collapses to the first line + truncation marker — the later
# command lines must NOT appear (this was the "huge block" regression).
assert "set -euo pipefail" in all_content
assert "npm install -g hyperframes@latest" not in all_content
assert "node --version" not in all_content
# No truncated quoted preview for the terminal command.
assert 'terminal: "' not in all_content
@pytest.mark.asyncio
async def test_terminal_progress_verbose_shows_full_command(monkeypatch, tmp_path):
"""Verbose mode on a markdown-capable gateway renders the FULL multi-line
command in a bare fenced block (no truncation, no 'bash' tag). This is the
parity guarantee for #42634: verbose keeps full detail, non-verbose caps."""
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "verbose")
fake_dotenv = types.ModuleType("dotenv")
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = TerminalCommandAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
import tools.terminal_tool # noqa: F401 - register terminal emoji
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
runner = _make_runner(adapter)
gateway_run = importlib.import_module("gateway.run")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="12345",
chat_type="dm",
thread_id=None,
)
result = await runner._run_agent(
message="hello",
context_prompt="",
history=[],
source=source,
session_id="sess-terminal-code-block-verbose",
session_key="agent:main:telegram:dm:12345",
)
assert result["final_response"] == "done"
all_content = " ".join(call["content"] for call in adapter.sent)
all_content += " ".join(call["content"] for call in adapter.edits)
assert "```" in all_content
assert "```bash" not in all_content
# Full command body present — verbose is uncapped.
assert "npm install -g hyperframes@latest" in all_content
assert "node --version" in all_content
@pytest.mark.asyncio
async def test_terminal_progress_no_bash_block_in_verbose_mode(monkeypatch, tmp_path):
"""#41215 also rendered the bash block in verbose mode. The revert removed it
from both branches, so verbose progress must not emit a fenced ```bash block
either (verbose still shows args by opt-in, just not as a code block)."""
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "verbose")
fake_dotenv = types.ModuleType("dotenv")
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = TerminalCommandAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
import tools.terminal_tool # noqa: F401 - register terminal emoji
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
runner = _make_runner(adapter)
gateway_run = importlib.import_module("gateway.run")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="12345",
chat_type="dm",
thread_id=None,
)
result = await runner._run_agent(
message="hello",
context_prompt="",
history=[],
source=source,
session_id="sess-terminal-verbose-no-bash",
session_key="agent:main:telegram:dm:12345",
)
assert result["final_response"] == "done"
all_content = " ".join(call["content"] for call in adapter.sent)
all_content += " ".join(call["content"] for call in adapter.edits)
assert "```bash" not in all_content
+48
View File
@@ -75,6 +75,54 @@ async def test_capabilities_advertises_session_control_surface(adapter):
}
@pytest.mark.asyncio
async def test_run_agent_binds_api_session_context_for_tool_env(adapter, monkeypatch):
"""API-server request sessions should reach tools and terminal subprocess env."""
monkeypatch.setenv("HERMES_SESSION_ID", "stale-session")
observed = {}
class FakeAgent:
session_prompt_tokens = 0
session_completion_tokens = 0
session_total_tokens = 0
def __init__(self, session_id: str):
self.session_id = session_id
def run_conversation(self, user_message, conversation_history, task_id):
from gateway.session_context import get_session_env
from tools.environments.local import _make_run_env
observed["task_id"] = task_id
observed["context_session_id"] = get_session_env("HERMES_SESSION_ID")
observed["context_platform"] = get_session_env("HERMES_SESSION_PLATFORM")
observed["context_session_key"] = get_session_env("HERMES_SESSION_KEY")
observed["child_session_id"] = _make_run_env({}).get("HERMES_SESSION_ID")
return {"final_response": "ok"}
def fake_create_agent(**kwargs):
return FakeAgent(kwargs["session_id"])
monkeypatch.setattr(adapter, "_create_agent", fake_create_agent)
result, usage = await adapter._run_agent(
user_message="hello",
conversation_history=[],
session_id="request-session",
gateway_session_key="request-key",
)
assert result["session_id"] == "request-session"
assert usage == {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
assert observed == {
"task_id": "request-session",
"context_session_id": "request-session",
"context_platform": "api_server",
"context_session_key": "request-key",
"child_session_id": "request-session",
}
@pytest.mark.asyncio
async def test_session_crud_and_message_history(adapter, session_db):
app = _create_session_app(adapter)
+11
View File
@@ -190,6 +190,17 @@ def test_session_key_falls_back_to_os_environ(monkeypatch):
assert get_session_env("HERMES_SESSION_KEY") == ""
def test_session_id_set_via_contextvars(monkeypatch):
"""set_session_vars should set HERMES_SESSION_ID via contextvars."""
monkeypatch.setenv("HERMES_SESSION_ID", "stale-env-session")
tokens = set_session_vars(session_id="ctx-session-456")
assert get_session_env("HERMES_SESSION_ID") == "ctx-session-456"
clear_session_vars(tokens)
assert get_session_env("HERMES_SESSION_ID") == ""
def test_set_session_env_includes_session_key():
"""_set_session_env should propagate session_key from SessionContext."""
runner = object.__new__(GatewayRunner)
+20 -1
View File
@@ -205,6 +205,13 @@ def test_corr_id_pending_set_self_trims():
@pytest.mark.asyncio
async def test_send_dm():
"""DMs use the bare ``@<id> text`` chat-command form.
The bracketed form ``@[<id>] text`` is what the daemon's man page
documents, but in practice both addressing styles route through
the same chat-command parser; bare ``@<id>`` matches what every
Hermes deployment has been using in production for months.
"""
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
adapter = SimplexAdapter(cfg)
@@ -222,6 +229,14 @@ async def test_send_dm():
@pytest.mark.asyncio
async def test_send_group():
"""Groups use the structured ``/_send #<id> json [...]`` form.
The bracket chat-command form ``#[<id>] text`` *looks* like an exact
ID match in the daemon docs but is parsed as a display-name lookup
so messages to groups whose display name isn't literally the ID
silently drop. The structured ``/_send`` form addresses by numeric
ID and survives newlines/quoting through ``json.dumps``.
"""
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
adapter = SimplexAdapter(cfg)
@@ -231,7 +246,11 @@ async def test_send_group():
result = await adapter.send("group:grp-99", "Hello, group!")
payload = json.loads(mock_ws.send.call_args[0][0])
assert payload["cmd"] == "#[grp-99] Hello, group!"
assert payload["cmd"].startswith("/_send #grp-99 json ")
msg_content = json.loads(payload["cmd"].split(" json ", 1)[1])[0][
"msgContent"
]
assert msg_content == {"type": "text", "text": "Hello, group!"}
assert result.success is True
+1 -1
View File
@@ -835,7 +835,7 @@ class TestEditMessageStreamingSafety:
assert second_call == {
"chat_id": 123,
"message_id": 456,
"text": "final **bold**",
"text": "final bold",
}
@pytest.mark.asyncio
+11 -1
View File
@@ -41,6 +41,16 @@ def _suppress_concurrent_hermes_gate(request, monkeypatch):
from hermes_cli import main as _cli_main
except Exception:
return
# raising=False: under pytest's per-test spawn isolation, a concurrent
# xdist worker importing a module that transitively touches hermes_cli.main
# can briefly expose a partially-initialized module object here — one where
# _detect_concurrent_hermes_instances isn't defined yet. A bare setattr
# would raise AttributeError and error the (unrelated) test. The attribute
# always exists once main.py finishes importing, so a no-op when it's
# transiently absent is the correct, race-free default.
monkeypatch.setattr(
_cli_main, "_detect_concurrent_hermes_instances", lambda *_a, **_k: []
_cli_main,
"_detect_concurrent_hermes_instances",
lambda *_a, **_k: [],
raising=False,
)
@@ -0,0 +1,58 @@
"""Regression tests for the Anthropic model-picker dropping curated aliases.
Bug newly-routed curated aliases vanished on a native Anthropic setup
``provider_model_ids("anthropic")`` returned the live ``/v1/models`` dump
verbatim whenever Anthropic credentials were configured. Anthropic's API
lags behind freshly-routed aliases (e.g. ``claude-fable-5``, which is
reachable on Anthropic before the models endpoint enumerates it), so the
curated entry disappeared from the picker. The picker now merges the
curated ``_PROVIDER_MODELS["anthropic"]`` list with the live catalog
curated entries first, live-only models appended, deduped mirroring the
OpenAI curated-merge philosophy.
"""
from unittest.mock import patch
from hermes_cli import models as M
def test_anthropic_curated_alias_survives_when_live_omits_it():
"""A curated alias missing from /v1/models still surfaces (first)."""
curated = M._PROVIDER_MODELS["anthropic"]
assert "claude-fable-5" in curated # sanity: the alias is curated
# Live catalog the API would actually return — no fable-5.
live = ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"]
with patch.object(M, "_fetch_anthropic_models", return_value=live):
result = M.provider_model_ids("anthropic")
assert "claude-fable-5" in result
# Curated order is preserved at the front.
assert result[:len(curated)] == list(curated)
def test_anthropic_merge_dedupes_overlap_and_appends_live_only():
"""Models in both lists appear once; live-only models are appended."""
live = [
"claude-opus-4-8", # overlaps curated
"claude-sonnet-4-6", # overlaps curated
"claude-future-9-99", # live-only, not curated
]
with patch.object(M, "_fetch_anthropic_models", return_value=live):
result = M.provider_model_ids("anthropic")
# No duplicates introduced by the merge.
assert result.count("claude-opus-4-8") == 1
# Live-only entry is preserved (discovery still works for unknown models).
assert "claude-future-9-99" in result
# Curated entries lead, live-only trails.
assert result.index("claude-fable-5") < result.index("claude-future-9-99")
def test_anthropic_falls_back_to_curated_when_live_unavailable():
"""No creds / live failure -> curated list verbatim (alias still present)."""
with patch.object(M, "_fetch_anthropic_models", return_value=None):
result = M.provider_model_ids("anthropic")
assert result == list(M._PROVIDER_MODELS["anthropic"])
assert "claude-fable-5" in result
+400 -3
View File
@@ -27,7 +27,7 @@ import hermes_cli.dashboard_register as dr
def _ns(**kw):
defaults = dict(name=None, redirect_uri=None)
defaults = dict(name=None, redirect_uri=None, portal_url=None)
defaults.update(kw)
return argparse.Namespace(**defaults)
@@ -76,7 +76,7 @@ def _fake_http_ok(payload: dict):
class TestHappyPath:
def _run(self, *, args, account_token="tok_abc", portal="https://portal.nousresearch.com",
response=None, captured=None):
response=None, captured=None, existing_client_id=None):
response = response or {
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
@@ -98,12 +98,21 @@ class TestHappyPath:
def fake_save(key, value):
saved[key] = value
# get_env_value is consulted twice: once for the stored client_id
# (idempotency key) and once for HERMES_DASHBOARD_PORTAL_URL. Route by
# key so a test can seed a prior client_id while keeping the portal
# unset (the default-portal-not-persisted path).
def fake_get_env(key):
if key == "HERMES_DASHBOARD_OAUTH_CLIENT_ID":
return existing_client_id
return None
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value=account_token
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
dr, "_resolve_portal_base_url", return_value=portal
), patch(
"hermes_cli.config.get_env_value", return_value=None
"hermes_cli.config.get_env_value", side_effect=fake_get_env
), patch(
"hermes_cli.config.save_env_value", side_effect=fake_save
), patch.object(
@@ -157,6 +166,394 @@ class TestHappyPath:
)
class TestIdempotentRerun(TestHappyPath):
"""Re-running with a stored client_id updates instead of creating.
Inherits ``_run`` from TestHappyPath; the only new lever is
``existing_client_id`` (the HERMES_DASHBOARD_OAUTH_CLIENT_ID a prior run
persisted), which the CLI re-sends so the portal updates that row.
"""
def test_stored_client_id_is_sent_as_idempotency_key(self, capsys):
captured: dict = {}
# Portal echoes back the SAME id -> it updated in place.
self._run(
args=_ns(),
existing_client_id="agent:selfhost-1",
response={
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": None,
"created_at": "2026-06-04T12:00:00.000Z",
},
captured=captured,
)
assert captured["body"]["client_id"] == "agent:selfhost-1"
def test_rerun_without_name_omits_name_to_preserve_stored(self, capsys):
# No --name on a re-run: don't churn the portal-stored name. The CLI
# leaves `name` out of the body so the portal keeps what it has.
captured: dict = {}
self._run(
args=_ns(),
existing_client_id="agent:selfhost-1",
captured=captured,
)
assert "name" not in captured["body"]
assert captured["body"]["client_id"] == "agent:selfhost-1"
def test_rerun_with_explicit_name_still_sends_name(self, capsys):
captured: dict = {}
self._run(
args=_ns(name="renamed_box"),
existing_client_id="agent:selfhost-1",
captured=captured,
)
assert captured["body"]["name"] == "renamed_box"
assert captured["body"]["client_id"] == "agent:selfhost-1"
def test_rerun_prints_updated_when_same_id_returned(self, capsys):
self._run(
args=_ns(),
existing_client_id="agent:selfhost-1",
response={
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": None,
"created_at": "2026-06-04T12:00:00.000Z",
},
)
out = capsys.readouterr().out
assert "Updated dashboard" in out
assert "Registered dashboard" not in out
def test_rerun_persists_returned_client_id(self, capsys):
saved = self._run(
args=_ns(),
existing_client_id="agent:selfhost-1",
)
# Same id round-trips into .env -> idempotent, one record.
assert saved["HERMES_DASHBOARD_OAUTH_CLIENT_ID"] == "agent:selfhost-1"
def test_stale_id_falls_through_to_create_prints_registered(self, capsys):
# Stored id no longer resolves server-side -> portal created a fresh
# row and returns a DIFFERENT id. The CLI treats that as a create and
# persists the new id (re-run stays safe, never worse than first run).
captured: dict = {}
saved = self._run(
args=_ns(name="seed_name"),
existing_client_id="agent:selfhost-stale",
response={
"client_id": "agent:selfhost-new",
"id": "selfhost-new",
"name": "seed_name",
"kind": "SELF_HOSTED",
"custom_redirect_uri": None,
"created_at": "2026-06-04T12:00:00.000Z",
},
captured=captured,
)
# The stale id is still SENT (portal decides create-vs-update).
assert captured["body"]["client_id"] == "agent:selfhost-stale"
# Returned id differs from what we sent -> message is "Registered".
out = capsys.readouterr().out
assert "Registered dashboard" in out
assert "Updated dashboard" not in out
assert saved["HERMES_DASHBOARD_OAUTH_CLIENT_ID"] == "agent:selfhost-new"
def test_blank_stored_client_id_treated_as_first_run(self, capsys):
# A blank/whitespace stored value is not a usable key: treat as a
# first registration (auto-generate a name, don't send client_id).
captured: dict = {}
self._run(
args=_ns(),
existing_client_id=" ",
captured=captured,
)
assert "client_id" not in captured["body"]
assert captured["body"].get("name") # auto-generated
class TestCustomPortalPersistence:
"""`--portal-url` / HERMES_DASHBOARD_PORTAL_URL is persisted to .env.
An *explicitly supplied* custom portal URL is an intentional choice the
user wants to survive across sessions, so it's always written (updating an
existing entry in place rather than appending a duplicate). When no custom
URL is supplied, the older conservative behaviour is preserved: an inferred
portal is only written when absent and non-default, and an existing entry
is never altered unexpectedly.
"""
def _run(self, *, args, portal, existing_portal):
"""Drive cmd_dashboard_register, capturing save_env_value calls.
`existing_portal` is what get_env_value returns for
HERMES_DASHBOARD_PORTAL_URL (None = not present in .env).
"""
response = {
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": None,
"created_at": "2026-06-04T12:00:00.000Z",
}
saved: dict = {}
def fake_save(key, value):
saved[key] = value
def fake_get_env_value(key, *a, **kw):
if key == "HERMES_DASHBOARD_PORTAL_URL":
return existing_portal
return None
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
), patch("hermes_cli.config.is_managed", return_value=False), patch.dict(
dr.os.environ, {}, clear=False
), patch.object(
dr, "_resolve_portal_base_url", return_value=portal
), patch(
"hermes_cli.config.get_env_value", side_effect=fake_get_env_value
), patch(
"hermes_cli.config.save_env_value", side_effect=fake_save
), patch.object(
dr.urllib.request, "urlopen", return_value=_fake_http_ok(response)
):
# The ambient process env may carry HERMES_DASHBOARD_PORTAL_URL
# (e.g. staging dev shells); drop it so `custom_portal_supplied`
# is driven solely by the args.portal_url under test.
dr.os.environ.pop("HERMES_DASHBOARD_PORTAL_URL", None)
dr.cmd_dashboard_register(args)
return saved
def test_explicit_custom_url_persisted_when_var_absent(self, capsys):
saved = self._run(
args=_ns(portal_url="https://preview.example.com"),
portal="https://preview.example.com",
existing_portal=None,
)
assert saved["HERMES_DASHBOARD_PORTAL_URL"] == "https://preview.example.com"
def test_explicit_custom_url_updates_existing_in_place(self, capsys):
# An entry already exists with a different value; the explicit custom
# URL overwrites it (save_env_value updates the matching key in place).
saved = self._run(
args=_ns(portal_url="https://new-preview.example.com"),
portal="https://new-preview.example.com",
existing_portal="https://old-preview.example.com",
)
assert (
saved["HERMES_DASHBOARD_PORTAL_URL"] == "https://new-preview.example.com"
)
def test_explicit_custom_url_persisted_even_when_equals_default(self, capsys):
# User explicitly asked for the production portal — honour the explicit
# request and persist it (the no-flag path would skip the default).
saved = self._run(
args=_ns(portal_url="https://portal.nousresearch.com"),
portal="https://portal.nousresearch.com",
existing_portal=None,
)
assert (
saved["HERMES_DASHBOARD_PORTAL_URL"] == "https://portal.nousresearch.com"
)
def test_explicit_custom_url_equal_to_existing_is_noop(self, capsys):
# Already persisted with the same value → no redundant write.
saved = self._run(
args=_ns(portal_url="https://preview.example.com"),
portal="https://preview.example.com",
existing_portal="https://preview.example.com",
)
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
def test_no_flag_default_portal_not_written(self, capsys):
# No custom URL supplied, resolves to default → not written.
saved = self._run(
args=_ns(),
portal="https://portal.nousresearch.com",
existing_portal=None,
)
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
def test_no_flag_does_not_overwrite_existing_entry(self, capsys):
# No custom URL supplied and the var already exists → left untouched,
# even if the inferred portal differs (acceptance criterion 4).
saved = self._run(
args=_ns(),
portal="https://inferred-from-login.example.com",
existing_portal="https://already-set.example.com",
)
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
class TestPublicUrlPersistence:
"""`--redirect-uri` derives & persists HERMES_DASHBOARD_PUBLIC_URL in .env.
--redirect-uri is the full public callback (e.g.
https://hermes.example.com/auth/callback). At serve time the dashboard auth
layer reconstructs that callback by appending "/auth/callback" to
HERMES_DASHBOARD_PUBLIC_URL, so the value that's actually consumed is the
ORIGIN (scheme://host). We derive the origin from the supplied redirect URI
and persist THAT as HERMES_DASHBOARD_PUBLIC_URL the var the runtime reads
so the public-URL override is genuinely wired, not just stored.
An explicitly supplied value is always written (updating an existing entry
in place rather than appending a duplicate); a no-op when it already
matches; and never written on a localhost-only install (no --redirect-uri).
"""
def _run(self, *, args, existing_public=None):
"""Drive cmd_dashboard_register, capturing save_env_value calls.
`existing_public` is what get_env_value returns for
HERMES_DASHBOARD_PUBLIC_URL (None = not present in .env).
"""
response = {
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": getattr(args, "redirect_uri", None),
"created_at": "2026-06-04T12:00:00.000Z",
}
saved: dict = {}
def fake_save(key, value):
saved[key] = value
def fake_get_env_value(key, *a, **kw):
if key == "HERMES_DASHBOARD_PUBLIC_URL":
return existing_public
return None
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
), patch("hermes_cli.config.is_managed", return_value=False), patch.dict(
dr.os.environ, {}, clear=False
), patch.object(
dr, "_resolve_portal_base_url", return_value="https://portal.nousresearch.com"
), patch(
"hermes_cli.config.get_env_value", side_effect=fake_get_env_value
), patch(
"hermes_cli.config.save_env_value", side_effect=fake_save
), patch.object(
dr.urllib.request, "urlopen", return_value=_fake_http_ok(response)
):
dr.os.environ.pop("HERMES_DASHBOARD_PORTAL_URL", None)
dr.cmd_dashboard_register(args)
return saved
def test_origin_derived_from_full_callback_path(self, capsys):
# The key behaviour: a full callback URL is reduced to its ORIGIN so
# the runtime's "public_url + /auth/callback" reconstruction matches.
saved = self._run(
args=_ns(redirect_uri="https://hermes.example.com/auth/callback"),
existing_public=None,
)
assert saved["HERMES_DASHBOARD_PUBLIC_URL"] == "https://hermes.example.com"
# The full callback path must NOT be persisted verbatim (would double
# the path at serve time).
assert "/auth/callback" not in saved["HERMES_DASHBOARD_PUBLIC_URL"]
def test_origin_preserves_port(self, capsys):
saved = self._run(
args=_ns(redirect_uri="https://hermes.example.com:8443/auth/callback"),
existing_public=None,
)
assert saved["HERMES_DASHBOARD_PUBLIC_URL"] == "https://hermes.example.com:8443"
def test_public_url_updates_existing_in_place(self, capsys):
# A stale public-url entry exists; the new derived origin overwrites it.
saved = self._run(
args=_ns(redirect_uri="https://new.example.com/auth/callback"),
existing_public="https://old.example.com",
)
assert saved["HERMES_DASHBOARD_PUBLIC_URL"] == "https://new.example.com"
def test_public_url_equal_to_existing_is_noop(self, capsys):
# Derived origin already matches what's stored → no redundant write.
saved = self._run(
args=_ns(redirect_uri="https://hermes.example.com/auth/callback"),
existing_public="https://hermes.example.com",
)
assert "HERMES_DASHBOARD_PUBLIC_URL" not in saved
def test_no_redirect_flag_not_written(self, capsys):
# Localhost-only install (no --redirect-uri) → var left untouched.
saved = self._run(
args=_ns(),
existing_public=None,
)
assert "HERMES_DASHBOARD_PUBLIC_URL" not in saved
def test_no_redirect_flag_does_not_overwrite_existing(self, capsys):
# No --redirect-uri supplied but a value already exists → never touch
# it (an existing entry is only changed by an explicit new value).
saved = self._run(
args=_ns(),
existing_public="https://already-set.example.com",
)
assert "HERMES_DASHBOARD_PUBLIC_URL" not in saved
def test_non_http_redirect_not_persisted(self, capsys):
# A malformed / non-http(s) redirect yields no derivable origin → skip.
saved = self._run(
args=_ns(redirect_uri="not-a-url"),
existing_public=None,
)
assert "HERMES_DASHBOARD_PUBLIC_URL" not in saved
def test_public_url_persisted_alongside_portal_url(self, capsys):
# Both --portal-url and --redirect-uri supplied → portal_url AND the
# derived public_url are both persisted (ADD semantics: the public-url
# write does not displace portal-url persistence).
response = {
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": "https://hermes.example.com/auth/callback",
"created_at": "2026-06-04T12:00:00.000Z",
}
saved: dict = {}
def fake_save(key, value):
saved[key] = value
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
), patch("hermes_cli.config.is_managed", return_value=False), patch.dict(
dr.os.environ, {}, clear=False
), patch.object(
dr, "_resolve_portal_base_url", return_value="https://preview.example.com"
), patch(
"hermes_cli.config.get_env_value", return_value=None
), patch(
"hermes_cli.config.save_env_value", side_effect=fake_save
), patch.object(
dr.urllib.request, "urlopen", return_value=_fake_http_ok(response)
):
dr.os.environ.pop("HERMES_DASHBOARD_PORTAL_URL", None)
dr.cmd_dashboard_register(
_ns(
portal_url="https://preview.example.com",
redirect_uri="https://hermes.example.com/auth/callback",
)
)
assert saved["HERMES_DASHBOARD_PORTAL_URL"] == "https://preview.example.com"
assert saved["HERMES_DASHBOARD_PUBLIC_URL"] == "https://hermes.example.com"
class TestPortalResolution:
def test_override_arg_wins(self):
assert (
+48
View File
@@ -540,6 +540,54 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
)
def test_run_doctor_accepts_vendor_slugs_for_named_custom_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: custom:hpc-ai\n"
" default: deepseek/deepseek-v4-flash\n"
"custom_providers:\n"
" - name: hpc-ai\n"
" base_url: https://hpc-ai.example/v1\n"
" api_key: test-key\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'custom:hpc-ai' is not a recognised provider" not in out
assert "model.provider 'custom:hpc-ai' is unknown" not in out
assert (
"model.default 'deepseek/deepseek-v4-flash' uses a vendor/model slug but provider is "
"'custom:hpc-ai'"
not in out
)
assert "Either set model.provider to 'openrouter', or drop the vendor prefix." not in out
def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
+34 -2
View File
@@ -498,11 +498,42 @@ def test_gui_retries_pack_once_after_purging_build_cache(tmp_path, monkeypatch):
assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)]
def test_gui_does_not_retry_when_purge_finds_nothing(tmp_path, monkeypatch, capsys):
"""If the purge clears nothing, there's no point retrying — fail fast."""
def test_gui_falls_back_to_mirror_when_purge_finds_nothing(tmp_path, monkeypatch, capsys):
"""Purge clears nothing (not a cache problem) → fall back to an Electron
mirror once before failing, so a GitHub-blocked download self-heals."""
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch, platform="linux")
monkeypatch.delenv("ELECTRON_MIRROR", raising=False)
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]) as mock_purge, \
patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail, pack_fail]) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns())
assert exc.value.code == 1
mock_purge.assert_called_once()
# pack(fail) → purge(nothing) → pack via mirror(fail) = 2 subprocess.run calls
assert mock_run.call_count == 2
# The retry runs the same build but with ELECTRON_MIRROR injected.
assert "ELECTRON_MIRROR" not in (mock_run.call_args_list[0].kwargs.get("env") or {})
assert mock_run.call_args_list[1].kwargs["env"]["ELECTRON_MIRROR"]
assert "Desktop GUI build failed" in capsys.readouterr().out
def test_gui_does_not_override_user_electron_mirror(tmp_path, monkeypatch, capsys):
"""A user-pinned ELECTRON_MIRROR is respected: no extra mirror fallback
attempt (and we never swap in our default mirror)."""
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch, platform="linux")
monkeypatch.setenv("ELECTRON_MIRROR", "https://mirror.example/electron/")
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
@@ -518,6 +549,7 @@ def test_gui_does_not_retry_when_purge_finds_nothing(tmp_path, monkeypatch, caps
assert exc.value.code == 1
mock_purge.assert_called_once()
assert mock_run.call_count == 1
assert mock_run.call_args_list[0].kwargs["env"]["ELECTRON_MIRROR"] == "https://mirror.example/electron/"
assert "Desktop GUI build failed" in capsys.readouterr().out
@@ -65,6 +65,15 @@ def test_resolve_provider_full_finds_named_custom_provider():
assert resolved.source == "user-config"
def test_is_aggregator_recognizes_named_custom_provider():
assert providers_mod.is_aggregator("custom:hpc-ai") is True
assert providers_mod.is_aggregator("custom:litellm") is True
def test_is_aggregator_leaves_unknown_provider_non_aggregator():
assert providers_mod.is_aggregator("not-a-provider") is False
def test_switch_model_accepts_explicit_named_custom_provider(monkeypatch):
"""Shared /model switch pipeline should accept --provider for custom_providers."""
monkeypatch.setattr(
+12
View File
@@ -442,6 +442,18 @@ class TestDeleteProfile:
with pytest.raises(FileNotFoundError):
delete_profile("nonexistent", yes=True)
def test_rmtree_failure_raises(self, profile_env):
profile_dir = create_profile("coder", no_alias=True)
set_active_profile("coder")
with patch("hermes_cli.profiles._cleanup_gateway_service"), \
patch("hermes_cli.profiles.shutil.rmtree", side_effect=PermissionError("locked")):
with pytest.raises(RuntimeError, match="Could not remove profile directory"):
delete_profile("coder", yes=True)
assert profile_dir.is_dir()
assert get_active_profile() == "default"
# ===================================================================
# TestListProfiles
+40
View File
@@ -896,6 +896,46 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
assert env["NODE_ENV"] == "production"
def test_launch_tui_applies_terminal_backend_config(
monkeypatch, main_mod, _isolate_hermes_home
):
captured = {}
config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml"
config_path.write_text(
"\n".join(
[
"terminal:",
" backend: docker",
" docker_image: example/hermes-tools:latest",
" docker_extra_args:",
" - --network=host",
]
),
encoding="utf-8",
)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_IMAGE", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_EXTRA_ARGS", raising=False)
monkeypatch.setattr(
main_mod,
"_make_tui_argv",
lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
)
monkeypatch.setattr(
main_mod.subprocess,
"call",
lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1,
)
with pytest.raises(SystemExit):
main_mod._launch_tui()
assert captured["env"]["TERMINAL_ENV"] == "docker"
assert captured["env"]["TERMINAL_DOCKER_IMAGE"] == "example/hermes-tools:latest"
assert captured["env"]["TERMINAL_DOCKER_EXTRA_ARGS"] == '["--network=host"]'
def test_launch_tui_exit_code_42_relaunches_update(monkeypatch, main_mod):
from unittest.mock import patch

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