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.
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.
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.
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.
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>
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>
* 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>
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).
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()).
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
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
#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
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.
Photon now exposes attachment send (Ray Sun, photon-nousresearch), so
the Photon plugin gains outbound media to match the BlueBubbles iMessage
channel.
- sidecar: new /send-attachment endpoint wrapping space.send(attachment())
/ space.send(voice()); caption sent as a trailing text bubble.
- adapter: override send_image/send_image_file/send_voice/send_video/
send_document/send_animation. URL helpers cache to a local path first
(cache_image_from_url), file helpers pass through. Defense-in-depth
path re-validation before the path reaches the Node sidecar.
- _standalone_send (cron): send text first, then each media_file as a
/send-attachment call (is_voice -> voice builder).
- docs/README: flip the 'outbound attachments not wired' note.
The Skills Hub lost every api.github.com-backed source — the OpenAI,
Anthropic, HuggingFace, NVIDIA, gstack, Claude Marketplace and Well-Known
tabs all vanished — while ClawHub/skills.sh/LobeHub/browse.sh survived. A
GitHub API rate limit during the docs-deploy crawl zeroed all three
api.github.com sources (github / claude-marketplace / well-known) at once.
Two compounding bugs let the broken index reach the live site:
1. build_skills_index.py wrote the output file BEFORE the health check, so
even when the github floor (30) tripped and the script exited 2, the
degenerate file was already on disk. deploy-site.yml then swallowed the
exit code with `|| echo non-fatal` and extract-skills.py read the partial
index. Fix: run the health check first, write the file only when healthy,
exit without writing on failure. Removed the non-fatal swallow in
deploy-site.yml so a collapse fails the deploy and the last good site
stays live (Pages serves the previous build).
2. The build-time GitHub listing path returned [] on a 403 rate-limit without
retrying or flagging it, so a rate-limited crawl looked identical to an
empty source. Fix: a shared _github_get() helper on GitHubSource with
retry/backoff (honors Retry-After / X-RateLimit-Reset on 403/429, backs
off on 5xx + transport errors) and flags is_rate_limited. Routed
_list_skills_in_repo and _fetch_file_content through it; gave
ClaudeMarketplaceSource a persistent GitHubSource + is_rate_limited so the
builder can name the rate limit as the cause instead of '0 results'.
Added tests/scripts/test_build_skills_index_health.py pinning both contracts:
a degenerate crawl exits non-zero and writes no file; a healthy crawl writes
the index with github/claude-marketplace/well-known all present.
The test keyed the 'which call raises' decision on a shared invocation
counter (first call → raise, second → success), then asserted the error
landed in messages[0] (c1) and success in messages[1] (c2). But
_execute_tool_calls_concurrent runs the two web_search calls on a thread
pool with no ordering guarantee — c2's handler can be invoked first, take
the 'first call raises' branch, and the error ends up in messages[1].
Results are ordered by tool_call_id, so messages[0] (c1) was then 'success'
and the assertion failed.
It passed in isolation but reliably failed under CI's full parallel slice
(8 xdist workers) where the scheduler actually interleaves the two handlers.
Fix: tie the raise to a specific tool call via its arguments (q=boom raises,
q=ok succeeds) instead of invocation order, and assert tool_call_id ↔ content
pairing explicitly. Deterministic regardless of thread scheduling — verified
10/10 in isolation and the full TestConcurrentToolExecution class (32) green.
Adds the last missing parity piece vs the established channels: group
chats can be made opt-in via a mention wake word, exactly like the
BlueBubbles iMessage channel.
- require_mention + mention_patterns, read from config.extra (config.yaml
via the generic gateway bridge) or PHOTON_REQUIRE_MENTION /
PHOTON_MENTION_PATTERNS env vars. Same shapes BlueBubbles accepts
(list / JSON / comma / newline), same default Hermes wake words.
- _dispatch_inbound drops unmatched group messages and strips the leading
wake word from matched ones; DMs are never gated.
- plugin.yaml + docs document both knobs and the config.yaml form.
- New test_mention_gating.py (8 tests): default-off, group drop/pass,
wake-word strip, DM bypass, custom patterns, env comma-list, invalid
regex skip.
The config.yaml -> extra bridge needed no core change — the generic
shared-key loop in gateway/config.py already iterates plugin platforms
(_shared_loop_targets += plugin_entries()), so require_mention /
mention_patterns flow through automatically.
Note: outbound media is the one capability Photon still can't reach —
Photon exposes no HTTP send-attachment endpoint yet (documented API
limitation), so the sidecar can't send files. Not faked.
Validation: 34/34 photon tests; E2E confirms config.yaml require_mention
+ custom mention_patterns bridge through load_gateway_config into a live
adapter and gate/strip correctly.
The advisory lint-diff bot flagged 17 new ty diagnostics. 6 are
`unresolved-import` for httpx/aiohttp/pytest, which is structural
(CI lint env has no project deps) and matches every other platform
plugin's noise floor. The remaining 11 are real and fixable:
- `Optional[callable]` → `Optional[Callable[..., None]]` (auth.py)
invalid-type-form on `callable` as a type expression. Added the
proper `typing.Callable` import. Two sites: on_pending in
poll_for_token, on_user_code in login_device_flow.
- Dropped three unused `# type: ignore` comments on
hermes_constants / hermes_cli.config imports — ty can resolve
those modules fine, the comments were dead.
- _supervise_sidecar(proc) widened `proc.stdout` from
`IO[Any] | None` to a narrowed local after an early `is None`
guard. Defensive against subprocesses launched without
stdout=PIPE.
- cli.py _cmd_setup: dropped the `has_existing_project = bool(...)`
intermediate, did the narrowing inline with `if existing_id and
existing_secret:` so ty can see project_id/project_secret are
non-None when create_user is called.
- test_inbound.py: replaced three `adapter.handle_message =
fake_handle # type: ignore[assignment]` with
`monkeypatch.setattr(adapter, 'handle_message', fake_handle)`.
Same behavior, no type-ignore, and the monkeypatch reverts
cleanly between tests.
Validation:
ty check plugins/platforms/photon/ tests/plugins/platforms/photon/
→ All checks passed!
tests/plugins/platforms/photon/ → 26/26 pass
py_compile clean
Windows footgun checker → 0 footguns
The previous pass moved credential reads into auth.credential_summary()
which returned a dict of pre-formatted display strings. CodeQL's
interprocedural taint analysis still flagged the cli.py prints because
the dict's values were transitively derived from load_photon_token()
and load_project_credentials().
Pattern that finally works: same as persist_webhook_signing_secret —
the helper takes an emit callback and does the formatting + emitting
itself. cli.py passes `print` as the sink and never receives any
return value derived from credential reads. CodeQL's flow stops at
the helper's emit() boundary.
Changes:
- auth.print_credential_summary(emit=print) — closure-scoped probes,
emits 6 lines (header + separator + 4 credential rows) via the
callback. Returns None.
- cli._cmd_status now calls print_credential_summary(print) then
appends the two non-credential rows (node binary, sidecar deps)
locally with no credential flow.
- Added test_print_credential_summary_emits_only_display_strings
asserting the emit callback never sees raw token/secret bytes.
Validation:
tests/plugins/platforms/photon/ → 26/26 pass
live smoke: hermes photon status (with empty HERMES_HOME) renders
the expected layout cleanly
CodeQL was still flagging three taint-flow alerts in cli.py — its
flow tracker keeps spreading the 'sensitive' label through every
variable that even touched a credential-returning function, including
'has_token = bool(load_photon_token())' and the redacted-response
dict returned by persist_webhook_signing_secret.
Refactor:
1. cli.py _cmd_status now calls a new auth.credential_summary() that
returns a {key: pre-formatted display string} dict. All probes +
bool checks happen inside the helper. cli.py never sees a token
or secret variable, only literals like '✓ stored' / '✗ missing'.
2. persist_webhook_signing_secret(webhook_data, *, on_summary=print)
now owns the formatting + writing + status messages. It returns
only a bool. The redacted-response JSON dump + 'saved to <path>'
confirmation are emitted via the on_summary callback, so cli.py
passes as the sink and never receives the path/dict back.
cli.py is now mechanical: register_webhook → persist (with print)
→ return 0/1. Zero credential-tainted variables in cli.py at all.
3. Tests updated for the new signatures and a credential_summary
guard added (the helper must never leak raw token/secret bytes
into its return strings).
Validation:
tests/plugins/platforms/photon/ → 25/25 pass
scripts/check-windows-footguns.py --all → 0 footguns
py_compile clean
Down to 4 CodeQL alerts after the last pass; all addressed:
cli.py:215 (clear-text-logging-sensitive-data)
The status banner literal 'project secret : ✓ stored' tripped
CodeQL's variable-name heuristic even though only a boolean was
interpolated. Renamed the column labels to 'project key' and
'webhook key' — fields contain only ✓ stored / ✗ missing / ⚠ unset
literals now, the word 'secret' is no longer in the source.
cli.py:283 (clear-text-logging-sensitive-data)
The fallback path for register-webhook used to echo
'PHOTON_WEBHOOK_SECRET=<value>' to stdout when the .env write
failed. Removed entirely — there is no scenario where we should
print the secret. On failure we now tell the user to fix the .env
permissions and re-register (after deleting the orphaned webhook
from the Photon dashboard).
cli.py:354 (clear-text-storage-sensitive-data) +
cli.py:276 (clear-text-logging-sensitive-data)
Replaced the hand-rolled .env writer in cli.py with the canonical
hermes_cli.config.save_env_value helper that every other API-key
persistence path uses (OpenAI key, Anthropic, Telegram, ...).
Moved the persist logic into auth.py as
persist_webhook_signing_secret(webhook_data) so the signing-secret
value never gets bound to a local in cli.py at all — cli.py hands
the raw API response straight to the helper and receives back only
the path + a redacted copy of the response for display. This both
matches project convention and removes the taint flow CodeQL was
tracking.
Bonus cleanup:
- dropped unused 'from typing import Any, Optional' in cli.py
- added 2 tests covering persist_webhook_signing_secret (writes
env successfully + returns redacted copy + no-secret-no-write)
Validation:
tests/plugins/platforms/photon/ → 24/24 pass
scripts/check-windows-footguns.py --all → 0 footguns
py_compile on all photon modules → clean
CI red on three blocking checks; all addressed:
1. Windows footguns: os.killpg() flagged as POSIX-only despite the
sys.platform != 'win32' guard. Static scanner doesn't see flow.
Added the documented '# windows-footgun: ok' suppression.
2. test (3): tests/plugins/platforms/photon/__init__.py shadowed the
real plugin's __init__.py because test_plugin_platform_interface.py
looks at PROJECT_ROOT/plugins/platforms/<name>/__init__.py with
PROJECT_ROOT=tests/ (pre-existing bug in that test, made visible
by the new test directory layout). Dropping the empty test
__init__.py restores the prior NOTSET parametrize behavior.
3. CodeQL (7 alerts in new code):
- cli.py: stop printing the first 8 chars of the bearer token after
login — even prefixes are partial credentials.
- cli.py: stop printing the first 8 chars of project_secret after
setup, same reason.
- cli.py 'hermes photon webhook register': stop dumping the raw
register-webhook response (contained signingSecret) and stop
echoing PHOTON_WEBHOOK_SECRET to stdout. Write it directly to
~/.hermes/.env (0o600), preserving existing entries; fall back
to manual instructions only if the file write fails. Photon
still only returns the secret once; this just doesn't put it
in scrollback / shell history.
- cli.py setup + status: rename project_id/project_secret/token
locals to has_* booleans before printing, breaking CodeQL's
taint flow through f-string interpolations. Drop diagnostic
prints of phone / assignedPhoneNumber that flagged as
'sensitive data' false positives.
- sidecar/index.mjs: stop returning the raw error message
(potentially containing stack trace) in HTTP 500 responses;
supervisor logs the real error to stderr, client only sees
a generic 'internal sidecar error'.
Validation:
- scripts/check-windows-footguns.py --all → 0 footguns (518 files)
- tests/plugins/platforms/photon/ → 22/22 pass
- tests/gateway/test_plugin_platform_interface.py → 7/7 pass, collects
NOTSET (matches pre-PR state)
- tests/gateway/test_platform_registry.py → 50/50 pass
- node --check sidecar/index.mjs clean
First-class iMessage support via Photon's managed Spectrum platform.
Targeted as a successor to the BlueBubbles adapter — Photon allocates
the iMessage line, handles delivery, and abuse-prevention so users
don't have to run their own Mac relay. Free tier uses Photon's shared
line pool.
Architecture:
- Inbound: signed JSON webhooks (X-Spectrum-Signature, HMAC-SHA256)
delivered to a local aiohttp listener. Dedupes on message.id,
rejects deliveries with >5min timestamp drift.
- Outbound: small supervised Node sidecar that runs the spectrum-ts
SDK. Photon does not currently expose a public HTTP send-message
endpoint; the sidecar is the only way to call Space.send() today.
When Photon ships an HTTP send endpoint we collapse the sidecar
into _sidecar_send and drop the Node dep — every other layer of
the plugin stays the same.
- Setup: 'hermes photon login' runs the RFC 8628 device-code flow;
'hermes photon setup' creates a Spectrum-enabled project, creates
a shared user (free tier), installs the sidecar's npm deps.
- Webhook management: 'hermes photon webhook register|list|delete'.
- Credentials persisted under credential_pool.photon /
credential_pool.photon_project in ~/.hermes/auth.json.
Plugin path (not built-in) — per current policy (May 2026), all new
platforms ship under plugins/platforms/. Registers itself via
ctx.register_platform() + ctx.register_cli_command(), zero edits to
core gateway code.
Tests cover:
- HMAC-SHA256 signature verification (happy path, tampered body,
wrong secret, drift, missing v0 prefix, empty inputs, non-integer
timestamp)
- Inbound dispatch for text DMs, group ids (any;+;...), and
attachment metadata markers
- Deduplication window
- check_requirements gating when Node is absent
- Device-code flow: request, header-based token return,
body-fallback token return, access_denied propagation
- Project/user/webhook API clients with mocked httpx
Known limitations (current Photon API):
- Attachments are metadata only — no download URL yet
- Outbound attachment send not wired (sidecar can add easily)
- Reactions / message effects not exposed yet
Docs: website/docs/user-guide/messaging/photon.md + sidebar entry.
A bare `git fetch origin` (and `git fetch upstream`) pulls every ref. The
repo carries thousands of auto-generated branches, so on any
non-single-branch checkout the installer's update path and `hermes update`
spend minutes downloading the full branch list — long enough to stall the
desktop installer or trip the follow-up `git pull --ff-only`.
Scope every update-path fetch to the branch we actually compare/merge
against:
- scripts/install.sh: collapse the remote to single-branch and fetch only
$BRANCH on the "existing install, updating" path.
- hermes_cli/main.py: fetch the resolved branch in the apply path, the
--check path (upstream + origin), and the fork upstream-sync.
Tracking-ref updates still happen via git's opportunistic refspec, so the
later origin/<branch> rev-parse/rev-list checks are unaffected.
Tests assert the apply-path fetch is branch-scoped and never bare.
The anthropic extra pinned anthropic==0.86.0 while LAZY_DEPS['provider.anthropic']
pins 0.87.0 (CVE-2026-34450, CVE-2026-34452) — the same drift class as the
aiohttp #31817 downgrade. On hermes update the extra pin won and rolled
anthropic 0.87.0 -> 0.86.0, reopening both CVEs until the native-Anthropic
lazy refresh re-bumped it.
Bump the extra to 0.87.0, regenerate uv.lock, and generalize the regression
guard: test_pyproject_pins_match_lazy_deps_pins now fails if ANY package
pinned in both a pyproject extra and a LAZY_DEPS entry drifts, so a third
package can't reintroduce this class. The aiohttp-specific test is kept for
focused #31817 coverage.
hermes auth add openai-codex now creates an independent
manual:device_code pool entry per account instead of routing through
the singleton _save_codex_tokens save path, which collapsed every
added account into the latest login (the second add overwrote the
first account's singleton-mirrored device_code entry). This is the
add-path half of #39236; PR #39243 (already on this branch) fixes the
re-auth half.
manual:device_code entries refresh from their own token pair
(_sync_codex_entry_from_auth_store only adopts the singleton for
source=="device_code"), so they need no providers.openai-codex
shadow. Adding the first credential marks openai-codex active (the
singleton path did this implicitly) so the setup wizard's
get_active_provider() check still passes; subsequent adds leave the
active provider untouched.
Adds SOURCE_MANUAL_DEVICE_CODE constant and a regression test that two
distinct accounts keep distinct token pairs. Updates two existing add
tests to the pool-only behavior.
Co-authored-by: glesperance <info@glesperance.com>
The #33538 fix refreshed every credential_pool entry with source
"manual:device_code" on every Codex OAuth re-auth, on the assumption that
such entries were always legacy aliases of the singleton from the #33000
workaround era. That assumption is no longer true: `hermes auth add
openai-codex` also produces "manual:device_code" entries for independent
ChatGPT accounts, and the broad sync silently clobbered them with the
latest-authenticated token pair (labels preserved, token material
overwritten, status / quota readings then lie).
Narrow the sync: refresh a "manual:device_code" entry only when its
existing access_token matches the previous singleton access_token (true
legacy alias). Entries with distinct token material represent independent
accounts and are now left alone. Error markers are cleared only on
entries actually rewritten, so an independent account's own 429 / 401
state survives a re-auth that targeted a different account.
Tests:
* New: independent acctB/acctC are not overwritten when acctA re-auths.
* New: legacy singleton-alias still refreshed (preserves #33538).
* New: missing previous singleton state handled (no crash, no false
alias match).
* New: access_token-only alias match (legacy schema without
refresh_token still recognized).
* New: error markers cleared only on entries actually refreshed.
* Updated: existing manual-device-code sync test now covers both the
legacy-alias path AND the independent-account path in one fixture.
Behaviour change is zero for users with a single Codex account and zero
for users whose only "manual:device_code" entry is the legacy alias of
the singleton. Users with multiple independent Codex accounts added via
`hermes auth add` now keep their distinct token material across
re-auths.
Local: 29 passed in tests/hermes_cli/test_auth_codex_provider.py, no
new failures in tests/hermes_cli/ vs upstream/main baseline.
Fixes#39236.
* fix(stream): don't report dropped mid-tool-call streams as output truncation
A streaming tool call whose SSE ends with no finish_reason (the upstream
delivers the tool name + opening '{' then closes the connection cleanly,
no terminator, no [DONE]) was stamped finish_reason='length' by the mock
builder. That routed it through the output-cap truncation path: 3 useless
max_tokens-boosted retries, then the misleading 'Response truncated due to
output length limit' error — even though the model never reported hitting
any cap.
Reproduced live on nvidia/nemotron-3-ultra:free via the Nous dedicated
endpoint, which stalls/drops during large tool-arg generation (50s-4m41s).
Now: when tool args are incomplete AND the provider sent no finish_reason,
tag the response as a partial-stream stub so the loop reports an honest
mid-tool-call drop and asks the model to chunk its output (existing
continuation machinery), instead of escalating output budget and lying.
A provider-reported finish_reason='length' still takes the real-truncation
path unchanged.
* test(stream): update truncated-tool-args test for drop-vs-cap split
test_truncated_tool_call_args_upgrade_finish_reason_to_length pinned the
old behaviour where ANY incomplete tool args → finish_reason='length' with
tool_calls preserved. That single-chunk-no-finish_reason scenario is exactly
the mid-tool-call stream drop now reclassified as a partial-stream stub.
Split into two tests matching the new contract:
- no finish_reason + incomplete args → PARTIAL_STREAM_STUB_ID, tool_calls=None,
_dropped_tool_names set (the drop path)
- explicit finish_reason='length' + incomplete args → tool_calls preserved,
'length' upgrade unchanged (the genuine output-cap path)
* fix(desktop): stop running app locking win-unpacked before pack
On Windows a running Hermes.exe keeps an exclusive lock on
release/win-unpacked/Hermes.exe, so electron-builder's pack cannot
replace it and dies with "remove ...\Hermes.exe: Access is denied" /
ERR_ELECTRON_BUILDER_CANNOT_EXECUTE (before-pack hits the same EPERM
cleaning the dir, and the cache-purge retry repeats the failure since
the lock is still held).
Before building the packaged app, terminate any process whose
executable lives inside this build's release/ tree so the rebuild --
including the installer's headless --update rebuild -- can replace the
binary. Scope is narrow (only exes under release/), POSIX is a no-op
(it can unlink a running binary), and the final error now points
Windows users at the running-app cause.
* test(desktop): cover the win-unpacked lock-breaker helper
Verify _stop_desktop_processes_locking_build is a no-op off-Windows,
terminates only processes whose exe lives under release/ (sparing our
own PID and unrelated installs), and short-circuits when no release dir
exists.
* feat(windows): enable dashboard chat tab via ConPTY (win_pty_bridge)
Add hermes_cli/win_pty_bridge.py — a pywinpty-backed drop-in for
PtyBridge with the same spawn/read/write/resize/close surface — and
wire it into the web_server PTY import block so Windows picks it up
instead of falling back to None.
pywinpty is already a declared win32 dependency (pyproject.toml).
The ConPTY read path runs inside run_in_executor so the event loop
is never blocked. Spawn/read/write/terminate call shapes are taken
directly from tools/process_registry.py which already exercises the
same pywinpty version.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: remove WSL2-only caveat for dashboard chat tab
The chat pane now works on native Windows via the ConPTY bridge added
in the previous commit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(windows): cover ConPTY bridge + web_server platform-branched import
Companion to the bridge added in the previous commits. Verified live on
native Windows 11 (pywinpty 2.0.15) against `hermes dashboard`'s
`/api/pty` WebSocket: the spawned `hermes --tui` (node entry.js) renders
through ConPTY, resize escapes reach `setwinsize`, and closing the WS
reaps both the node child and the pywinpty agent with zero orphans.
tests/hermes_cli/test_win_pty_bridge.py
Mirrors the layout of the existing POSIX test_pty_bridge.py:
spawn/io/resize/close/env coverage against cmd.exe and python -c,
plus the cross-platform fallback surface (PtyUnavailableError, the
off-Windows `spawn -> raises PtyUnavailableError` guard, and the
load-bearing _clamp() helper that protects setwinsize from garbage
winsize values out of xterm.js).
tests/hermes_cli/test_web_server_pty_import.py
Asserts that web_server.PtyBridge resolves to WinPtyBridge on win32
and to the POSIX PtyBridge on POSIX, that PtyUnavailableError is the
matching class on each side (so isinstance checks in /api/pty's
spawn fallback path work), and a source-text check that pins the
platform-branched import shape so a future refactor can't quietly
collapse it back to a POSIX-only import.
scripts/release.py
AUTHOR_MAP entries so CI release-note generation can resolve both
authors' plain (non-noreply) emails to their GitHub logins.
Co-Authored-By: JoelJJohnson <josephjohnson.joel@gmail.com>
Co-Authored-By: Nea74 <andreas@schwarz-ketsch.de>
---------
Co-authored-by: JoelJJohnson <josephjohnson.joel@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Nea74 <andreas@schwarz-ketsch.de>
The messaging/slack/homeassistant/sms extras exact-pinned aiohttp==3.13.3
while LAZY_DEPS['platform.slack'] already pins 3.13.4 (the CVE fix). On
`hermes update` the extras pin won, downgrading aiohttp 3.13.4 -> 3.13.3
and reopening 10 published advisories (CVE-2026-34513/34515/34516/34517/
34518/34519/34520/34525, -22815, -34514) until Slack's lazy refresh
re-upgraded it.
Bump all four extras to 3.13.4 to match the lazy pin, regenerate uv.lock,
and add test_pyproject_aiohttp_pins_match_lazy_slack_pin to guard the
alignment going forward.
Fixes#31817
When the agent has its own SessionDB reference (_session_db is not None),
_flush_messages_to_session_db() persists user messages to SQLite during the
agent run. Two gateway fallback paths also wrote the same user message
without skip_db=True, creating duplicate entries in state.db:
1. agent_failed_early path (transient 429/timeout failures)
2. not-new-messages path (history_offset >= len(messages) edge case)
Move agent_persisted flag definition to before the if/elif/else block so
all paths can use it, and pass skip_db=agent_persisted to every fallback
append_to_transcript() call.
Fixes#42039
Main folded slash_worker.close() into _finalize_session (the single
_finalized-guarded chokepoint) while #42143 was open. The rebase
conflicted with the PR's worker-close in _teardown_session. Keep both —
they target the same #38095 leak and _SlashWorker.close() is
idempotent (_closed/poll()-guarded) — so callers reaching
_teardown_session without the real _finalize_session (and the PR's own
tests, which monkeypatch _finalize_session out) still reap the worker.
Same for _shutdown_sessions, now routed through the unified
_close_session_by_id funnel.
Salvaged from #35626 (banditburai) and re-scoped after maintainers landed the
parent-death watchdog (slash_worker.py) and PTY process-group teardown
(pty_bridge.py) directly on main. Those pieces are intentionally NOT included
here — this carries only what is still missing:
- C1 disconnect reap: ws.py's `finally` only re-pointed the dead transport at
stdio. `_close_sessions_for_transport` now reaps `close_on_disconnect`
sessions and schedules the grace-reap for the rest, offloaded via
`asyncio.to_thread` so the blocking worker.close() + DB write never stalls
the uvicorn loop.
- C2 create/close orphan race: `_attach_worker` stores the worker iff
`_sessions.get(sid) is session` under the lock (else closes it), applied at
every spawn site incl. the post-turn `_restart_slash_worker`.
- Single idempotent teardown funnel: session.close, WS disconnect, the
generous-TTL idle reaper, shutdown, and the WS grace-reap all reach
`_close_session_by_id` → `_teardown_session`; `_finalized`/`_closed` flags
make concurrent/double teardown a no-op. `_sessions_lock` upgraded to RLock.
- uvicorn `ws_ping_interval/timeout=20s` so a half-open socket (reverse-proxy
524) becomes a `WebSocketDisconnect` and the C1 path runs.
Plus two review-driven hardening fixes (mine):
- `session.active_list` now skips `_finalized` sessions so the footer
"N sessions" count reflects attachable sessions instead of only ever
growing until restart (#38950). Keys on `_finalized` only, NOT the stdio
sentinel, so a standalone `hermes --tui` session stays visible.
- `_schedule_ws_orphan_reap._reap` pops via `_close_session_by_id`
(under `_sessions_lock`) instead of `_sessions.pop` under the unrelated
`_session_resume_lock` (#39591); the resume_lock now only guards the orphan
re-check against `session.resume`.
- Float env knobs (`HERMES_SLASH_WATCHDOG_*`, `HERMES_TUI_SESSION_TTL_S`)
parse with a fallback helper so a malformed value can't crash the worker at
import.
Fixes#32377Fixes#38950
Addresses #22855
Co-authored-by: banditburai <123342691+banditburai@users.noreply.github.com>
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
resolve_provider() auto-detection only checked OPENROUTER_API_KEY/
OPENAI_API_KEY env vars, never the credential pool. A key added via
`hermes auth add openrouter` (manual pool entry, no env var) was invisible:
the provider failed to resolve or resolved with an empty api_key, so
requests went out with no Authorization header and OpenRouter returned
"HTTP 401: Missing Authentication header" while `hermes auth list` showed
the credential. Closes#42130.
- auth.py: check load_pool("openrouter").has_credentials() after the env check
- dump.py: `debug share` shows 'openrouter set (auth pool)' instead of the
misleading 'not set' when the key lives in the pool
- add regression tests (pool credential auto-detects; empty pool still raises)
Lift the 18 _model_flow_* provider-setup wizard functions out of hermes_cli/main.py
into hermes_cli/model_setup_flows.py. Behavior-neutral; main.py 14050 -> 11479 LOC.
select_provider_and_model (the dispatcher) STAYS in main.py and re-imports the
flows via an explicit 'from hermes_cli.model_setup_flows import (...)' block, so
both its bare-name calls and existing test monkeypatches targeting
hermes_cli.main._model_flow_* keep resolving against main's namespace unchanged.
Imports: 3 neutral deps (argparse, os, subprocess) at the module top; the 14
main.py-internal helpers the flows call (_prompt_api_key, _save_custom_provider,
the reasoning-effort/stepfun/qwen helpers, _run_anthropic_oauth_flow, ...) are
lazy-imported per-flow (from hermes_cli.main import ...) so the new module never
imports main at module scope -> no import cycle.
Repointed one source-inspection change-detector (test_setup_ollama_cloud_force_refresh)
to read the module the ollama-cloud branch moved to.
Validation: 6563/6563 hermes_cli tests pass; live flow-dispatch probe confirms the
lazy main-internal imports resolve at runtime.
Lift the 5 agent-construction/session-resume methods out of HermesCLI into
hermes_cli/cli_agent_setup_mixin.py:CLIAgentSetupMixin. Behavior-neutral; cli.py
14139 -> 13492 LOC.
Methods moved (~647 LOC): _ensure_runtime_credentials, _resolve_turn_agent_config,
_init_agent, _preload_resumed_session, _display_resumed_history. All self.* calls
resolve unchanged via the MRO (HermesCLI(CLIAgentSetupMixin, CLICommandsMixin)).
Import split (same recipe as #41942): 2 neutral deps (sys, _escape) imported at
the mixin module top; 12 cli.py-internal helpers/constants (AIAgent, ChatConsole,
CLI_CONFIG, _cprint, _DIM, _RST, _accent_hex, ...) imported lazily per-method
(from cli import ...) so the mixin never imports cli at module scope -> no cycle.
Repointed one source-inspection change-detector (test_callable_api_key.py) to read
the mixin file where the method now lives.
Two narrow Windows desktop fixes:
1. tools/process_registry.py — PTY stdin writes are now platform-aware.
pywinpty (Windows) expects str; ptyprocess (POSIX) expects bytes.
Previously bytes was unconditionally passed, producing a TypeError on
Windows ("'bytes' object cannot be converted to 'PyString'").
2. tui_gateway/server.py + ws.py — Detached WebSocket sessions now park on
a _DropTransport sink instead of _stdio_transport. In the desktop the
gateway runs in-process and stdout is captured by Electron into
desktop.log, so falling back to stdio leaked raw JSON-RPC frames into
the desktop log after WS disconnects. Orphan-reap semantics are
preserved via _ws_session_is_orphaned.
Verified on a Windows desktop install:
- pywinpty 2.0.15 rejects bytes / accepts str — reproduced exactly
- Focused suite green (write_stdin × 2, write_json_drops_detached_ws_frames,
ws_orphan_reap × 2)
- All 6 CI test shards green, e2e green, nix (ubuntu/macos) green
Salvage commit (21be7ca) fixes the new test referencing an undefined
_ThreadUnsafeStdout — uses the existing _ChunkyStdout helper.
A Responses-API-shaped payload carrying instructions=/input=/store=/
parallel_tool_calls= can reach the native Anthropic messages.stream() /
messages.create() call under a rare api_mode-flip race (e.g. a concurrent
auxiliary vision call mutating a shared agent between the kwargs build and
the stream dispatch). The Anthropic SDK rejects these with a non-retryable
TypeError that kills the whole turn and propagates the entire fallback chain.
Add sanitize_anthropic_kwargs() at both Anthropic dispatch sites: it drops
the Responses-only keys in place and logs a WARNING (with #31673 breadcrumb)
when one is present, so the underlying race stays visible in the wild
instead of being silently papered over.