Compare commits

...
Author SHA1 Message Date
alt-glitch 774ecf93dc cli: worktree lock + dirty-tree preservation — stop pruning uncommitted work
Three behavior changes to the hermes -w worktree lifecycle:

1. Git-native locks. _setup_worktree now locks its worktree
   (git worktree lock --reason "hermes session pid=<pid>"), and
   _prune_stale_worktrees skips locked worktrees at ANY age — a lock
   from a live or crashed session means "do not touch". New helpers
   _lock_worktree / _unlock_worktree / _worktree_is_locked (fail-safe:
   any error reads as locked) / _worktree_is_dirty (fail-safe: any
   error reads as dirty).

2. Dirty trees are preserved. _cleanup_worktree previously destroyed
   worktrees with uncommitted changes if there were no unpushed
   commits; it now keeps the worktree, branch, and lock when the tree
   is dirty OR has unpushed commits, and prints manual cleanup hints
   (git worktree unlock + remove --force). The >72h "force remove
   regardless" prune tier is removed: pruning may only ever delete
   clean, unlocked, fully-pushed worktrees.

3. Branch deletion is gated on removal success. Both cleanup and
   prune previously deleted the branch without checking the
   git worktree remove returncode, dropping easy reachability of the
   commits even when removal failed; the branch is now only deleted
   after a successful remove.
2026-06-16 19:35:25 +05:30
Teknium a68ac0c49a feat(desktop): allow /browser connect on a local gateway (#47245)
* fix(skills): guard recursive skill delete against tree-escape

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

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

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

* feat(desktop): allow /browser connect on a local gateway

/browser was hardcoded as terminal-only in the desktop slash palette, so
the chat GUI rejected it with "only available in the terminal interface."
The TUI already drives the live CDP connection via the browser.manage RPC.

Wire the same RPC into the desktop dispatcher as a /browser action handler,
gated to local-gateway connections ($connection.mode !== 'remote'). connect
mutates BROWSER_CDP_URL (and may launch Chrome) in the gateway process, so
it's only meaningful when that process runs on this machine; a remote
gateway gets a clear "local gateway only" message instead.
2026-06-16 09:03:43 -05:00
Wolfram Ravenwolf 16fc717091 fix(mattermost): harden delivery hygiene
PROBLEM: Mattermost threads can become invalid or enormous, exposing two failure modes: internal scratch/reasoning/commentary displays could leak into persistent Mattermost threads via global display toggles, while rejected threaded user-visible replies could disappear unless every failed send fell back flat. A broad flat fallback would pollute channels with tool/status/progress noise.

SOLUTION: Require explicit Mattermost platform opt-in for scratch displays, keep using the existing notify=True metadata marker for user-visible final text/media/file replies, and allow the Mattermost plugin adapter to flat-fallback only notify-worthy sends whose threaded POST failure looks like a broken root/thread. Keep tool/status/progress and other non-notify sends thread-strict. Add regression tests for display opt-in, notify-only broken-thread fallback, generic API failure suppression, and stream notify metadata.

Verification: tests/gateway/test_mattermost.py tests/gateway/test_stream_consumer.py tests/gateway/test_stream_consumer_thread_routing.py tests/gateway/test_stream_consumer_fresh_final.py tests/gateway/test_stream_consumer_draft.py; tests/gateway/test_session_api.py tests/gateway/test_status_command.py tests/gateway/test_resume_command.py tests/hermes_cli/test_commands.py; py_compile touched gateway files; git diff --check.

Session: Mattermost thread 6qg8e9dd1pd9pkhi74xyaa1mry, 2026-06-01.
2026-06-16 06:34:54 -07:00
teknium1 925b0d1ab5 chore: add zimigit2020 to release AUTHOR_MAP 2026-06-16 06:23:53 -07:00
Rory EvansandClaude Opus 4.8 e65d74bc6f fix(gateway): accept metadata kwarg in WhatsApp/email send_image
`BasePlatformAdapter.send_multiple_images` passes `metadata=metadata` to
`send_image` / `send_image_file` / `send_animation` on every send. The
WhatsApp and email `send_image` overrides stopped their signature at
`reply_to`, so any image delivered as a URL (the common case — image-gen
backends return URLs) raised:

    TypeError: send_image() got an unexpected keyword argument "metadata"

and the image silently failed to send. Their sibling overrides
(`send_image_file` / `send_video` / `send_voice` / `send_document`)
already absorb it via **kwargs, which is why only plain image-URL sends
broke.

- whatsapp/email `send_image`: accept `metadata` (matches the base
  signature); WhatsApp forwards it to the super() text fallback.
- Add `tests/gateway/test_media_metadata_contract.py`: asserts WhatsApp +
  email accept it, plus a best-effort sweep over every adapter so the next
  slip fails at test time instead of in production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:23:53 -07:00
Teknium 4858942c55 fix(auxiliary): honor main fallback chain for auto tasks (#47235) 2026-06-16 06:23:24 -07:00
Teknium 4d470b3dbb fix(slack): route /debug via /hermes to restore Telegram-parity (#47248)
Slack caps apps at 50 slash commands and the registry is at that ceiling, so
adding /debug clamped it out of the native list and broke the telegram-parity
test (debug on Telegram, absent from Slack native slashes, in neither
exclusion set). Add 'debug' to _SLACK_VIA_HERMES_ONLY — same treatment credits
already gets. /debug stays native on CLI/TUI/Telegram/Discord and reachable via
/hermes debug on Slack.
2026-06-16 06:20:01 -07:00
Teknium 2483200963 test(tui): isolate session-create no-race test from shard-sibling leakage (#47230)
test_session_create_no_race_keeps_worker_alive flaked on CI shard 3 with
'build thread unregistered its own notify despite no race' while passing
20/20 in isolation locally. Root cause: daemon build threads from sibling
session.create tests in the same shard process mutate the shared
server._sessions dict under _sessions_lock and can replace/pop entries
mid-run, flipping this build thread's 'replaced' check (server.py:1011) to
True and triggering a spurious unregister_gateway_notify.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(delegation): harden async background delegation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: rio-jeong <rio.jeong@thebytesize.ai>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-15 14:35:15 -04:00
Austin PickettandCursor 0bbf325a8f fix(dashboard): scope chat sidebar model card to selected profile (#46665)
* fix(dashboard): scope chat sidebar model card to selected profile

The PTY already honors ?profile= on profile switch, but the JSON-RPC
sidecar created sessions against the dashboard launch profile. Pass the
management profile through session.create and reconnect on switch.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dashboard): sync active profile with management scope

Align the sidebar switcher with the sticky active profile on load and
when "Set as active" is clicked, so Chat and management pages match
what the Profiles page shows as active.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dashboard): auto-reconnect chat sidebar on profile switch

Bump the sidecar connection version when profile or PTY channel changes,
matching the manual Reconnect path so gateway and events sockets come
back without clicking the error banner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dashboard): prevent model selector chevron overlapping label

Use inline flex layout instead of Button suffix, which is absolutely
positioned and overlapped truncated model names at px-0.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 12:50:19 -04:00
0bbff1fc7e fix(deps): declare websockets as core dep + relax dev setuptools pin (salvage #45486, #44693) (#46744)
* fix: declare websockets as a core dependency

* fix(deps): relax dev setuptools pin 82.0.1 -> 81.0.0 (torch caps setuptools<82)

torch >= 2.11 publishes Requires-Dist: setuptools<82, so any environment
that resolves the dev extra together with torch is unsatisfiable:

    $ uv pip install --dry-run ".[dev]" "torch==2.12.0"
    x No solution found when resolving dependencies:
      ... torch==2.12.0 and all versions of hermes-agent[dev] are incompatible.

81.0.0 is the latest release under the cap and stays inside the declared
build-system window (setuptools>=77.0,<83). uv.lock regenerated with
'uv lock'; diff is scoped to the setuptools entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: map salvaged contributor emails for attribution

Add AUTHOR_MAP entries for the two cherry-picked contributors so the
check-attribution CI gate passes:
- yehaotian@xuanshudeMac-mini.local -> ArcanePivot (#45486)
- dbeyer7@gmail.com -> benegessarit (#44693)

---------

Co-authored-by: 玄枢 <yehaotian@xuanshudeMac-mini.local>
Co-authored-by: David Beyer <dbeyer7@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-15 12:44:44 -04:00
ethernetandibrahim özsaraç ae433634db fix(desktop): move tsconfig to es2023
Co-authored-by: ibrahim özsaraç <160004724+iborazzi@users.noreply.github.com>
2026-06-15 12:07:17 -04:00
ethernet 9eb0bcd60f change(ci): rip out nix ci for now
to be re-added later when we have more stable ci flows
2026-06-15 12:06:54 -04:00
xxxigm 45e2f4fdcd nix: refresh npmDepsHash for the @assistant-ui/store pin
The store pin changed package-lock.json, so the workspace-wide
npmDepsHash in nix/lib.nix is stale and the Nix flake check fails on
the hash mismatch. Use the hash reported by the real fetchNpmDeps
build (the flake check's `got:`), which is authoritative — it differs
from prefetch-npm-deps' lockfile-contents hash, exactly the divergence
nix/lib.nix already documents.
2026-06-15 11:55:02 -04:00
xxxigm 30377e108c ci(desktop): build the renderer on PRs so vite breaks fail in CI
The desktop build break shipped because nothing in CI runs the
apps/desktop production build. typecheck only runs `tsc`, which does
not exercise Vite/Rolldown module resolution, so an unresolvable
package export (the @assistant-ui/tap "./react-shim" split) sailed
through green checks and only failed when users built from source on
install/update.

Add a desktop-build job that runs `npm run build` (tsc -b + vite build
+ assert-dist-built) for apps/desktop. This closes the gap so the same
class of break fails in CI instead of on every user's machine.
2026-06-15 11:55:02 -04:00
xxxigm f02484feba test(deps): guard @assistant-ui cluster on one tap version
Lockfile invariant that would have caught the desktop build break: the
single hoisted @assistant-ui/tap must satisfy every @assistant-ui/*
package's declared tap requirement (deps or non-optional peer). It is a
contract, not a snapshot -- no hardcoded versions -- so it stays green
across routine bumps but fails the moment the cluster splits its tap
requirement again.
2026-06-15 11:55:02 -04:00
xxxigm eae3836eb6 fix(desktop): pin @assistant-ui/store so the cluster shares one tap
The desktop app is built from source on every install/update
(install.ps1 -> npm ci/install -> tsc -b && vite build). The
@assistant-ui packages share an internal reactivity lib,
@assistant-ui/tap, and only interoperate when they all resolve the
SAME tap version.

@assistant-ui/react@0.12.28 and @assistant-ui/core pin tap@^0.5.x
(which exports only "." and "./react"), but the caret range
react -> store@^0.2.9 floated store up to 0.2.18, which bumped its
tap peer to ^0.9.0 and began importing "@assistant-ui/tap/react-shim"
-- an entry point that only exists in the tap 0.9.x line. With the
hoisted tap stuck on 0.5.x, vite build crashed:

    "./react-shim" is not exported ... from package @assistant-ui/tap

i.e. the opaque "apps/desktop build failed (exit 1)" everyone hit when
updating today.

Pin @assistant-ui/store via root overrides to 0.2.13 -- the last
release that targets tap@^0.5.x -- so react/core/store all agree on the
hoisted tap@0.5.14 again. Verified: tsc -b and vite build both pass.
2026-06-15 11:55:02 -04:00
3e7e9b24d4 fix: harden salvaged session and browser improvements
Polish salvaged contributor work before PR review:
- read browser inactivity timeout from config with documented fallback
- skip redundant v10 trigram backfill before v11 FTS rebuild
- show delegate_task goals safely in progress previews
- show gateway status model/context without redundant token wording
- wire gateway /sessions to shared session-listing helpers
- map Ravenwolf author emails for release attribution

Co-authored-by: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de>
Co-authored-by: Amy Ravenwolf <amy@ravenwolf.de>
2026-06-15 07:46:34 -07:00
Wolfram Ravenwolf ead38107a2 feat(status): restore model and context in gateway status
PROBLEM: The old public /status PR drifted out of the current Amy patch stack, leaving /status without the model/provider, context window, or explicit cumulative token label that Wolfram uses to monitor context pressure from chat.

SOLUTION: Re-port the feature onto the current gateway status handler. Prefer live/cached agent runtime metadata, fall back to SessionDB + SessionStore state between turns, add localized status model/context lines, and keep token totals explicitly labeled cumulative.

Verification: tests/gateway/test_status_command.py, tests/hermes_cli/test_commands.py
2026-06-15 07:46:34 -07:00
Amy Ravenwolf 5035fa9029 feat(display): show delegate_task goals in tool progress notifications
Previously, delegate_task in batch mode only showed '3 parallel tasks'
without revealing what the tasks actually are. Single-task mode showed
the goal via the primary_args fallback, but batch mode had no goal
extraction.

Changes:
- build_tool_preview(): Add dedicated delegate_task handler that
  extracts individual task goals from both single and batch modes.
  Batch shows '3 tasks: Goal A | Goal B | Goal C'.
- _get_cute_tool_message_impl(): Show individual goals in CLI cute
  messages for batch delegate calls ('3x: Goal A | Goal B').
- Add 4 tests covering single goal, batch goals, missing goals,
  and no-goal edge case.
2026-06-15 07:46:34 -07:00
Wolfram Ravenwolf 5b2604df99 fix(state): skip redundant trigram backfill before v11 FTS rebuild 2026-06-15 07:46:34 -07:00
Amy Ravenwolf 2f2e3616b4 fix(config): read browser inactivity timeout from config 2026-06-15 07:46:34 -07:00
xxxigm bee13817f0 test(desktop): cover $connection resync on profile switch
Asserts ensureGatewayProfile keeps $connection in lockstep with the active
profile's backend: activating a remote pool profile flips mode to remote,
returning to default resyncs to local, a failed descriptor fetch leaves the
prior connection intact, and a same-profile activation doesn't churn it.
Regression coverage for #46651.
2026-06-15 07:11:02 -07:00
xxxigm fbabf438a1 fix(desktop): sync $connection on profile switch so remote profiles attach images as bytes
The renderer's $connection seeds from the PRIMARY (window) backend at boot and
otherwise only refreshes on a sleep/wake reconnect. Activating a background
profile (ensureGatewayProfile) pointed the live gateway + REST at that profile's
backend but never updated $connection, so its `mode` stayed stuck on the
primary. With a local primary and a remote pool profile active, every code path
that branches on local-vs-remote misfired: image attachments went out via the
path-based `image.attach` instead of `image.attach_bytes`, handing the remote
gateway a client-only Windows path it can't resolve ("image not found: C:\..."),
and the /api/fs/* file browser and /api/media fetches targeted the wrong
machine.

Resync $connection from the now-active profile's descriptor right after the
gateway swap, so the remote-aware paths follow the live backend. Best-effort: a
failed descriptor fetch leaves the prior connection intact for boot/reconnect to
resync. Single-profile users are unaffected (the same-profile fast path never
runs the swap).

Fixes #46651
2026-06-15 07:11:02 -07:00
Tekniumandgoku94123 49e743985a fix: route minimax m3 reasoning controls through profile
Follow up PR #46609's api.minimax.io reasoning report by moving the behavior out of the broad run_agent host gate and into the MiniMax provider profile. Only MiniMax-M3 on the documented OpenAI-compatible /v1 route gets reasoning_split/thinking/reasoning_effort; Anthropic-format MiniMax and non-M3 models keep their existing wire shapes.

Co-authored-by: goku94123 <gooku94123@gmail.com>
2026-06-15 07:08:43 -07:00
goku94123 ba3883cd18 fix(minimax): enable reasoning extra_body for api.minimax.io 2026-06-15 07:08:43 -07:00
Teknium be7c919bf9 fix(process): label background completion causes (#46659)
Track why a background process finished and include that source in notify-on-complete messages so SIGTERM from process.kill, kill_all, backend loss, and ordinary exits are distinguishable.
2026-06-15 07:08:24 -07:00
Teknium 733472952a fix: complete cron jobs lock salvage
Route curator rollback through the same cross-process cron job lock, make save_jobs lock for legacy direct callers without deadlocking nested mutation paths, and harden the regression test so a second _jobs_lock caller really blocks across processes.
2026-06-15 06:29:00 -07:00
CiarasClawsandClaude Opus 4.8 e5b4cf7bea fix(cron): make jobs.json writes safe across processes
`hermes cron pause`/`resume`/`remove` run in their own CLI process (CLI →
cronjob tool → pause_job → update_job → save_jobs), entirely separate from
the gateway process that also writes jobs.json (mark_job_run, advance_next_run,
due-fast-forward in get_due_jobs). The only synchronization was a module-level
`threading.Lock`, which serializes writers *within a single process* but does
nothing across processes — and update_job/pause_job/remove_job/create_job did
not even take it.

The result is a classic lost update: a `cron pause` issued while the gateway is
live loads jobs.json, sets enabled=False, and saves; concurrently the gateway
loads the same file and saves back its run-bookkeeping, clobbering the pause.
The CLI prints "Paused" (it succeeded against its own in-memory copy) but the
job stays enabled and keeps firing, with no error surfaced. The scheduler's
`.tick.lock` flock can't be reused for this — it is held for the entire tick,
including multi-minute agent runs, so a CLI mutation would block for minutes.

Add `_jobs_lock()`: a short-held cross-process advisory file lock (fcntl/msvcrt
flock on `<hermes_home>/cron/.jobs.lock`) layered over the existing in-process
lock, and wrap every load→modify→save critical section with it — create_job,
update_job, remove_job, mark_job_run, advance_next_run, get_due_jobs,
rewrite_skill_refs. The lock degrades to in-process-only if neither fcntl nor
msvcrt is available, preserving prior behaviour. All critical sections are short
(field edits, no agent execution), so contention resolves in milliseconds.

Adds a regression test that proves the lock excludes a second process (an
in-process threading.Lock cannot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:29:00 -07:00
Teknium 29c6985590 fix(nix): refresh npm deps hash 2026-06-15 06:18:27 -07:00
FT_IOxCS 92a456f711 fix(cli,deps): clear esbuild audit loop
Upgrade the Vite/esbuild surfaces that kept web, ui-tui, and the bootstrap installer on vulnerable esbuild versions, regenerate the root lockfile, and preserve intentional package+lock dependency edits during update lockfile cleanup.
2026-06-15 06:18:27 -07:00
Teknium 975b9f0a54 docs: recommend standard installer for development (#46646) 2026-06-15 06:14:57 -07:00
Teknium 0d82060c74 fix: harden WhatsApp target alias salvage
Add a parser-only routing regression that proves raw WhatsApp group JIDs bypass channel-directory resolution and home-channel fallback, include channel_aliases.json in quick state snapshots, harden malformed alias handling, and map Keiron McCammon for release attribution.
2026-06-15 05:51:47 -07:00
Keiron McCammon ea49a79633 fix(messaging): route WhatsApp group JIDs to the target, not the home DM
send_message(target="whatsapp:<group-jid>") silently delivered to the
configured home DM instead of the requested group. Two gaps:

1. _parse_target_ref had no WhatsApp branch. Group JIDs (<id>@g.us),
   user JIDs (<id>@s.whatsapp.net), linked-identity JIDs (<id>@lid), and
   broadcast/newsletter JIDs matched no pattern and fell through to
   `return None, None, False`, so the caller treated them as
   unresolvable and used the home channel. The bridge's /send endpoint
   accepts any chatId, so only the tool-side target parsing was at fault.
   Add a whatsapp branch that recognizes native JIDs as explicit targets.
   The pre-existing '+'-prefixed E.164 path is preserved.

2. WhatsApp groups have no human-friendly name — the channel directory
   is regenerated from session data on a timer, so a group shows up as
   its raw 18-digit JID and any hand-edit to channel_directory.json is
   clobbered on the next rebuild. Add a user-maintained alias overlay
   (~/.hermes/channel_aliases.json) re-applied on every build AND every
   load, giving durable friendly names and letting a freshly-created
   group be pre-named before its first message.

Tests: TestParseTargetRefWhatsAppJID (7 cases) for the parser;
TestChannelAliases (7 cases) for the overlay, plus an autouse fixture
isolating CHANNEL_ALIASES_PATH so a real alias file can't leak into the
existing directory tests.
2026-06-15 05:51:47 -07:00
Teknium c17469cb19 chore: map Veritas-7 release attribution
Add the contributor noreply email used by the salvaged xAI OAuth refresh-skew commit so release notes credit the original author.
2026-06-15 05:40:23 -07:00
Veritas-7 febdddb41a fix(auth): refresh xAI OAuth tokens earlier 2026-06-15 05:40:23 -07:00
Teknium aab2e99bae test: cover request debug dump redaction
Keep request dump writes on the shared atomic JSON path, add regression coverage for request body/error/stdout redaction, and map the salvaged contributor email for release attribution.
2026-06-15 05:31:21 -07:00
xtymac ad58dd51ac redact secrets in API request debug dumps
dump_api_request_debug() masks the provider Authorization header but writes
the request `body` (system prompt, tool defs, context-embedded values) and the
error message raw via atomic_json_write. This path also fires unconditionally
on API errors (not only under HERMES_DUMP_REQUESTS), so any secret surfaced
into context (e.g. an integration token) lands in cleartext at
request_dump_*.json on every failed call.

Run the serialized dump through the existing redact_sensitive_text() scrubber
(already used for logs/tool output) before persisting and before the
HERMES_DUMP_REQUEST_STDOUT print; preserve atomicity via temp-file +
Path.replace. Also add the Notion internal-integration prefix (ntn_) to
_PREFIX_PATTERNS so bare values are caught.

Per SECURITY.md §3.2 this is a redaction (in-process heuristic) hardening, not
a §3.1 vulnerability. Refs #46583.
2026-06-15 05:31:21 -07:00
Teknium a688d2a1bd test: assert disk cleanup prunes protected walks 2026-06-15 05:25:27 -07:00
墨綠BG 40699c3292 🐛 fix(disk-cleanup): avoid brittle sweep review issues 2026-06-15 05:25:27 -07:00
墨綠BG c1a70a5439 🐛 fix(disk-cleanup): prune protected cleanup walks 2026-06-15 05:25:27 -07:00
liuhao1024 2cddc9c895 fix(bedrock): check boto3 version >= 1.34.59 before using converse_stream
converse() and converse_stream() were added in boto3 1.34.59. When Hermes
is installed editable into system Python (e.g. Ubuntu 24.04 ships 1.34.46),
the system boto3 takes precedence and calls to converse_stream fail with
AttributeError. Add an early version check in _require_boto3() that raises
a clear RuntimeError with upgrade instructions.
2026-06-15 05:25:17 -07:00
Teknium f79b109f4f chore: map 0xneobyte release author 2026-06-15 05:25:07 -07:00
Tharushka Dinujaya ec05d2bc3e fix(gateway): evict scoped lock when PID+start_time match but process is not a gateway
On Linux, systemd spawns core services (cron, nginx, sshd) with
deterministic PIDs and jiffy start_times across reboots. A service can
land on the exact same PID and start_time as a previous gateway, causing
acquire_scoped_lock to mistake it for a live gateway and block startup.

The existing stale-detection paths only covered:
  - start_times both non-None and different (clear mismatch)
  - start_times both None (macOS/Windows fallback to cmdline check)

The boot-time collision falls through both: times are non-None and
equal, so neither branch fired.

Add a third check: when both start_times are known and match but the
live process fails _looks_like_gateway_process, read its cmdline. If
the cmdline is readable (non-None), we have positive evidence of an
impostor and mark the lock stale. Requiring a readable cmdline keeps the
check conservative — if cmdline is unreadable we do not evict.
2026-06-15 05:25:07 -07:00
Nicolò Boschi a376ca0081 feat(hindsight): make observation scopes configurable on retain
Adds an observation_scopes config key (and HINDSIGHT_RETAIN_OBSERVATION_SCOPES
env var) so retained memories can opt into per_tag / all_combinations /
custom scoping instead of Hindsight's default combined pass.

Threaded through _build_retain_kwargs so all three retain paths honor it:
auto-retain and flush-on-switch already use aretain_batch; the tool retain
path is switched from aretain to aretain_batch (functionally equivalent,
aretain just wraps a single-item batch) since aretain doesn't accept the
observation_scopes parameter.
2026-06-15 04:59:17 -07:00
kshitij 8844e091c1 Merge pull request #46614 from kshitijk4poor/salvage/xai-oauth-profile-writethrough
fix(auth): resolve xAI OAuth credentials across profiles + write rotated tokens back to root
2026-06-15 17:16:19 +05:30
kshitijk4poor 1227007aed chore: map capt-marbles contributor email for attribution
Salvaged commit in this PR is authored by capt-marbles
(andrewdmwalker@gmail.com), a bare gmail that does not auto-resolve in
the check-attribution job. Add the AUTHOR_MAP entry.
2026-06-15 17:09:27 +05:30
kshitijk4poor 497352bc4e fix(auth): write rotated xAI OAuth tokens back to global root (#43589)
The salvaged read-side fix lets a profile resolve the xAI OAuth grant from
the global-root auth store when it has no own providers.xai-oauth block.
But _save_xai_oauth_tokens still wrote rotated tokens only to the active
profile store. Because xAI rotates the refresh_token on every refresh, a
profile that reads root's grant and refreshes it left root holding a now-
revoked refresh token — killing every other profile reading the stale root
grant with invalid_grant once its access token expired (#43589).

Detect the read-from-root case (profile lacks its own providers.xai-oauth
block) and, after the profile save, write the rotated chain back to the
global root too via a best-effort, TOCTOU-safe write-through that reuses
_save_auth_store with an explicit target path. A profile that genuinely
shadows root (has its own block) is left untouched, classic mode is a
no-op, and a failed root write never breaks the profile's own save.

Pairs with the read fallback in the preceding commit so the cross-profile
xAI grant stays coherent in both directions.
2026-06-15 17:08:19 +05:30
Andrew Walker f1d6f04362 fix(auth): resolve xAI OAuth credentials across profiles
(cherry picked from commit 8d8b9f50e4)
2026-06-15 17:03:35 +05:30
helix4u dcc3216955 fix(mcp): fail fast for noninteractive oauth without tokens 2026-06-15 04:22:07 -07:00
aca11c227e fix(docker): skip gateway reconciliation in dashboard container (autodetect) (#46293)
* fix(docker): skip per-profile gateway reconciliation in dashboard container

When gateway and dashboard containers share a bind-mounted HERMES_HOME,
both run the cont-init.d profile reconciliation script, which creates
s6-log processes for every persisted profile.  These s6-log processes
in different containers race to flock() the same log-directory lock
files under logs/gateways/<profile>/lock, producing repeated
"s6-log: fatal: unable to lock ... Resource busy" errors and a
supervision restart storm.

Add HERMES_SKIP_PROFILE_RECONCILE env var support to container_boot.py
and set it in the official docker-compose.yml dashboard service so the
dashboard container no longer creates per-profile gateway s6 services
it never uses.

* chore(release): map salvaged contributor

* refactor(docker): autodetect dashboard container instead of env-var gate

Replace the HERMES_SKIP_PROFILE_RECONCILE env var with PID 1 argv role
detection. A dashboard-only container never spawns or supervises
per-profile gateways, so the reconcile boot hook now skips itself when
/proc/1/cmdline is the dashboard command — no operator flag to set (or
forget in a hand-written manifest, which would reintroduce the s6-log
flock storm this prevents).

- Extract _strip_container_argv_prefix() shared by the legacy-gateway
  and new dashboard detectors (DRY the init/wrapper/hermes peel).
- Add _is_dashboard_container(); gate reconcile main() on it.
- Drop HERMES_SKIP_PROFILE_RECONCILE from code + docker-compose.yml.
- Tests: argv matrix for both roles + main()-level skip/reconcile proof
  and a regression that the removed env var is now inert.

Co-authored-by: 895252509 <895252509@qq.com>

---------

Co-authored-by: zhouxiang <895252509@qq.com>
Co-authored-by: Ben <ben@nousresearch.com>
2026-06-15 20:51:48 +10:00
kshitij 6cb88a0874 Merge pull request #46552 from kshitijk4poor/salvage/file-tools-session-cwd
fix(tools): respect session cwd in file tools (salvage of #46460)
2026-06-15 14:13:15 +05:30
kshitijk4poor 8fce54499f refactor(tools): extract shared sentinel-free abs cwd validator
_configured_terminal_cwd and _registered_task_cwd_override carried a
byte-identical sentinel + expanduser + isabs validation tail. Extract it
into _sentinel_free_abs_cwd(raw) so the relative/sentinel rejection rule
lives in one place. Behaviour unchanged (the str() coercion the override
path relied on is preserved in the helper).
2026-06-15 14:03:41 +05:30
kshitijk4poor b0c99c12dd docs(tools): document registered-cwd step in resolver docstrings
The session-cwd fix inserted a registered task/session cwd override step
between the live-cwd and $TERMINAL_CWD fallbacks, but three docstrings still
described the old two-step order — _resolve_base_dir's numbered list was
outright wrong. Update _authoritative_workspace_root, _resolve_base_dir, and
_path_resolution_warning to reflect the actual four-step resolution order.
No behaviour change.
2026-06-15 14:02:54 +05:30
kshitijk4poor ddf7c7af81 refactor(tools): consolidate task-override lookup into one helper
The raw-key-first-then-collapsed override lookup was hand-rolled in three
places with subtly different spellings: terminal_tool's command setup, and
both file_tools._registered_task_cwd_override and _get_file_ops. Since that
exact raw-vs-collapsed invariant is what the session-cwd fix depends on,
keeping three copies invites the drift that caused the original bug.

Add terminal_tool.resolve_task_overrides(task_id) as the single source and
route all three sites through it. Behaviour is unchanged (verified
byte-equivalent across raw/collapsed/isolation/None/subagent inputs).
2026-06-15 14:02:17 +05:30
Gille d6a8d9dcab fix(tools): respect session cwd in file tools 2026-06-15 14:00:42 +05:30
Ben Barclay 95715dcb03 fix(s6): reserved default gateway must not follow sticky active_profile (#46483)
The supervised `gateway-default` s6 slot runs bare `hermes gateway run`
(no -p) to mean "the root HERMES_HOME profile". But `_apply_profile_override`
falls through its #22502 HERMES_HOME guard for the container root
(/opt/data, whose parent is not `profiles`) and reads the sticky
`active_profile` file. If the user set another profile active (e.g. via
the dashboard), the reserved default gateway gets redirected into that
profile — producing a duplicate gateway for the active profile and no
real default gateway. The profile page and `gateway status` then
correctly report default as "not running" because there genuinely isn't
one.

Guard step 2 (the sticky active_profile fallback) with the existing
HERMES_S6_SUPERVISED_CHILD sentinel that the container run-script already
exports. Supervised named-profile slots pass -p explicitly (step 1, never
reaches step 2); only the bare default slot was affected. Inert outside
the s6 container — the sentinel is never set elsewhere.

Reported in the 'Docker & Profiles & Dashboard' support thread.
2026-06-15 05:36:20 +00:00
Ben Barclay 80f8ffc74c fix(dashboard): pin machine-dashboard reroute to the machine root, not $HOME/.hermes (#46487)
The unified machine-dashboard reroute (cmd_dashboard) re-execs a named-profile
dashboard launch as the machine dashboard and dropped HERMES_HOME from the
child env with the comment "so the child binds the machine root". That holds
for a standard install (root == ~/.hermes) but breaks the Docker layout: the
published image sets `ENV HERMES_HOME=/opt/data`, so once HERMES_HOME is unset
the child falls back to $HOME/.hermes = /opt/data/.hermes — an empty,
auto-seeded home.

Two user-visible symptoms, one root cause (reported via support):

1. Dashboard Profiles page shows only an empty `default` — the real
   default/oracle/saga profiles live under /opt/data/profiles, but the
   rerouted child resolves _get_profiles_root() to /opt/data/.hermes/profiles.

2. The "Update Hermes" button runs `hermes update` inside the container
   repeatedly instead of bailing with the docker-update guidance. The Docker
   guard keys off detect_install_method(), which reads
   $HERMES_HOME/.install_method; the image stamps that at /opt/data, but the
   misresolved home has no stamp, no HERMES_MANAGED, and no .git → falls
   through to "pip", so the guard never fires.

The reporter's workaround was to bind-mount the host dir at both /opt/data and
/opt/data/.hermes so the two paths converge (at the cost of a self-referential
recursion).

Fix: resolve the machine root explicitly with get_default_hermes_root() and set
it on the child env instead of popping HERMES_HOME. That helper returns the
root for both layouts — ~/.hermes for a standard install, and /opt/data for
Docker (it strips a trailing profiles/<name>). Falls back to the old pop
behaviour only if root resolution raises, so the reroute is never blocked.

Regression tests in test_dashboard_unified_launch.py: the existing standard-
install test now asserts the child carries HERMES_HOME == get_default_hermes_root()
(not absent), and a new test_reexec_pins_docker_machine_root covers the Docker
layout (HERMES_HOME=/opt/data/profiles/oracle → child gets /opt/data). Both
fail against the pre-fix pop behaviour (mutation-verified).
2026-06-15 15:33:15 +10:00
c2b7669ad3 fix(s6): clear stale log lock before startup (#46289)
* fix(cli): clear stale s6-log lock file before startup on virtiofs

* chore(release): map salvaged contributor

---------

Co-authored-by: zxcasongs <35259607+zxcasongs@users.noreply.github.com>
Co-authored-by: Ben <ben@nousresearch.com>
2026-06-15 14:10:51 +10:00
b770967263 fix(s6): persist profile gateway desired state (#46292)
* fix: persist s6 gateway desired state

* chore(release): map salvaged contributor

---------

Co-authored-by: Alfred Smith <alfred@my-cloud.me>
Co-authored-by: Ben <ben@nousresearch.com>
2026-06-15 14:02:10 +10:00
Tekniumandtangtaizhong666 61ee2dbfdb fix(s6): make profile gateway log parent writable (#46291)
* fix(gateway): chown logs/gateways parent so late-added profiles can log

The per-profile log service script created $HERMES_HOME/logs/gateways/
via 'mkdir -p' but only chowned the leaf logs/gateways/<profile>. When
the first log service boots in root context, the gateways/ parent stays
root:root; every profile registered later runs its log service as the
dropped hermes user, 'mkdir -p' fails with EACCES, and s6-log enters a
sub-second fatal crash-loop flooding the container log. The stage2
recursive heal does not catch it either: it is gated on needs_chown,
which is false when the top-level $HERMES_HOME is already hermes-owned.

Two complementary fixes:

- service_manager._render_log_run: chown the gateways/ parent
  (non-recursively) before the leaf chown. Runs on every root-context
  boot, so it also heals volumes already poisoned by older images.
- docker/stage2-hook.sh: seed logs/gateways in the as_hermes mkdir -p
  block; cont-init runs before any service starts, so the parent
  already exists hermes-owned when the first log/run does 'mkdir -p'.

The needs_chown repair loop needs no twin entry: it already chowns
logs/ recursively, which covers logs/gateways.

Fixes #45258

* chore(release): map salvaged contributor

---------

Co-authored-by: tangtaizhong666 <tangtaizhong792@gmail.com>
2026-06-15 13:47:05 +10:00
leo4226andClaude Sonnet 4.6 f795513782 fix(windows): kill hermes before recreating venv to release _bcrypt.pyd lock (#45120)
On Windows, native Python extensions such as _bcrypt.pyd are loaded as
DLLs by any running hermes process. When the installer tries to recreate
the venv (Remove-Item -Recurse -Force "venv"), Windows denies the delete
because the DLL is still mapped into the running process.

Add a taskkill /F /T /IM hermes.exe call before the Remove-Item so any
hermes process tree is stopped first, releasing the file lock. A short
sleep gives the OS time to unload the image before deletion proceeds.

This mirrors the existing force_kill_other_hermes() guard already present
in the --update flow (update.rs), applying the same pattern to the full
reinstall/repair path through install.ps1.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 02:27:00 +00:00
8fe334b056 fix(desktop): inset hover-reveal trigger past the adjacent scrollbar (#44159)
The collapsed-pane hover-reveal trigger strip (14px wide, 6px edge
gutter) overlapped the neighboring scroller's 8px .scrollbar-dt
scrollbar, which sits flush with the window edge when the rail panes
are collapsed. Hovering the scrollbar revealed the file browser over
it, and clicks on the overlapped band hit the trigger instead of the
scrollbar thumb.

Widen the edge gutter to calc(0.5rem + 2px) so the strip clears the
scrollbar (rem-coupled to the .scrollbar-dt width) while still
covering the OS window-resize grab area inset.

Part of #44140 (item 2).

Co-authored-by: AIalliAI <285906080+AIalliAI@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 21:23:38 -05:00
Tekniumandliuhao1024 40d7c264f0 fix(s6): register profile gateways without auto-starting (#46266)
* fix(s6): prevent profile create from auto-starting gateway service

When hermes profile create runs inside an s6 container,
_maybe_register_gateway_service() calls register_profile_gateway()
which creates the service directory and triggers s6-svscanctl -a.
Previously the service always started immediately, causing profiles
that share the main gateway's bot token (e.g. Kanban worker profiles)
to fail with a token-lock conflict and persist gateway_state: running
— becoming zombies that resurrect on every container restart.

Wire the existing start_now parameter through the S6 implementation:
when start_now=False, write a  marker file (same pattern as
container_boot.py _register_gateway_slot) so s6-supervise leaves the
service stopped until the user explicitly runs hermes -p <profile>
gateway start.

4 files, +61/-6, 4 new tests (all passing).

* test(docker): wait for gateway running state before restart

---------

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-15 11:43:23 +10:00
TekniumandIAvecilla 4eb0ff639b Remove is_container check when restarting over dashboard (#46290)
Co-authored-by: IAvecilla <ignacio.avecilla@lambdaclass.com>
2026-06-15 11:09:23 +10:00
Teknium f3fe99863d revert(web): remove keyless Parallel search fallback (#46350)
Remove the free Parallel Search MCP path and restore the keyed Parallel backend behavior from before it was introduced.

Also drops the keyless fallback registration/display labeling tests and returns the Parallel SDK pin to the prior version.
2026-06-14 16:47:57 -07:00
Teknium a829e04d62 fix: migrate cloned profile configs (#46345) 2026-06-14 16:30:23 -07:00
Teknium 2a14e8957d fix(kimi): surface K2.7 Code in native picker (#46309) 2026-06-14 14:01:03 -07:00
mr-r0b0t bff78a34dc feat(zai): add GLM-5.2 with verified 1M context window
GLM-5.2 ships with a 1M (1,048,576) token context window. Without this
entry, Hermes falls through to the generic 'glm' key (202,752 tokens),
under-reporting the context bar and prematurely compressing conversations.

The 1M limit was verified empirically via needle-in-a-haystack retrieval
at 789,240 prompt tokens on api.z.ai/api/coding/paas/v4 — zero errors,
zero truncation, correct retrieval at every tested size (25K through 789K).

Changes:
- agent/model_metadata.py: add 'glm-5.2': 1_048_576 before 'glm' fallback
- hermes_cli/models.py: add glm-5.2 to zai curated models
- hermes_cli/setup.py: add glm-5.2 to setup wizard zai list
- hermes_cli/auth.py: add glm-5.2 to coding plan endpoint probes
- plugins/model-providers/zai/__init__.py: add glm-5.2 to fallback_models
- tests/agent/test_model_metadata.py: context resolution + vendor-prefix tests
2026-06-14 13:50:36 -07:00
xxxigm 1db8f7ea80 fix(install): repair existing managed-Node global prefix on re-run
The initial fix only wrote the prefix npmrc on a fresh Node install, so
pre-existing bundled-Node installs (Node already present) were not repaired
by re-running the installer — install_node/ensure_node skip when Node is
already up to date.

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

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

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

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

Resolver still reads pinPeerName as a back-compat alias; that's deferred.
2026-06-10 16:07:53 -04:00
275 changed files with 15104 additions and 9890 deletions
+2 -3
View File
@@ -1,12 +1,11 @@
name: Contributor Attribution Check
on:
pull_request:
branches: [main]
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
permissions:
contents: read
+4 -5
View File
@@ -18,13 +18,12 @@ on:
- docker/**
- .hadolint.yaml
- .github/workflows/docker-lint.yml
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- Dockerfile
- docker/**
- .hadolint.yaml
- .github/workflows/docker-lint.yml
permissions:
contents: read
+5 -8
View File
@@ -11,16 +11,13 @@ on:
- 'docker/**'
- '.github/workflows/docker-publish.yml'
- '.github/actions/hermes-smoke-test/**'
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- '**/*.py'
- 'pyproject.toml'
- 'uv.lock'
- 'Dockerfile'
- 'docker/**'
- '.github/workflows/docker-publish.yml'
- '.github/actions/hermes-smoke-test/**'
release:
types: [published]
+9 -7
View File
@@ -1,10 +1,12 @@
name: Docs Site Checks
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
paths:
- 'website/**'
- '.github/workflows/docs-site-checks.yml'
branches: [main]
workflow_dispatch:
permissions:
@@ -14,9 +16,9 @@ jobs:
docs-site-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
@@ -26,9 +28,9 @@ jobs:
run: npm ci
working-directory: website
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
python-version: "3.11"
- name: Install ascii-guard
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
+5 -2
View File
@@ -14,6 +14,9 @@ name: History Check
# the PR head and main to be non-empty.
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
@@ -24,9 +27,9 @@ jobs:
check-common-ancestor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # full history both sides for merge-base
fetch-depth: 0 # full history both sides for merge-base
- name: Reject PRs with no common ancestor on main
run: |
+4 -5
View File
@@ -15,12 +15,12 @@ on:
- "**/*.md"
- "docs/**"
- "website/**"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
- "website/**"
permissions:
contents: read
@@ -154,7 +154,6 @@ jobs:
});
}
ruff-blocking:
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
-255
View File
@@ -1,255 +0,0 @@
name: Nix Lockfile Fix
on:
push:
branches: [main]
paths:
- 'package-lock.json'
- 'package.json'
- 'ui-tui/package.json'
- 'apps/desktop/package.json'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to fix (leave empty to run on the selected branch)'
required: false
type: string
issue_comment:
types: [edited]
permissions:
contents: write
pull-requests: write
concurrency:
group: nix-lockfile-fix-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }}
cancel-in-progress: false
jobs:
# ── Auto-fix on main ───────────────────────────────────────────────
# Fires when a push to main touches package.json or package-lock.json.
# Runs fix-lockfiles and pushes the hash update commit directly to main
# so Nix builds never stay broken.
#
# Safety invariants:
# 1. The fix commit only touches nix/*.nix files, which are NOT in
# the paths filter above, so this cannot re-trigger itself.
# 2. An explicit file-whitelist check before commit aborts if
# fix-lockfiles ever modifies unexpected files.
# 3. Job-level concurrency with cancel-in-progress: true ensures
# back-to-back pushes collapse to the newest; ref: main checkout
# always operates on the latest branch state.
# 4. Uses a GitHub App token (not GITHUB_TOKEN) so the fix commit
# triggers downstream nix.yml verification.
auto-fix-main:
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 25
concurrency:
group: auto-fix-main
cancel-in-progress: true
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@7bfa3a4717ef143a604ee0a99d859b8886a96d00 # v1.9.3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
token: ${{ steps.app-token.outputs.token }}
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Apply lockfile hashes
id: apply
run: nix run .#fix-lockfiles -- --apply
- name: Commit & push
if: steps.apply.outputs.changed == 'true'
shell: bash
run: |
set -euo pipefail
# Ensure only nix/lib.nix (home of the single npmDepsHash) was
# modified — prevents accidental self-triggering if fix-lockfiles
# ever touches package files.
unexpected="$(git diff --name-only | grep -Ev '^nix/lib\.nix$' || true)"
if [ -n "$unexpected" ]; then
echo "::error::Unexpected modified files: $unexpected"
exit 1
fi
# Record the base SHA before committing — used to detect package
# file changes if we need to rebase after a non-fast-forward push.
BASE_SHA="$(git rev-parse HEAD)"
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add nix/lib.nix
git commit -m "fix(nix): auto-refresh npm lockfile hashes" \
-m "Source: $GITHUB_SHA" \
-m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
# Retry push with rebase in case main advanced with an unrelated
# commit during the nix build. Without this, a non-fast-forward
# rejection silently loses the fix. If package files changed during
# the rebase, abort — a fresh auto-fix run will handle the new state.
for attempt in 1 2 3; do
if git push origin HEAD:main; then
exit 0
fi
echo "::warning::Push attempt $attempt failed (non-fast-forward?), rebasing…"
git fetch origin main
# If package files changed between our base and the new main,
# our computed hashes are stale. Abort and let the next triggered
# run recompute from the correct package-lock state.
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
'package-lock.json' 'package.json' \
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
if [ -n "$pkg_changed" ]; then
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
exit 0
fi
git rebase origin/main
done
echo "::error::Failed to push after 3 rebase attempts"
exit 1
# ── PR fix (manual / checkbox) ─────────────────────────────────────
# Existing behavior: run on manual dispatch OR when a task-list
# checkbox in the sticky lockfile-check comment flips from [ ] to [x].
fix:
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issue_comment'
&& github.event.issue.pull_request != null
&& contains(github.event.comment.body, '[x] **Apply lockfile fix**')
&& !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**'))
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Authorize & resolve PR
id: resolve
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
// 1. Verify the actor has write access — applies to both checkbox
// clicks and manual dispatch.
const { data: perm } =
await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.actor,
});
if (!['admin', 'write', 'maintain'].includes(perm.permission)) {
core.setFailed(
`${context.actor} lacks write access (has: ${perm.permission})`
);
return;
}
// 2. Resolve which ref to check out.
let prNumber = '';
if (context.eventName === 'issue_comment') {
prNumber = String(context.payload.issue.number);
} else if (context.eventName === 'workflow_dispatch') {
prNumber = context.payload.inputs.pr_number || '';
}
if (!prNumber) {
core.setOutput('ref', context.ref.replace(/^refs\/heads\//, ''));
core.setOutput('repo', context.repo.repo);
core.setOutput('owner', context.repo.owner);
core.setOutput('pr', '');
return;
}
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(prNumber),
});
core.setOutput('ref', pr.head.ref);
core.setOutput('repo', pr.head.repo.name);
core.setOutput('owner', pr.head.repo.owner.login);
core.setOutput('pr', String(pr.number));
# Wipe the sticky lockfile-check comment to a "running" state as soon
# as the job is authorized, so the user sees their click was picked up
# before the ~minute of nix build work.
- name: Mark sticky as running
if: steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### 🔄 Applying lockfile fix…
Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }}
ref: ${{ steps.resolve.outputs.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Apply lockfile hashes
id: apply
run: nix run .#fix-lockfiles
- name: Commit & push
if: steps.apply.outputs.changed == 'true'
shell: bash
run: |
set -euo pipefail
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add nix/lib.nix
git commit -m "fix(nix): refresh npm lockfile hashes"
git push
- name: Update sticky (applied)
if: steps.apply.outputs.changed == 'true' && steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### ✅ Lockfile fix applied
Pushed a commit refreshing the npm lockfile hashes — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
- name: Update sticky (already current)
if: steps.apply.outputs.changed == 'false' && steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### ✅ Lockfile hashes already current
Nothing to commit — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
- name: Update sticky (failed)
if: failure() && steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### ❌ Lockfile fix failed
See the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for logs.
-105
View File
@@ -1,105 +0,0 @@
name: Nix
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
pull-requests: write
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Resolve head SHA
if: github.event_name == 'pull_request'
id: sha
shell: bash
run: |
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
echo "full=$FULL" >> "$GITHUB_OUTPUT"
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
- name: Check flake
id: flake
continue-on-error: true
run: nix flake check --print-build-logs
# When the flake check fails, run a targeted diagnostic to see if
# the failure is specifically a stale npm lockfile hash in one of the
# known npm subpackages (tui / web). This avoids surfacing a generic
# "build failed" message when the fix is a single known command.
- name: Diagnose npm lockfile hashes
id: hash_check
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
continue-on-error: true
env:
LINK_SHA: ${{ steps.sha.outputs.full }}
run: nix run .#fix-lockfiles -- --check
# If fix-lockfiles itself crashes (infrastructure blip, cache throttle,
# etc.) it won't set stale=true/false. Treat that as a distinct failure
# mode rather than silently ignoring it.
- name: Fail if hash check crashed without reporting
if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false'
run: |
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
exit 1
- name: Post sticky PR comment (stale hashes)
if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
message: |
### ⚠️ npm lockfile hash out of date
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
${{ steps.hash_check.outputs.report }}
#### Apply the fix
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
- Or locally: `nix run .#fix-lockfiles` and commit the diff
# Clear the sticky comment when either the flake check passed outright (no
# hash check needed) or the hash check explicitly returned stale=false
# (check failed for a non-hash reason).
- name: Clear sticky PR comment (resolved)
if: |
github.event_name == 'pull_request' &&
(steps.hash_check.outputs.stale == 'false' ||
steps.flake.outcome == 'success')
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
delete: true
- name: Final fail if flake check failed
if: steps.flake.outcome == 'failure'
run: |
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
else
echo "::error::Nix flake check failed. See logs above."
fi
exit 1
+10 -16
View File
@@ -20,29 +20,23 @@ name: OSV-Scanner
# vulnerabilities in pinned deps that we may need to patch deliberately.
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- 'uv.lock'
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'ui-tui/package.json'
- 'website/package.json'
- 'website/package-lock.json'
- '.github/workflows/osv-scanner.yml'
push:
branches: [main]
paths:
- 'uv.lock'
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'website/package-lock.json'
- "uv.lock"
- "pyproject.toml"
- "package.json"
- "package-lock.json"
- "website/package-lock.json"
schedule:
# Weekly scan against main — catches CVEs published after merge for
# deps that haven't changed since.
- cron: '0 9 * * 1'
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
@@ -54,7 +48,7 @@ permissions:
jobs:
scan:
name: Scan lockfiles
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
# Scan explicit lockfiles rather than recursing, so we only look at
# the three sources of truth and skip vendored / test / worktree dirs.
+6 -6
View File
@@ -1,11 +1,11 @@
name: Supply Chain Audit
on:
pull_request:
types: [opened, synchronize, reopened]
# No paths filter — the jobs must always run so required checks
# report a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
@@ -32,7 +32,7 @@ jobs:
# True when the curated MCP catalog / bundled MCP manifests changed.
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Check for relevant file changes
@@ -72,7 +72,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -207,7 +207,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -286,7 +286,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
+4 -4
View File
@@ -6,11 +6,11 @@ on:
paths-ignore:
- "**/*.md"
- "docs/**"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
permissions:
contents: read
@@ -219,4 +219,4 @@ jobs:
env:
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
NOUS_API_KEY: ""
+20
View File
@@ -4,6 +4,9 @@ name: Typecheck
on:
push:
branches: [main]
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
@@ -23,3 +26,20 @@ jobs:
cache: npm
- run: npm ci
- run: npm run --prefix ${{ matrix.package }} typecheck
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
# which does NOT exercise Vite/Rolldown module resolution — so an
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
# longer exports "./react-shim") slips past typecheck and only explodes when
# users build apps/desktop from source on install/update. Run the real
# `vite build` here so that class of break fails in CI instead.
desktop-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run --prefix apps/desktop build
+9 -9
View File
@@ -47,15 +47,15 @@ on:
push:
branches: [main]
paths:
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/uv-lockfile-check.yml'
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/uv-lockfile-check.yml"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/uv-lockfile-check.yml'
permissions:
contents: read
@@ -71,10 +71,10 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
# `uv lock --check` re-resolves the project from pyproject.toml and
# compares the result to uv.lock, exiting non-zero if they disagree.
+44 -6
View File
@@ -78,7 +78,41 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
### Clone and install
### Install with the standard installer
For most contributors, the best development bootstrap is the same path users
take: run the standard installer, then work inside the repository it cloned.
The installer creates the Hermes venv, wires the `hermes` command, stamps the
install method for `hermes update`, and clones the full git project into
`$HERMES_HOME/hermes-agent` (usually `~/.hermes/hermes-agent`). That keeps your
development environment on the same layout the CLI, updater, lazy dependency
installer, gateway, and docs assume.
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
# Add dev/test extras on top of the standard install.
uv pip install -e ".[all,dev]"
# Optional: browser tools / docs site dependencies.
npm install
```
After that, create branches and run tests from that checkout:
```bash
git checkout -b fix/description
scripts/run_tests.sh
```
### Manual clone fallback
Use this only if you intentionally do not want Hermes' managed install layout
(for example, a throwaway clone inside a container or CI job). If you install
this way, make sure you run the `hermes` entrypoint from this venv; running the
system `python3 -m hermes_cli.main` can pick up unrelated system Python
packages.
```bash
git clone https://github.com/NousResearch/hermes-agent.git
@@ -109,15 +143,19 @@ echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
### Run
```bash
# Symlink for global access
mkdir -p ~/.local/bin
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
# Verify
# The standard installer already put `hermes` on PATH.
hermes doctor
hermes chat -q "Hello"
```
If you used the manual clone fallback, run `./hermes` from the checkout or
symlink this clone's venv explicitly:
```bash
mkdir -p ~/.local/bin
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
```
### Run tests
```bash
+10 -6
View File
@@ -181,16 +181,20 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration
We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process.
Quick start for contributors — clone and go with `setup-hermes.sh`:
Quick start for contributors — use the standard installer, then work from the
full git checkout it creates at `$HERMES_HOME/hermes-agent` (usually
`~/.hermes/hermes-agent`). This matches the layout used by `hermes update`, the
managed venv, lazy dependencies, gateway, and docs tooling.
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes
./hermes # auto-detects the venv, no need to `source` first
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
uv pip install -e ".[all,dev]"
scripts/run_tests.sh
```
Manual path (equivalent to the above):
Manual clone fallback (for throwaway clones/CI where you intentionally do not
want the managed install layout):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
+8 -6
View File
@@ -164,16 +164,18 @@ hermes claw migrate --overwrite # 覆盖已有冲突
欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。
贡献者快速开始——克隆并使用 `setup-hermes.sh`
贡献者快速开始——使用标准安装器,然后在它创建的完整 git checkout 中开发
`$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这会匹配
`hermes update`、托管 venv、lazy dependencies、gateway 和 docs tooling 使用的布局。
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes
./hermes # 自动检测 venv,无需先 source
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
uv pip install -e ".[all,dev]"
scripts/run_tests.sh
```
手动安装(等效于上述命令):
手动克隆备用路径(用于一次性 clone / CI,或你明确不想使用 managed install layout 时):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
+1
View File
@@ -299,6 +299,7 @@ def init_agent(
# would mangle the escape sequences. None = use builtins.print.
agent._print_fn = None
agent.background_review_callback = None # Optional sync callback for gateway delivery
agent.memory_notifications = "on" # Memory update notifications: "off", "on", "verbose"
agent.skip_context_files = skip_context_files
agent.load_soul_identity = load_soul_identity
agent.pass_session_id = pass_session_id
+13 -2
View File
@@ -1217,12 +1217,23 @@ def dump_api_request_debug(
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
atomic_json_write(dump_file, dump_payload, default=str)
# Redact secrets before persisting/printing. This dump captures the
# full request body (system prompt, tool defs, context-embedded
# values), and this path fires unconditionally on API errors — so it
# otherwise lands any context-embedded secret in cleartext on disk.
# Run the serialized dump through the same scrubber used for logs/tool
# output, then hand the resulting payload back to the shared atomic
# JSON writer so request dumps keep the same write semantics as before.
from agent.redact import redact_sensitive_text
_serialized = json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)
_redacted_payload = json.loads(redact_sensitive_text(_serialized, force=True))
atomic_json_write(dump_file, _redacted_payload, default=str)
agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")
if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"):
print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))
print(json.dumps(_redacted_payload, ensure_ascii=False, indent=2, default=str))
return dump_file
except Exception as dump_error:
+159 -27
View File
@@ -3079,23 +3079,20 @@ def _try_configured_fallback_chain(
if not fb_provider or fb_provider.lower() == skip:
continue
fb_model = str(entry.get("model", "")).strip() or None
fb_base_url = str(entry.get("base_url", "")).strip() or None
fb_api_key = str(entry.get("api_key", "")).strip() or None
label = f"fallback_chain[{i}]({fb_provider})"
try:
fb_client = _resolve_single_provider(
fb_provider, fb_model, fb_base_url, fb_api_key)
fb_client, resolved_model = _resolve_fallback_entry(entry)
except Exception:
fb_client = None
fb_client, resolved_model = None, None
if fb_client is not None:
logger.info(
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
task, reason, failed_provider, label, fb_model or "default",
task, reason, failed_provider, label, resolved_model or fb_model or "default",
)
return fb_client, fb_model, label
return fb_client, resolved_model or fb_model, label
tried.append(label)
if tried:
@@ -3106,6 +3103,103 @@ def _try_configured_fallback_chain(
return None, None, ""
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
"""Resolve inline or env-backed API key from a fallback-chain entry."""
explicit = str(entry.get("api_key") or "").strip()
if explicit:
return explicit
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
if key_env:
return os.getenv(key_env, "").strip() or None
return None
def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
"""Resolve one fallback entry through the central provider router."""
provider = str(entry.get("provider") or "").strip()
model = str(entry.get("model") or "").strip() or None
if not provider or not model:
return None, None
base_url = str(entry.get("base_url") or "").strip() or None
api_key = _fallback_entry_api_key(entry)
api_mode = str(entry.get("api_mode") or entry.get("transport") or "").strip() or None
return resolve_provider_client(
provider,
model=model,
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
)
def _try_main_fallback_chain(
task: Optional[str],
failed_provider: str = "",
reason: str = "error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try the top-level main-agent fallback chain for an auxiliary call.
``provider: auto`` auxiliary tasks should respect the user's declared
main fallback policy before dropping into Hermes' built-in discovery
chain. The top-level chain is read through ``get_fallback_chain`` so
both modern ``fallback_providers`` and legacy ``fallback_model`` entries
participate in the same order as the main agent.
"""
try:
from hermes_cli.config import load_config
from hermes_cli.fallback_config import get_fallback_chain
chain = get_fallback_chain(load_config())
except Exception as exc:
logger.debug("Auxiliary %s: could not load main fallback chain: %s", task or "call", exc)
return None, None, ""
if not chain:
return None, None, ""
failed_norm = (failed_provider or "").strip().lower()
main_norm = (_read_main_provider() or "").strip().lower()
skip = {p for p in (failed_norm, main_norm, "auto") if p}
tried: List[str] = []
for i, entry in enumerate(chain):
if not isinstance(entry, dict):
continue
fb_provider = str(entry.get("provider") or "").strip()
fb_model = str(entry.get("model") or "").strip()
if not fb_provider or not fb_model:
continue
fb_norm = fb_provider.lower()
label = f"fallback_providers[{i}]({fb_provider})"
if fb_norm in skip:
tried.append(f"{label} (skipped)")
continue
if _is_provider_unhealthy(fb_norm):
_log_skip_unhealthy(fb_norm, task)
tried.append(f"{label} (unhealthy)")
continue
try:
fb_client, resolved_model = _resolve_fallback_entry(entry)
except Exception as exc:
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
fb_client, resolved_model = None, None
if fb_client is not None:
logger.info(
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
task or "call", reason, failed_provider or "auto", label,
resolved_model or fb_model,
)
return fb_client, resolved_model or fb_model, fb_provider
tried.append(label)
if tried:
logger.debug(
"Auxiliary %s: main fallback chain exhausted (tried: %s)",
task or "call", ", ".join(tried),
)
return None, None, ""
def _resolve_single_provider(
provider: str,
model: Optional[str] = None,
@@ -3116,16 +3210,19 @@ def _resolve_single_provider(
Uses the existing provider resolution infrastructure where possible.
"""
# Reuse resolve_provider_client which handles provider→client mapping
# Reuse resolve_provider_client which handles provider→client mapping.
client, resolved_model = resolve_provider_client(
provider=provider,
model=model,
base_url=base_url,
api_key=api_key,
explicit_base_url=base_url,
explicit_api_key=api_key,
)
return client
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
def _resolve_auto(
main_runtime: Optional[Dict[str, Any]] = None,
task: Optional[str] = None,
) -> Tuple[Optional[OpenAI], Optional[str]]:
"""Full auto-detection chain.
Priority:
@@ -3223,7 +3320,22 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
main_provider, resolved or main_model)
return client, resolved or main_model
# ── Step 2: aggregator / fallback chain ──────────────────────────────
# ── Step 2: user-configured fallback policy ─────────────────────────
# In auto mode, respect the task-specific fallback chain first, then the
# main agent's top-level fallback_providers/fallback_model chain. The
# hardcoded provider discovery chain below is only the convenience default
# for users who have not declared a fallback policy.
if task:
fb_client, fb_model, _fb_label = _try_configured_fallback_chain(
task, main_provider or "auto", reason="main provider unavailable")
if fb_client is not None:
return fb_client, fb_model
fb_client, fb_model, _fb_label = _try_main_fallback_chain(
task, main_provider or "auto", reason="main provider unavailable")
if fb_client is not None:
return fb_client, fb_model
# ── Step 3: aggregator / fallback chain ──────────────────────────────
tried = []
for label, try_fn in _get_provider_chain():
if _is_provider_unhealthy(label):
@@ -3344,6 +3456,7 @@ def resolve_provider_client(
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Central router: given a provider name and optional model, return a
configured client with the correct auth, base URL, and API format.
@@ -3464,7 +3577,7 @@ def resolve_provider_client(
# ── Auto: try all providers in priority order ────────────────────
if provider == "auto":
client, resolved = _resolve_auto(main_runtime=main_runtime)
client, resolved = _resolve_auto(main_runtime=main_runtime, task=task)
if client is None:
return None, None
# When auto-detection lands on a non-OpenRouter provider (e.g. a
@@ -4357,11 +4470,16 @@ def _client_cache_key(
api_mode: Optional[str] = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> tuple:
runtime = _normalize_main_runtime(main_runtime)
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
# `auto` can now resolve through task-specific or main fallback policy,
# so the task participates in the cache key. Non-auto providers keep the
# old cache shape because the explicit provider/model tuple is sufficient.
task_key = (task or "") if provider == "auto" else ""
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, pool_hint)
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint)
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
@@ -4554,6 +4672,7 @@ def _get_cached_client(
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Get or create a cached client for the given provider.
@@ -4591,6 +4710,7 @@ def _get_cached_client(
api_mode=api_mode,
main_runtime=main_runtime,
is_vision=is_vision,
task=task,
)
with _client_cache_lock:
if cache_key in _client_cache:
@@ -4635,6 +4755,7 @@ def _get_cached_client(
api_mode=api_mode,
main_runtime=runtime,
is_vision=is_vision,
task=task,
)
if client is not None:
# For async clients, remember which loop they were created on so we
@@ -5140,7 +5261,7 @@ def call_llm(
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", main_runtime=main_runtime)
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
@@ -5466,14 +5587,19 @@ def call_llm(
# Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set
# 2. Main agent model (last-resort safety net)
# For auto users (no explicit aux provider), use the full
# auto-detection chain instead — its Step 1 IS the main agent
# model, so users on `auto` already get main-model fallback.
# 2. For auto: top-level main fallback_providers/fallback_model
# 3. For auto: built-in auxiliary discovery chain
# 4. For explicit aux providers: main agent model safety net
fb_client, fb_model, fb_label = (None, None, "")
if is_auto:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
else:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
@@ -5636,7 +5762,7 @@ async def async_call_llm(
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", async_mode=True)
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
@@ -5904,13 +6030,19 @@ async def async_call_llm(
# Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set
# 2. Main agent model (last-resort safety net)
# Auto users get the full auto-detection chain instead — its
# Step 1 IS the main agent model.
# 2. For auto: top-level main fallback_providers/fallback_model
# 3. For auto: built-in auxiliary discovery chain
# 4. For explicit aux providers: main agent model safety net
fb_client, fb_model, fb_label = (None, None, "")
if is_auto:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
else:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
+121 -19
View File
@@ -237,18 +237,25 @@ _COMBINED_REVIEW_PROMPT = (
def summarize_background_review_actions(
review_messages: List[Dict],
prior_snapshot: List[Dict],
notification_mode: str = "on",
) -> List[str]:
"""Build the human-facing action summary for a background review pass.
Walks the review agent's session messages and collects "successful tool
action" descriptions to surface to the user (e.g. "Memory updated").
Tool messages already present in ``prior_snapshot`` are skipped so we
don't re-surface stale results from the prior conversation that the
review agent inherited via ``conversation_history`` (issue #14944).
Walks the review agent's session messages and collects successful memory
and skill-management actions to surface to the user. Tool messages already
present in ``prior_snapshot`` are skipped so stale inherited results are
not re-surfaced as fresh background work (issue #14944).
Matching is by ``tool_call_id`` when available, with a content-equality
fallback for tool messages that lack one.
``notification_mode`` controls display detail:
- ``off``: return no actions.
- ``on``: generic "Memory updated"/tool messages.
- ``verbose``: include compact content previews from tool-call arguments.
"""
mode = str(notification_mode or "on").lower()
if mode == "off":
return []
verbose = mode == "verbose"
existing_tool_call_ids = set()
existing_tool_contents = set()
for prior in prior_snapshot or []:
@@ -262,6 +269,42 @@ def summarize_background_review_actions(
if isinstance(content, str):
existing_tool_contents.add(content)
# Map review-agent tool results back to the calls that produced them. The
# result JSON only says "Entry added"; the call arguments contain action,
# target, and content previews. Restricting to notify_tools also prevents
# helper tools from surfacing as memory work just because they succeeded.
notify_tools = {"memory", "skill_manage"}
all_tool_call_ids: set = set()
call_details: dict = {}
for msg in review_messages or []:
if not isinstance(msg, dict) or msg.get("role") != "assistant":
continue
for tc in msg.get("tool_calls", []) or []:
if not isinstance(tc, dict):
continue
fn = tc.get("function", {}) or {}
fn_name = fn.get("name", "")
tcid = tc.get("id")
if tcid:
all_tool_call_ids.add(tcid)
if fn_name not in notify_tools:
continue
try:
args = json.loads(fn.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
args = {}
if tcid:
call_details[tcid] = {
"tool": fn_name,
"action": args.get("action", "?"),
"target": args.get("target", "memory"),
"content": args.get("content", ""),
"old_text": args.get("old_text", ""),
"name": args.get("name", ""),
"old_string": args.get("old_string", ""),
"new_string": args.get("new_string", ""),
}
actions: List[str] = []
for msg in review_messages or []:
if not isinstance(msg, dict) or msg.get("role") != "tool":
@@ -273,6 +316,8 @@ def summarize_background_review_actions(
content_str = msg.get("content")
if isinstance(content_str, str) and content_str in existing_tool_contents:
continue
if tcid and all_tool_call_ids and tcid not in call_details:
continue
try:
data = json.loads(msg.get("content", "{}"))
except (json.JSONDecodeError, TypeError):
@@ -280,19 +325,75 @@ def summarize_background_review_actions(
if not isinstance(data, dict) or not data.get("success"):
continue
message = data.get("message", "")
target = data.get("target", "")
if "created" in message.lower():
actions.append(message)
elif "updated" in message.lower():
actions.append(message)
elif "added" in message.lower() or (target and "add" in message.lower()):
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
actions.append(f"{label} updated")
elif "Entry added" in message:
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
actions.append(f"{label} updated")
elif "removed" in message.lower() or "replaced" in message.lower():
detail = call_details.get(tcid, {})
target = data.get("target", "") or detail.get("target", "")
is_skill = detail.get("tool") == "skill_manage"
message_lower = message.lower()
if not verbose:
if "created" in message_lower:
actions.append(message)
continue
if "updated" in message_lower:
actions.append(message)
continue
if is_skill and "patched" in message_lower:
actions.append(message)
continue
if is_skill:
label = "Skill"
elif target:
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
else:
continue
if verbose:
action = detail.get("action", "")
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
max_preview = 120
if is_skill:
change = data.get("_change", {})
old_string = change.get("old", "") or detail.get("old_string", "")
new_string = change.get("new", "") or detail.get("new_string", "")
description = change.get("description", "")
if action == "patch" and (old_string or new_string):
old_preview = old_string[:80].replace("\n", " ") + (
"" if len(old_string) > 80 else ""
)
new_preview = new_string[:80].replace("\n", " ") + (
"" if len(new_string) > 80 else ""
)
actions.append(
f"📝 Skill '{skill_name}' patched: "
f"\"{old_preview}\"\"{new_preview}\""
)
elif action == "create" and description:
actions.append(f"📝 Skill '{skill_name}' created: {description}")
elif action == "edit" and description:
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
else:
actions.append(f"📝 {message}" if message else f"Skill {action}")
elif action == "add" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} {preview}")
elif action == "replace" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} ✏️ {preview}")
elif action == "remove" and old_text:
preview = old_text[:60] + ("" if len(old_text) > 60 else "")
actions.append(f"{label} {preview}")
else:
actions.append(f"{label} updated")
elif (
"added" in message_lower
or "replaced" in message_lower
or "removed" in message_lower
or (target and "add" in message.lower())
or "Entry added" in message
):
actions.append(f"{label} updated")
return actions
@@ -522,6 +623,7 @@ def _run_review_in_thread(
actions = summarize_background_review_actions(
review_messages,
messages_snapshot,
notification_mode=getattr(agent, "memory_notifications", "on"),
)
if actions:
+19 -2
View File
@@ -58,17 +58,34 @@ _bedrock_runtime_client_cache: Dict[str, Any] = {}
_bedrock_control_client_cache: Dict[str, Any] = {}
_MIN_BOTO3_VERSION = (1, 34, 59)
def _require_boto3():
"""Import boto3, raising a clear error if not installed."""
"""Import boto3, raising a clear error if not installed or too old."""
try:
import boto3
return boto3
except ImportError:
raise ImportError(
"The 'boto3' package is required for the AWS Bedrock provider. "
"Install it with: pip install boto3\n"
"Or install Hermes with Bedrock support: pip install -e '.[bedrock]'"
)
# converse() / converse_stream() were added in boto3 1.34.59.
# When Hermes is installed editable into system Python, the system boto3
# (e.g. Ubuntu 24.04 ships 1.34.46) may take precedence over the venv
# version pinned in pyproject.toml.
try:
version = tuple(int(x) for x in boto3.__version__.split(".")[:3])
except (AttributeError, ValueError):
return boto3 # can't parse — don't block on version check
if version < _MIN_BOTO3_VERSION:
raise RuntimeError(
f"boto3 {boto3.__version__} does not support converse_stream "
f"(minimum 1.34.59 required). Upgrade with: "
f"pip install --upgrade boto3"
)
return boto3
def _get_bedrock_runtime_client(region: str):
+3 -3
View File
@@ -454,16 +454,16 @@ def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]:
report["attempted"] = True # we tried but there was nothing to do
return report
# Load and rewrite the live jobs under the scheduler's lock.
# Load and rewrite the live jobs under the scheduler's cross-process lock.
try:
from cron.jobs import load_jobs, save_jobs, _jobs_file_lock
from cron.jobs import load_jobs, save_jobs, _jobs_lock
except ImportError as e:
report["error"] = f"cron module unavailable: {e}"
return report
report["attempted"] = True
try:
with _jobs_file_lock:
with _jobs_lock():
live_jobs = load_jobs()
changed = False
+45 -20
View File
@@ -12,6 +12,7 @@ import time
from dataclasses import dataclass, field
from difflib import unified_diff
from pathlib import Path
from typing import Any
from utils import safe_json_loads
from agent.tool_result_classification import file_mutation_result_landed
@@ -168,6 +169,27 @@ def _oneline(text: str) -> str:
return " ".join(text.split())
def _truncate_preview(text: str, max_len: int | None) -> str:
if max_len and max_len > 0 and len(text) > max_len:
if max_len <= 3:
return "." * max_len
return text[:max_len - 3] + "..."
return text
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
if not isinstance(tasks, list):
return 0, []
goals: list[str] = []
for task in tasks:
if not isinstance(task, dict):
continue
raw_goal = task.get("goal")
goal = "?" if raw_goal is None else _oneline(str(raw_goal))
goals.append(_truncate_preview(goal or "?", per_goal_len))
return len(goals), goals
def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
"""Build a short preview of a tool call's primary argument for display.
@@ -191,6 +213,22 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
"clarify": "question", "skill_manage": "name",
}
# delegate_task: show goal (single) or individual task goals (batch)
if tool_name == "delegate_task":
tasks = args.get("tasks")
if tasks and isinstance(tasks, list):
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=40)
preview = (
f"{task_count} tasks: " + " | ".join(goals)
if goals else f"{len(tasks)} parallel tasks"
)
return _truncate_preview(preview, max_len)
goal = args.get("goal", "")
if goal is None:
return None
preview = _oneline(str(goal))
return _truncate_preview(preview, max_len) if preview else None
if tool_name == "process":
action = args.get("action", "")
sid = args.get("session_id", "")
@@ -858,20 +896,6 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
return False, ""
def _used_free_parallel(result: str | None) -> bool:
"""True when a web result came from Parallel's free Search MCP.
Only the keyless Parallel path tags its result with ``provider="parallel"``;
the paid REST path and every other provider omit it. Used to label the tool
line "Parallel search" / "Parallel fetch" exactly when the free MCP served
the call.
"""
if not isinstance(result, str) or '"provider"' not in result:
return False
data = safe_json_loads(result)
return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel"
def get_cute_tool_message(
tool_name: str, args: dict, duration: float, result: str | None = None,
) -> str:
@@ -909,17 +933,15 @@ def get_cute_tool_message(
return f"{line}{failure_suffix}"
if tool_name == "web_search":
verb = "Parallel search" if _used_free_parallel(result) else "search"
return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}")
return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}")
if tool_name == "web_extract":
verb = "Parallel fetch" if _used_free_parallel(result) else "fetch"
urls = args.get("urls", [])
if urls:
url = urls[0] if isinstance(urls, list) else str(urls)
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 {verb:<9} pages {dur}")
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 fetch pages {dur}")
if tool_name == "terminal":
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
if tool_name == "process":
@@ -1035,7 +1057,10 @@ def get_cute_tool_message(
if tool_name == "delegate_task":
tasks = args.get("tasks")
if tasks and isinstance(tasks, list):
return _wrap(f"┊ 🔀 delegate {len(tasks)} parallel tasks {dur}")
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=30)
detail = " | ".join(goals) if goals else "parallel"
count_label = task_count or len(tasks)
return _wrap(f"┊ 🔀 delegate {count_label}x: {_trunc(detail, 35)} {dur}")
return _wrap(f"┊ 🔀 delegate {_trunc(args.get('goal', ''), 35)} {dur}")
preview = build_tool_preview(tool_name, args) or ""
+7 -1
View File
@@ -261,7 +261,13 @@ DEFAULT_CONTEXT_LENGTHS = {
# https://platform.minimax.io/docs/api-reference/text-chat-openai
"minimax-m3": 1000000,
"minimax": 204800,
# GLM
# GLM — GLM-5.2 ships with a 1M context window (verified empirically:
# needle-in-a-haystack retrieval at 789K prompt tokens succeeded with
# zero errors on api.z.ai/api/coding/paas/v4). Older GLM models
# (5, 5.1, 5-turbo) are ~202K. Longest-key-first substring matching
# ensures "glm-5.2" resolves to 1M while older variants still hit the
# generic 202K fallback.
"glm-5.2": 1_048_576,
"glm": 202752,
# xAI Grok — xAI /v1/models does not return context_length metadata,
# so these hardcoded fallbacks prevent Hermes from probing-down to
+1
View File
@@ -104,6 +104,7 @@ _PREFIX_PATTERNS = [
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key
r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token
]
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
+1
View File
@@ -531,6 +531,7 @@ class ChatCompletionsTransport(ProviderTransport):
supports_reasoning=params.get("supports_reasoning", False),
qwen_session_metadata=params.get("qwen_session_metadata"),
model=model,
base_url=params.get("base_url"),
ollama_num_ctx=params.get("ollama_num_ctx"),
session_id=params.get("session_id"),
)
+3 -3
View File
@@ -16,7 +16,7 @@
},
"dependencies": {
"@nous-research/ui": "0.16.0",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -40,8 +40,8 @@
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@vitejs/plugin-react": "^6.0.2",
"typescript": "^6.0.3",
"vite": "^7.3.1"
"vite": "^8.0.16"
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
+1 -1
View File
@@ -34,7 +34,7 @@ It builds and launches the GUI against your existing install — same config, ke
### Prebuilt installers
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop).
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/).
---
@@ -166,6 +166,39 @@ function profileRemoteOverride(config, profile) {
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}
/**
* In global-remote mode one backend serves every Desktop profile, so REST calls
* that are scoped by renderer-side `request.profile` must carry that scope as a
* query parameter. Local pooled backends and per-profile remote overrides do not
* need this: they already run against a backend scoped to the target profile.
*/
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
return path
}
const rawPath = String(path || '')
if (!rawPath) {
return path
}
let parsed
try {
parsed = new URL(rawPath, 'http://hermes.local')
} catch {
return path
}
if (parsed.searchParams.has('profile')) {
return path
}
parsed.searchParams.set('profile', scopedProfile)
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
function tokenPreview(value) {
const raw = String(value || '')
@@ -247,6 +280,7 @@ module.exports = {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -24,6 +24,7 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -90,6 +91,72 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride(null, 'coder'), null)
})
// --- pathWithGlobalRemoteProfile ---
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=iris'
)
})
test('pathWithGlobalRemoteProfile preserves existing query params', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/options?force=1&profile=iris'
)
})
test('pathWithGlobalRemoteProfile does not replace an explicit profile query', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info?profile=default', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=default'
)
})
test('pathWithGlobalRemoteProfile skips local and per-profile remote override paths', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: false,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: true
}),
'/api/model/info'
)
})
test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', '', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
''
)
})
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
+90 -58
View File
@@ -39,6 +39,7 @@ const { waitForDashboardPort } = require('./backend-ready.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
@@ -62,6 +63,7 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -242,6 +244,16 @@ if (INSTALL_STAMP) {
function resolveHermesHome() {
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
if (IS_WINDOWS) {
// A GUI app launched from Explorer inherits the environment block captured
// at login, so a HERMES_HOME set via `setx` AFTER login is invisible in
// process.env even though the CLI (a fresh shell) sees it. Without this the
// backend silently falls back to %LOCALAPPDATA%\hermes and reports "No
// inference provider configured" despite a valid configured home (#45471).
// Consult the live User-scoped registry value before the default below.
const fromRegistry = readWindowsUserEnvVar('HERMES_HOME')
if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry)
}
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
const legacy = path.join(app.getPath('home'), '.hermes')
@@ -5072,65 +5084,75 @@ function focusWindow(win) {
win.focus()
}
function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(win)
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch,
newSession
})
)
return win
}
// Open (or focus) a standalone window for a single chat session.
function createSessionWindow(sessionId, { watch = false } = {}) {
return sessionWindows.openOrFocus(sessionId, () => {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
}
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(win)
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch
})
)
return win
})
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
// like ⌘N in a browser, every press opens a new window — and a draft window that
// later converts to a real session must not get refocused as if it were blank.
function createNewSessionWindow() {
return spawnSecondaryWindow({ newSession: true })
}
function createWindow() {
@@ -5317,6 +5339,11 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
return { ok: true }
})
ipcMain.handle('hermes:window:openNewSession', async () => {
createNewSessionWindow()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
@@ -5586,9 +5613,14 @@ ipcMain.handle('hermes:api', async (_event, request) => {
await prepareProfileDeleteRequest(request)
const connection = await ensureBackend(request?.profile)
const profile = request?.profile
const connection = await ensureBackend(profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
globalRemote: globalRemoteActive(),
profileRemoteOverride: profileHasRemoteOverride(profile)
})
const url = `${connection.baseUrl}${requestPath}`
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
// the OAuth partition — route through Electron's net stack bound to that
// session so the cookie attaches automatically. Token/local modes keep using
+1
View File
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
+7 -6
View File
@@ -15,12 +15,13 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
// treated as the route by HashRouter and would break routeSessionId(). The
// renderer reads the flag from window.location.search to suppress the install /
// onboarding overlays and the global session sidebar. `watch=1` marks a
// spectator window (e.g. a running subagent's session): the renderer resumes
// it lazily so the gateway never builds an agent just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) {
const query = `?win=secondary${watch ? '&watch=1' : ''}`
const route = `#/${encodeURIComponent(sessionId)}`
// onboarding overlays and the global session sidebar. `new=1` marks the compact
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
// session): the renderer resumes it lazily so the gateway never builds an agent
// just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
@@ -82,6 +82,12 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
})
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
})
test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry()
let built = 0
@@ -0,0 +1,76 @@
// windows-user-env.cjs
//
// Read a User-scoped environment variable straight from the Windows registry
// (HKCU\Environment).
//
// A GUI app launched from Explorer inherits the environment block captured at
// login, so a variable set via `setx` AFTER login is invisible in process.env
// even though a fresh shell — and the Hermes CLI — sees it immediately. The
// desktop's HERMES_HOME resolution relies on process.env, so that stale-snapshot
// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading
// the live registry value closes the gap. See #45471.
const { execFileSync } = require('node:child_process')
// Parse the output of `reg query HKCU\Environment /v <name>`, which looks like:
//
// HKEY_CURRENT_USER\Environment
// HERMES_HOME REG_SZ F:\Hermes\data
//
// Returns the raw value string (spaces inside the value preserved), or null when
// the requested value line isn't present.
function parseRegQueryValue(stdout, name) {
if (!stdout || !name) return null
const typePattern =
/^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
for (const rawLine of String(stdout).split(/\r?\n/)) {
const line = rawLine.trim()
const match = line.match(typePattern)
if (match && match[1].toLowerCase() === name.toLowerCase()) {
return match[2]
}
}
return null
}
// Expand %VAR% references against an env map. REG_EXPAND_SZ values store
// unexpanded references; plain REG_SZ paths have none, so this is a no-op for
// the common F:\... case. Unknown references are left verbatim.
function expandWindowsEnvRefs(value, env = process.env) {
if (!value) return value
return value.replace(/%([^%]+)%/g, (whole, name) => {
const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase())
return key != null && env[key] != null ? env[key] : whole
})
}
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
// (the value doesn't exist), or when the value is empty.
function readWindowsUserEnvVar(
name,
{ platform = process.platform, env = process.env, exec = execFileSync } = {}
) {
if (platform !== 'win32' || !name) return null
let stdout
try {
stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], {
encoding: 'utf8',
windowsHide: true,
timeout: 5000
})
} catch {
// `reg` missing, or value absent (reg exits 1) — caller falls back.
return null
}
const raw = parseRegQueryValue(stdout, name)
if (raw == null) return null
const expanded = expandWindowsEnvRefs(raw, env).trim()
return expanded || null
}
module.exports = {
expandWindowsEnvRefs,
parseRegQueryValue,
readWindowsUserEnvVar
}
@@ -0,0 +1,90 @@
const assert = require('node:assert/strict')
const { test } = require('node:test')
const {
expandWindowsEnvRefs,
parseRegQueryValue,
readWindowsUserEnvVar
} = require('./windows-user-env.cjs')
// ── parseRegQueryValue ─────────────────────────────────────────────────────
test('parseRegQueryValue extracts a REG_SZ value', () => {
const out = [
'',
'HKEY_CURRENT_USER\\Environment',
' HERMES_HOME REG_SZ F:\\Hermes\\data',
''
].join('\r\n')
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data')
})
test('parseRegQueryValue matches the name case-insensitively', () => {
const out = 'HKEY_CURRENT_USER\\Environment\r\n Hermes_Home REG_EXPAND_SZ %USERPROFILE%\\h\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), '%USERPROFILE%\\h')
})
test('parseRegQueryValue preserves spaces inside the value', () => {
const out = ' HERMES_HOME REG_SZ C:\\Program Files\\Hermes\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'C:\\Program Files\\Hermes')
})
test('parseRegQueryValue returns null when the value line is absent', () => {
const out = 'HKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\x\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), null)
assert.equal(parseRegQueryValue('', 'HERMES_HOME'), null)
assert.equal(parseRegQueryValue('garbage', 'HERMES_HOME'), null)
})
// ── expandWindowsEnvRefs ───────────────────────────────────────────────────
test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => {
assert.equal(
expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }),
'C:\\Users\\jeff\\h'
)
})
test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => {
assert.equal(expandWindowsEnvRefs('F:\\Hermes\\data', {}), 'F:\\Hermes\\data')
assert.equal(expandWindowsEnvRefs('%NOPE%\\x', {}), '%NOPE%\\x')
})
// ── readWindowsUserEnvVar ──────────────────────────────────────────────────
test('readWindowsUserEnvVar returns null off Windows without spawning', () => {
let spawned = false
const exec = () => {
spawned = true
return ''
}
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null)
assert.equal(spawned, false)
})
test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => {
const calls = []
const exec = (cmd, args) => {
calls.push([cmd, args])
return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n'
}
const value = readWindowsUserEnvVar('HERMES_HOME', {
platform: 'win32',
env: { DRIVE: 'F:' },
exec
})
assert.equal(value, 'F:\\Hermes')
assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]])
})
test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)', () => {
const exec = () => {
throw new Error('reg exited 1')
}
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
})
test('readWindowsUserEnvVar returns null for an empty value', () => {
const exec = () => ' HERMES_HOME REG_SZ \r\n'
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
})
+3 -1
View File
@@ -20,6 +20,7 @@
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
@@ -36,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -134,6 +135,7 @@
},
"build": {
"electronVersion": "40.9.3",
"electronDist": "../../node_modules/electron/dist",
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",
@@ -0,0 +1,59 @@
const fs = require('node:fs')
const path = require('node:path')
if (process.platform !== 'darwin') {
process.exit(0)
}
const desktopRoot = path.resolve(__dirname, '..')
const repoRoot = path.resolve(desktopRoot, '..', '..')
const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js')
const marker = 'hermes-macos-electron-binary-fallback'
const needle = ` await Promise.all([
doRename(path.join(contentsPath, "MacOS"), electronBranding.productName, appPlist.CFBundleExecutable),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
]);`
const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes copy
// Electron.app without its main MacOS/Electron binary before this rename.
// Restore it from the installed Electron runtime so local desktop installs
// do not fail with ENOENT during macOS arm64 packaging.
const macosDir = path.join(contentsPath, "MacOS");
const bundledElectronBinary = path.join(macosDir, electronBranding.productName);
if (!fs.existsSync(bundledElectronBinary)) {
const candidates = [
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
];
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
if (sourceBinary == null) {
throw new Error("Electron binary missing from packaged app and Electron runtime: " + bundledElectronBinary);
}
await (0, promises_1.copyFile)(sourceBinary, bundledElectronBinary);
await (0, promises_1.chmod)(bundledElectronBinary, 0o755);
}
await Promise.all([
doRename(macosDir, electronBranding.productName, appPlist.CFBundleExecutable),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
]);`
if (!fs.existsSync(electronMacPath)) {
console.warn(`[patch-electron-builder] skipped: ${electronMacPath} not found`)
process.exit(0)
}
const source = fs.readFileSync(electronMacPath, 'utf8')
if (source.includes(marker)) {
console.log('[patch-electron-builder] macOS Electron binary fallback already applied')
process.exit(0)
}
if (!source.includes(needle)) {
console.warn('[patch-electron-builder] skipped: expected electronMac.js shape not found')
process.exit(0)
}
fs.writeFileSync(electronMacPath, source.replace(needle, replacement))
console.log('[patch-electron-builder] applied macOS Electron binary fallback')
+4 -8
View File
@@ -23,6 +23,7 @@ import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
import { mediaExternalUrl } from '@/lib/media'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
@@ -124,17 +125,12 @@ function artifactKind(value: string): ArtifactKind {
}
function artifactHref(value: string): string {
if (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('file://') ||
value.startsWith('data:')
) {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) {
return value
}
if (value.startsWith('/')) {
return `file://${encodeURI(value)}`
if (value.startsWith('file://') || value.startsWith('/')) {
return mediaExternalUrl(value)
}
return value
+6 -2
View File
@@ -42,6 +42,7 @@ import {
$sessions,
sessionPinId
} from '@/store/session'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
@@ -122,7 +123,7 @@ function ChatHeader({
// A brand-new session has no session to pin/delete/rename, so the header is
// just a dead "New session" label + chevron. Drop it (and its border)
// entirely until there's a real session to act on.
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
return null
}
@@ -302,7 +303,10 @@ export function ChatView({
// waiting for the resume effect (which paints a frame later) to clear them.
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
!isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
@@ -77,6 +77,7 @@ import {
setSessionsLoading,
setSessionsTotal
} from '../store/session'
import { onSessionsChanged } from '../store/session-sync'
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
import { isSecondaryWindow } from '../store/windows'
@@ -464,6 +465,17 @@ export function DesktopController() {
void refreshSessions()
}, [refreshSessions])
// Another window mutated the shared session list (e.g. a chat started in the
// pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so
// only real windows bother.
useEffect(() => {
if (isSecondaryWindow()) {
return
}
return onSessionsChanged(() => void refreshSessions().catch(() => undefined))
}, [refreshSessions])
// ALL-profiles view pages one profile at a time: fetch that profile's next
// page and merge it in place, leaving every other profile's rows untouched.
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
@@ -37,6 +37,7 @@ import {
switcherActive,
switcherJustClosed
} from '@/store/session-switcher'
import { openNewSessionInNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
@@ -132,6 +133,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.newWindow': () => void openNewSessionInNewWindow(),
'session.next': () => stepSession(1),
'session.prev': () => stepSession(-1),
...sessionSlotHandlers,
@@ -0,0 +1,75 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirResult } from '@/global'
import { $connection, setCurrentCwd } from '@/store/session'
import { resetProjectTreeState } from './files/use-project-tree'
import { RightSidebarPane } from './index'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const selectPaths = vi.fn()
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
return { entries }
}
function installBridge() {
;(
window as unknown as {
hermesDesktop: {
readDir: typeof readDir
selectPaths: typeof selectPaths
}
}
).hermesDesktop = { readDir, selectPaths }
}
describe('RightSidebarPane', () => {
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
setCurrentCwd('/repo')
readDir.mockReset()
selectPaths.mockReset()
readDir.mockResolvedValue(ok([{ name: 'README.md', path: '/repo/README.md', isDirectory: false }]))
selectPaths.mockResolvedValue(['/repo-next'])
installBridge()
})
afterEach(() => {
cleanup()
$connection.set(null)
setCurrentCwd('')
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('refreshes the current tree without opening the folder picker', async () => {
const onChangeCwd = vi.fn()
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} onChangeCwd={onChangeCwd} />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh tree' }).hasAttribute('disabled')).toBe(false))
readDir.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Refresh tree' }))
await waitFor(() => expect(readDir).toHaveBeenCalledWith('/repo'))
expect(selectPaths).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Open folder' }))
await waitFor(() =>
expect(selectPaths).toHaveBeenCalledWith({
defaultPath: '/repo',
directories: true,
multiple: false,
title: 'Change working directory'
})
)
await waitFor(() => expect(onChangeCwd).toHaveBeenCalledWith('/repo-next'))
})
})
+5 -5
View File
@@ -126,12 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps {
onRefresh: () => void
}
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
// stays visible while any folder is expanded.
// Sidebar palette + hover-reveal: header actions stay reachable while moving
// from the project label to the action buttons.
const HEADER_ACTION_CLASS =
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
function FilesystemTab({
canCollapse,
@@ -158,7 +158,7 @@ function FilesystemTab({
return (
<div className="flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<div className="peer/project-label flex min-w-0 flex-1">
<div className="flex min-w-0 flex-1">
<button
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
@@ -216,7 +216,7 @@ function FilesystemTab({
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
return <div className="group/project-header flex h-7 shrink-0 items-center px-2.5">{children}</div>
}
interface FileTreeBodyProps {
@@ -47,6 +47,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { setSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
@@ -641,6 +642,9 @@ export function useMessageStream({
})
void refreshSessions().catch(() => undefined)
// Sync the freshly-titled row to other windows (e.g. main, when the turn
// ran in the pop-out).
broadcastSessionsChanged()
if (compactedTurnRef.current.delete(sessionId)) {
shouldHydrate = false
@@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import { cleanup, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelInfo } from '@/hermes'
@@ -13,12 +13,51 @@ import {
import { useModelControls } from './use-model-controls'
const setGlobalModel = vi.fn()
const notifyError = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelInfo: vi.fn(),
setGlobalModel: vi.fn()
setGlobalModel: (...args: Parameters<typeof setGlobalModel>) => setGlobalModel(...args)
}))
describe('useModelControls.refreshCurrentModel', () => {
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
desktop: {
modelSwitchFailed: 'Model switch failed'
}
}
})
}))
vi.mock('@/store/notifications', () => ({
notifyError: (...args: Parameters<typeof notifyError>) => notifyError(...args)
}))
type Controls = ReturnType<typeof useModelControls>
function Harness({
activeSessionId,
onReady,
requestGateway
}: {
activeSessionId: string | null
onReady: (controls: Controls) => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const controls = useModelControls({
activeSessionId,
queryClient: new QueryClient(),
requestGateway
})
onReady(controls)
return null
}
describe('useModelControls', () => {
beforeEach(() => {
$activeSessionId.set(null)
setCurrentModel('')
@@ -26,6 +65,7 @@ describe('useModelControls.refreshCurrentModel', () => {
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
$activeSessionId.set(null)
setCurrentModel('')
@@ -74,4 +114,55 @@ describe('useModelControls.refreshCurrentModel', () => {
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
expect($currentProvider.get()).toBe('deepseek')
})
it('routes active-session picker changes through config.set with an explicit provider', async () => {
const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never)
let controls!: Controls
render(
<Harness
activeSessionId="session-1"
onReady={value => (controls = value)}
requestGateway={requestGateway}
/>
)
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic'
})
).resolves.toBe(true)
expect(requestGateway).toHaveBeenCalledWith('config.set', {
session_id: 'session-1',
key: 'model',
value: 'claude-sonnet-4.6 --provider anthropic'
})
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('keeps the global path on setGlobalModel when there is no active session', async () => {
setGlobalModel.mockResolvedValue(undefined)
let controls!: Controls
render(
<Harness
activeSessionId={null}
onReady={value => (controls = value)}
requestGateway={vi.fn()}
/>
)
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic'
})
).resolves.toBe(true)
expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6')
})
})
@@ -82,9 +82,10 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
try {
if (activeSessionId) {
await requestGateway('slash.exec', {
await requestGateway('config.set', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
key: 'model',
value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
if (selection.persistGlobal) {
@@ -58,6 +58,7 @@ import { clearSessionTodos } from '@/store/todos'
import type {
ClientSessionState,
BrowserManageResponse,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
@@ -1141,6 +1142,81 @@ export function usePromptActions({
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
// /browser connect|disconnect|status manages the live CDP connection on
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
// meaningful when that process runs on this machine, so it's gated to
// local connections. A remote gateway would act on the wrong host.
browser: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
if ($connection.get()?.mode === 'remote') {
renderSlashOutput(
'/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.'
)
return
}
const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean)
const cmdAction = rawAction.toLowerCase()
if (!['connect', 'disconnect', 'status'].includes(cmdAction)) {
renderSlashOutput(
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
)
return
}
const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
if (url) {
renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`)
}
try {
const result = await requestGateway<BrowserManageResponse>('browser.manage', {
action: cmdAction,
session_id: sessionId,
...(url && { url })
})
// Without a streamed session subscription, the gateway bundles its
// progress lines into `messages` — flush them inline.
result?.messages?.forEach(message => renderSlashOutput(message))
if (cmdAction === 'status') {
renderSlashOutput(
result?.connected
? `browser connected: ${result.url || '(url unavailable)'}`
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
)
return
}
if (cmdAction === 'disconnect') {
renderSlashOutput('browser disconnected')
return
}
if (result?.connected) {
renderSlashOutput('Browser connected to live Chromium-family browser via CDP')
renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`)
renderSlashOutput('next browser tool call will use this CDP endpoint')
}
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
@@ -42,6 +42,7 @@ import {
setYoloActive,
workspaceCwdForNewSession
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { reportBackendContract } from '@/store/updates'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
@@ -472,6 +473,9 @@ export function useSessionActions({
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
// Other windows (e.g. the main window when this is the pop-out) can't
// see this session until they re-pull the shared list.
broadcastSessionsChanged()
}
setFreshDraftReady(false)
+8 -3
View File
@@ -16,7 +16,7 @@ import {
} from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
@@ -80,6 +80,7 @@ export function AppShell({
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
const hideTitlebarControls = isNewSessionWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
// on macOS, where window controls sit on the left and are reported via
@@ -162,7 +163,9 @@ export function AppShell({
} as CSSProperties
}
>
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
{!hideTitlebarControls && (
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
)}
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
<PaneShell className="min-h-0 flex-1">
@@ -183,7 +186,9 @@ export function AppShell({
the panes' z-20 resize handles, keeping every pane resizable. */}
{mainOverlays}
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
{/* The compact pop-out drops the statusbar it's a scratch window, not
the full shell. */}
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
</main>
{overlays}
+6
View File
@@ -46,6 +46,12 @@ export interface SlashExecResponse {
warning?: string
}
export interface BrowserManageResponse {
connected?: boolean
url?: string
messages?: string[]
}
export interface SessionSteerResponse {
// 'queued' == accepted into the live turn's steer slot (injected at the next
// tool-result boundary); 'rejected' == no live tool window, caller queues.
@@ -1,5 +1,6 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import {
type CSSProperties,
type ComponentProps,
type FC,
memo,
@@ -21,6 +22,7 @@ import {
resetThreadScroll,
setThreadAtBottom
} from '@/store/thread-scroll'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import { MessageRenderBoundary } from './message-render-boundary'
@@ -132,6 +134,13 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
const hiddenCount = firstVisible
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null)
const newSessionWindow = isNewSessionWindow()
const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)'
const threadContentTopPad = newSessionWindow
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: isSecondaryWindow()
? 'pt-6'
: 'pt-[calc(var(--titlebar-height)+1.5rem)]'
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
useEffect(() => () => resetThreadScroll(), [])
@@ -235,7 +244,12 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
style={
{
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {})
} as CSSProperties
}
>
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
@@ -252,9 +266,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
</div>
) : (
<div
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
className={cn('mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6', threadContentTopPad)}
data-slot="aui_thread-content"
ref={contentRef as React.RefCallback<HTMLDivElement>}
>
@@ -80,9 +80,12 @@ const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
// Offset shadow lifting the revealed panel off the content (same both sides;
// the mirror axis is offset-x, which is 0). Same color on light + dark.
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
// Edge trigger strip, inset past the OS window-resize grab area.
// Edge trigger strip, inset past the OS window-resize grab area AND the
// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the
// neighboring scroller's edge, so any overlap makes the scrollbar reveal the
// pane on hover and swallow its clicks (#44140).
const HOVER_REVEAL_TRIGGER_WIDTH = 14
const HOVER_REVEAL_EDGE_GUTTER = 6
const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)'
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
// from the keyboard, since its store-open toggle is a no-op while collapsed.
+2
View File
@@ -24,6 +24,8 @@ declare global {
// a spectator window (lazy resume — no agent build) for live-streaming
// a running subagent's session.
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
// Open (or focus) a compact secondary window on the new-session draft.
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
+1
View File
@@ -189,6 +189,7 @@ export const en: Translations = {
'nav.cron': 'Open scheduled jobs',
'nav.agents': 'Open agents',
'session.new': 'New session',
'session.newWindow': 'New session in window',
'session.next': 'Next session',
'session.prev': 'Previous session',
'session.slot.1': 'Switch to recent session 1',
+1
View File
@@ -185,6 +185,7 @@ export const zh: Translations = {
'nav.cron': '打开定时任务',
'nav.agents': '打开智能体',
'session.new': '新建会话',
'session.newWindow': '在新窗口中新建会话',
'session.next': '下一个会话',
'session.prev': '上一个会话',
'session.slot.1': '切换到最近会话 1',
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { ChatMessage, ChatMessagePart } from './chat-messages'
import {
appendAssistantTextPart,
appendReasoningPart,
chatMessageText,
preserveLocalAssistantErrors,
renderMediaTags,
@@ -175,6 +176,52 @@ describe('renderMediaTags', () => {
})
})
describe('interleaved reasoning/text coalescing', () => {
it('keeps narration contiguous when reasoning interrupts mid-sentence', () => {
// Models that interleave reasoning_content + content deltas emit
// text → reasoning → text within one tool-bounded segment. The two text
// fragments are really one sentence and must not be split by the
// "Thinking" block between them.
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me ')
parts = appendReasoningPart(parts, 'checking the file...')
parts = appendAssistantTextPart(parts, 'verify the full file is correct:')
expect(parts.map(p => p.type)).toEqual(['text', 'reasoning'])
expect((parts[0] as { text: string }).text).toBe('Let me verify the full file is correct:')
expect((parts[1] as { text: string }).text).toBe('checking the file...')
})
it('merges reasoning bursts that straddle a narration fragment', () => {
let parts: ChatMessagePart[] = appendReasoningPart([], 'first thought ')
parts = appendAssistantTextPart(parts, 'Working on it.')
parts = appendReasoningPart(parts, 'second thought')
expect(parts.map(p => p.type)).toEqual(['reasoning', 'text'])
expect((parts[0] as { text: string }).text).toBe('first thought second thought')
expect((parts[1] as { text: string }).text).toBe('Working on it.')
})
it('starts a fresh text part after a tool call (segment boundary)', () => {
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me check.')
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
parts = appendAssistantTextPart(parts, 'Now editing.')
expect(parts.map(p => p.type)).toEqual(['text', 'tool-call', 'text'])
expect((parts[0] as { text: string }).text).toBe('Let me check.')
expect((parts[2] as { text: string }).text).toBe('Now editing.')
})
it('does not merge reasoning across a tool call', () => {
let parts: ChatMessagePart[] = appendReasoningPart([], 'before tool')
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
parts = appendReasoningPart(parts, 'after tool')
expect(parts.map(p => p.type)).toEqual(['reasoning', 'tool-call', 'reasoning'])
expect((parts[0] as { text: string }).text).toBe('before tool')
expect((parts[2] as { text: string }).text).toBe('after tool')
})
})
describe('preserveLocalAssistantErrors', () => {
it('preserves a local user+error pair when hydration omits the failed turn', () => {
const nextMessages: ChatMessage[] = [
+49 -29
View File
@@ -178,50 +178,70 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
}
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const next = [...parts]
const last = next.at(-1)
if (last?.type === 'text') {
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
return next
}
next.push(textPart(delta))
return next
const STREAM_PART: Record<'reasoning' | 'text', (text: string) => ChatMessagePart> = {
reasoning: reasoningPart,
text: textPart
}
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const next = appendTextPart(parts, delta)
const last = next.at(-1)
// Coalesce a streaming delta into the most recent same-type part within the
// current segment, where a segment is bounded by any non-streaming part (a
// tool call, image, …). The opposite streaming channel (text <-> reasoning) is
// transparent, so a reasoning burst between two content deltas can't shred one
// sentence into text / Thinking / text — the fragmentation models that
// interleave reasoning_content + content otherwise produce. Tool calls still
// open a fresh part, preserving narration order across steps.
function appendStreamPart(
parts: ChatMessagePart[],
type: 'reasoning' | 'text',
delta: string
): { index: number; parts: ChatMessagePart[] } {
const next = [...parts]
if (last?.type === 'text') {
const current = last.text
for (let i = next.length - 1; i >= 0; i--) {
const part = next[i]
const deltaMayContainMedia =
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
if (part.type === type) {
next[i] = { ...part, text: `${(part as { text: string }).text}${delta}` } as ChatMessagePart
const needsMediaPass = deltaMayContainMedia || current.includes('MEDIA:')
const nextText = needsMediaPass ? renderMediaTags(current) : current
next[next.length - 1] = nextText === current ? last : { ...last, text: nextText }
return { index: i, parts: next }
}
if (part.type !== 'text' && part.type !== 'reasoning') {
break
}
}
return next
next.push(STREAM_PART[type](delta))
return { index: next.length - 1, parts: next }
}
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
return appendStreamPart(parts, 'text', delta).parts
}
export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const next = [...parts]
const last = next.at(-1)
return appendStreamPart(parts, 'reasoning', delta).parts
}
if (last?.type === 'reasoning') {
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const { index, parts: next } = appendStreamPart(parts, 'text', delta)
const part = next[index]
if (part?.type !== 'text') {
return next
}
next.push(reasoningPart(delta))
const mayContainMedia =
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
if (mayContainMedia || part.text.includes('MEDIA:')) {
const rendered = renderMediaTags(part.text)
if (rendered !== part.text) {
next[index] = { ...part, text: rendered }
}
}
return next
}
@@ -52,6 +52,17 @@ describe('desktop slash command curation', () => {
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
})
it('treats /browser as an executable action command (local-gateway connect)', () => {
// /browser used to be terminal-only; it now resolves to a desktop action
// handler that routes browser.manage RPC when the gateway is local.
expect(isDesktopSlashCommand('/browser')).toBe(true)
expect(isDesktopSlashSuggestion('/browser')).toBe(true)
expect(desktopSlashUnavailableMessage('/browser')).toBeNull()
expect(resolveDesktopCommand('/browser')?.surface).toEqual({ kind: 'action', action: 'browser' })
// Bare /browser expands to its sub-action options in the popover.
expect(resolveDesktopCommand('/browser')?.args).toBe(true)
})
it('allows aliases to execute without cluttering the popover', () => {
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
expect(isDesktopSlashCommand('/reset')).toBe(true)
@@ -30,6 +30,7 @@ export interface DesktopThemeCommandOption {
*/
export type DesktopActionId =
| 'branch'
| 'browser'
| 'handoff'
| 'help'
| 'new'
@@ -103,6 +104,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
{ name: '/title', description: 'Rename the current session', surface: action('title') },
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
{
name: '/browser',
description: 'Manage browser CDP connection [connect|disconnect|status] (local gateway only)',
surface: action('browser'),
args: true
},
// Overlay pickers
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
@@ -142,7 +149,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
// per reason beats 40 identical object literals.
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
terminal: [
'/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
'/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
+1
View File
@@ -66,6 +66,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Session ──────────────────────────────────────────────────────────────
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
{ id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] },
// ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
{ id: 'session.next', category: 'session', defaults: ['ctrl+tab'] },
+33 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway } from './media'
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl } from './media'
describe('isRemoteGateway', () => {
afterEach(() => {
@@ -35,6 +35,38 @@ describe('filePathFromMediaPath', () => {
})
})
describe('mediaExternalUrl', () => {
afterEach(() => {
$connection.set(null)
})
it('passes through http(s) URLs untouched', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 't' } as never)
expect(mediaExternalUrl('https://example.com/a.png')).toBe('https://example.com/a.png')
})
it('keeps file:// form in local mode', () => {
$connection.set({ mode: 'local' } as never)
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
expect(mediaExternalUrl('file:///tmp/a.png')).toBe('file:///tmp/a.png')
})
it('rewrites gateway-local paths to an authenticated download URL', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 's e/cret' } as never)
expect(mediaExternalUrl('file:///tmp/a b.png')).toBe(
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
)
expect(mediaExternalUrl('/tmp/a b.png')).toBe(
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
)
})
it('falls back to file:// when remote connection lacks a token', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://gw' } as never)
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
})
})
describe('gatewayMediaDataUrl', () => {
const api = vi.fn(async () => ({ data_url: 'data:image/png;base64,ZHVtbXk=' }))
+18 -1
View File
@@ -56,8 +56,25 @@ export function mediaMarkdownHref(path: string): string {
return `#media:${encodeURIComponent(path)}`
}
// Resolve a media path to a URL the shell can open. Remote mode rewrites
// gateway-local paths to an authenticated /api/files/download URL (the file
// lives on the gateway, not this disk); local mode keeps the file:// form.
export function mediaExternalUrl(path: string): string {
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
if (/^https?:/i.test(path)) {
return path
}
if (isRemoteGateway()) {
const conn = $connection.get()
if (conn?.baseUrl && conn.token) {
const file = encodeURIComponent(filePathFromMediaPath(path))
return `${conn.baseUrl}/api/files/download?path=${file}&token=${encodeURIComponent(conn.token)}`
}
}
return /^file:/i.test(path) ? path : `file://${path}`
}
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
+89
View File
@@ -0,0 +1,89 @@
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesConnection } from '@/global'
// Keep profile.ts's side-effecting imports inert: the gateway socket layer and
// the REST query client must not run for real in a unit test.
const ensureGatewayForProfile = vi.fn(async () => undefined)
const $gateway = atom<unknown>({ id: 'live-socket' })
vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForProfile }))
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
setApiRequestProfile: vi.fn()
}))
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
const { $activeGatewayProfile, ensureGatewayProfile } = await import('./profile')
const { $connection } = await import('./session')
const remoteConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
({ baseUrl: 'https://hermes-roy.tail.ts.net', mode: 'remote', profile: 'vps-remote', ...over }) as HermesConnection
const localConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
({ baseUrl: '', mode: 'local', profile: 'default', ...over }) as HermesConnection
const getConnection = vi.fn<(profile?: string | null) => Promise<HermesConnection>>()
beforeEach(() => {
getConnection.mockReset()
ensureGatewayForProfile.mockClear()
$gateway.set({ id: 'live-socket' })
$activeGatewayProfile.set('default')
$connection.set(localConn())
vi.stubGlobal('window', { hermesDesktop: { getConnection } })
})
afterEach(() => {
vi.unstubAllGlobals()
$connection.set(null)
})
describe('ensureGatewayProfile → $connection sync (#46651)', () => {
it('refreshes $connection to the remote descriptor when activating a remote pool profile', async () => {
// Regression: the primary window backend is local, so $connection.mode is
// "local". Activating the remote profile must flip it to "remote" — without
// this, image attach uses path-based image.attach against the remote
// gateway ("image not found: C:\\…") instead of image.attach_bytes.
getConnection.mockResolvedValue(remoteConn())
await ensureGatewayProfile('vps-remote')
expect(ensureGatewayForProfile).toHaveBeenCalledWith('vps-remote')
expect(getConnection).toHaveBeenCalledWith('vps-remote')
expect($connection.get()?.mode).toBe('remote')
expect($connection.get()?.profile).toBe('vps-remote')
})
it('resyncs $connection back to local when returning to the default profile', async () => {
$activeGatewayProfile.set('vps-remote')
$connection.set(remoteConn())
getConnection.mockResolvedValue(localConn())
await ensureGatewayProfile('default')
expect(getConnection).toHaveBeenCalledWith('default')
expect($connection.get()?.mode).toBe('local')
})
it('leaves the prior connection intact when the descriptor fetch fails', async () => {
getConnection.mockRejectedValue(new Error('backend unreachable'))
await ensureGatewayProfile('vps-remote')
// Best-effort: boot/reconnect resyncs later; we must not null it out here.
expect($connection.get()?.mode).toBe('local')
})
it('does not churn $connection when the target is already the active profile', async () => {
$activeGatewayProfile.set('vps-remote')
$connection.set(remoteConn())
await ensureGatewayProfile('vps-remote')
expect(getConnection).not.toHaveBeenCalled()
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
expect($connection.get()?.mode).toBe('remote')
})
})
+30
View File
@@ -12,6 +12,7 @@ import {
storedStringRecord
} from '@/lib/storage'
import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
import { setConnection } from '@/store/session'
import type { ProfileInfo } from '@/types/hermes'
// Canonical key for a profile: trimmed, empty → "default". Used everywhere we
@@ -178,6 +179,32 @@ export const $gatewaySwapTarget = atom<string | null>(null)
let gatewaySwitch: Promise<void> | null = null
// Keep the renderer's $connection (mode / baseUrl / profile) in lockstep with
// the profile the live gateway is now on. $connection seeds from the PRIMARY
// (window) backend at boot and otherwise only refreshes on a sleep/wake
// reconnect — so activating a *background* profile left $connection describing
// the primary, with the wrong `mode` for everything that branches on
// local-vs-remote. Headline symptom: with a local primary and a remote pool
// profile active, image attachments went out via the path-based `image.attach`
// instead of `image.attach_bytes`, handing the remote gateway a client-only
// path it can't resolve ("image not found: C:\…"), while the /api/fs/* file
// browser and /api/media fetches targeted the wrong machine (#46651).
// Best-effort: a failed descriptor fetch leaves the prior connection intact for
// boot/reconnect to resync.
async function syncConnectionToActiveProfile(profile: string): Promise<void> {
const getConnection = window.hermesDesktop?.getConnection
if (!getConnection) {
return
}
try {
setConnection(await getConnection(profile))
} catch {
// Leave the prior connection in place; boot/reconnect resyncs it later.
}
}
// Make `profile`'s backend the active gateway, lazily opening its socket if it
// isn't live yet. Unlike the old single-socket swap, background profiles keep
// their sockets — so their sessions keep streaming concurrently. A null/empty
@@ -218,6 +245,9 @@ export async function ensureGatewayProfile(profile: string | null | undefined):
// the active gateway at it — without closing the profile you came from.
await ensureGatewayForProfile(target)
$activeGatewayProfile.set(target)
// The active backend just changed; resync $connection so remote-aware
// paths (image.attach_bytes vs image.attach, /api/fs/*, /api/media) follow.
await syncConnectionToActiveProfile(target)
})()
try {
+25
View File
@@ -0,0 +1,25 @@
// Cross-window session-list sync. Each desktop window is its own renderer
// process with its own gateway socket and session store, so a mutation in one
// (e.g. a new chat started in the compact pop-out) never reaches another
// window. This bus pings every window to re-pull the shared session list; the
// data already lives in the backend, the other window just doesn't know to look.
const CHANNEL = 'hermes:sessions'
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL)
// A window that mutated the session list (created / titled a chat) tells the
// others to refresh. A BroadcastChannel never delivers to its own poster, so the
// caller refreshes locally as it already does.
export function broadcastSessionsChanged(): void {
channel?.postMessage(1)
}
export function onSessionsChanged(handler: () => void): () => void {
if (!channel) {
return () => {}
}
channel.addEventListener('message', handler)
return () => channel.removeEventListener('message', handler)
}
+43 -3
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { canOpenSessionWindow, openSessionInNewWindow } from './windows'
import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow } from './windows'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
@@ -11,9 +11,13 @@ vi.mock('./notifications', () => ({
notifyError: (...args: unknown[]) => notifyError(...args)
}))
function installBridge(openSessionWindow?: Window['hermesDesktop']['openSessionWindow']) {
function installBridge(
openSessionWindow?: Window['hermesDesktop']['openSessionWindow'],
openNewSessionWindow?: Window['hermesDesktop']['openNewSessionWindow']
) {
desktopWindow.hermesDesktop = {
...(openSessionWindow ? { openSessionWindow } : {})
...(openSessionWindow ? { openSessionWindow } : {}),
...(openNewSessionWindow ? { openNewSessionWindow } : {})
} as unknown as Window['hermesDesktop']
}
@@ -101,3 +105,39 @@ describe('openSessionInNewWindow', () => {
expect(notifyError).toHaveBeenCalledTimes(1)
})
})
describe('openNewSessionInNewWindow', () => {
it('no-ops gracefully when the bridge is absent (web fallback)', async () => {
delete desktopWindow.hermesDesktop
await openNewSessionInNewWindow()
expect(notifyError).not.toHaveBeenCalled()
})
it('no-ops when openNewSessionWindow is missing', async () => {
installBridge(vi.fn().mockResolvedValue({ ok: true }))
await openNewSessionInNewWindow()
expect(notifyError).not.toHaveBeenCalled()
})
it('invokes the bridge', async () => {
const openNew = vi.fn().mockResolvedValue({ ok: true })
installBridge(vi.fn().mockResolvedValue({ ok: true }), openNew)
await openNewSessionInNewWindow()
expect(openNew).toHaveBeenCalledTimes(1)
expect(notifyError).not.toHaveBeenCalled()
})
it('notifies on an ok:false result', async () => {
installBridge(vi.fn().mockResolvedValue({ ok: true }), vi.fn().mockResolvedValue({ ok: false, error: 'nope' }))
await openNewSessionInNewWindow()
expect(notifyError).toHaveBeenCalledTimes(1)
})
})
+45 -7
View File
@@ -6,6 +6,7 @@ import { notifyError } from './notifications'
// never from the router. A "secondary" window renders a single chat without the
// global session sidebar or the install / onboarding overlays.
const SECONDARY_WINDOW_FLAG = 'secondary'
const NEW_SESSION_WINDOW_FLAG = '1'
let secondaryWindowCache: boolean | null = null
@@ -27,6 +28,26 @@ export function isSecondaryWindow(): boolean {
return result
}
let newSessionWindowCache: boolean | null = null
export function isNewSessionWindow(): boolean {
if (newSessionWindowCache !== null) {
return newSessionWindowCache
}
let result = false
try {
result = new URLSearchParams(window.location.search).get('new') === NEW_SESSION_WINDOW_FLAG
} catch {
result = false
}
newSessionWindowCache = result
return result
}
let watchWindowCache: boolean | null = null
// A "watch" window spectates a session that is being driven elsewhere (a
@@ -57,6 +78,22 @@ export function canOpenSessionWindow(): boolean {
return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === 'function'
}
type WindowOpenResult = { ok: boolean; error?: string } | undefined
// Run a window-open bridge call, surfacing any failure as a toast. Shared by the
// session pop-out and the new-session pop-out.
async function openWindow(call: () => Promise<WindowOpenResult>, failMessage: string): Promise<void> {
try {
const result = await call()
if (!result?.ok) {
notifyError(new Error(result?.error || 'unknown error'), failMessage)
}
} catch (err) {
notifyError(err, failMessage)
}
}
// Open (or focus) a standalone OS window for a single chat session. No-ops
// gracefully outside Electron so callers can wire it unconditionally.
// `watch: true` opens a spectator window (lazy resume, live-mirror stream).
@@ -65,13 +102,14 @@ export async function openSessionInNewWindow(sessionId: string, opts?: { watch?:
return
}
try {
const result = await window.hermesDesktop.openSessionWindow(sessionId, opts)
await openWindow(() => window.hermesDesktop.openSessionWindow(sessionId, opts), 'Could not open chat in a new window')
}
if (!result?.ok) {
notifyError(new Error(result?.error || 'unknown error'), 'Could not open chat in a new window')
}
} catch (err) {
notifyError(err, 'Could not open chat in a new window')
// Open a fresh compact window on the new-session draft.
export async function openNewSessionInNewWindow(): Promise<void> {
if (!canOpenSessionWindow() || typeof window.hermesDesktop.openNewSessionWindow !== 'function') {
return
}
await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window')
}
+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"],
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
+1 -1
View File
@@ -724,7 +724,7 @@ platform_toolsets:
# # allowed_chats: ["-1001234567890"]
# extra:
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2
#
# Discord-specific settings (config.yaml top-level, not under platforms:):
#
+175 -28
View File
@@ -977,6 +977,11 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
_cleanup_all_terminals()
except Exception:
pass
try:
from tools.async_delegation import interrupt_all as _interrupt_async_delegations
_interrupt_async_delegations(reason="CLI shutdown")
except Exception:
pass
try:
_cleanup_all_browsers()
except Exception:
@@ -1268,6 +1273,11 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
print(f"\033[31m✗ Failed to create worktree: {e}\033[0m")
return None
# Lock the worktree so concurrent/later hermes processes' pruning
# leaves this session's work alone (locks survive crashes too).
# Lock failure is non-fatal — _lock_worktree logs at debug level.
_lock_worktree(repo_root, str(wt_path))
# Copy files listed in .worktreeinclude (gitignored files the agent needs)
include_file = Path(repo_root) / ".worktreeinclude"
if include_file.exists():
@@ -1378,13 +1388,109 @@ def _worktree_has_unpushed_commits(worktree_path: str, timeout: int = 10) -> boo
return True
def _cleanup_worktree(info: Dict[str, str] = None) -> None:
"""Remove a worktree and its branch on exit.
def _lock_worktree(repo_root: str, wt_path: str, timeout: int = 10) -> bool:
"""Lock a worktree using git's native lock mechanism.
Preserves the worktree only if it has unpushed commits (real work
that hasn't been pushed to any remote). Uncommitted changes alone
(untracked files, test artifacts) are not enough to keep it agent
work lives in commits/PRs, not the working tree.
The lock marks the worktree as in-use by a live (or crashed) hermes
session so that other hermes processes' pruning leaves it alone.
Never raises; returns whether the lock was taken.
"""
import subprocess
try:
result = subprocess.run(
["git", "worktree", "lock",
"--reason", f"hermes session pid={os.getpid()}", str(wt_path)],
capture_output=True, text=True, timeout=timeout, cwd=repo_root,
)
if result.returncode != 0:
logger.debug(
"Failed to lock worktree %s: %s", wt_path, result.stderr.strip()
)
return False
return True
except Exception as e:
logger.debug("Failed to lock worktree %s: %s", wt_path, e)
return False
def _unlock_worktree(repo_root: str, wt_path: str, timeout: int = 10) -> bool:
"""Release a git worktree lock. Never raises."""
import subprocess
try:
result = subprocess.run(
["git", "worktree", "unlock", str(wt_path)],
capture_output=True, text=True, timeout=timeout, cwd=repo_root,
)
if result.returncode != 0:
logger.debug(
"Failed to unlock worktree %s: %s", wt_path, result.stderr.strip()
)
return False
return True
except Exception as e:
logger.debug("Failed to unlock worktree %s: %s", wt_path, e)
return False
def _worktree_is_locked(repo_root: str, wt_path: str, timeout: int = 10) -> bool:
"""Return whether a worktree is locked (per ``git worktree list --porcelain``).
Fails SAFE: on any error (bad repo_root, git failure, timeout) returns
True so callers treat the worktree as in-use and do not delete it.
"""
import subprocess
try:
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
capture_output=True, text=True, timeout=timeout, cwd=repo_root,
)
if result.returncode != 0:
return True
target = Path(wt_path).resolve()
current_path: Optional[Path] = None
for line in result.stdout.splitlines():
if line.startswith("worktree "):
current_path = Path(line[len("worktree "):].strip()).resolve()
elif line == "locked" or line.startswith("locked "):
if current_path == target:
return True
return False
except Exception:
return True
def _worktree_is_dirty(wt_path: str, timeout: int = 10) -> bool:
"""Return whether a worktree has uncommitted changes (staged, unstaged,
or untracked).
Fails SAFE: on any error returns True so callers do not delete a
worktree whose state they cannot determine.
"""
import subprocess
try:
result = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, timeout=timeout, cwd=wt_path,
)
if result.returncode != 0:
return True
return bool(result.stdout.strip())
except Exception:
return True
def _cleanup_worktree(info: Dict[str, str] = None) -> None:
"""Remove a worktree and its branch on graceful exit.
Preserves the worktree (along with its branch and lock) if it has
unpushed commits OR uncommitted changes either may be work the user
has not retrieved yet. Only clean, fully-pushed worktrees are
removed, and the branch is only deleted after ``git worktree remove``
actually succeeded.
"""
global _active_worktree
info = info or _active_worktree
@@ -1401,24 +1507,41 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
return
has_unpushed = _worktree_has_unpushed_commits(wt_path, timeout=10)
is_dirty = _worktree_is_dirty(wt_path)
if has_unpushed:
print(f"\n\033[33m⚠ Worktree has unpushed commits, keeping: {wt_path}\033[0m")
print(f" To clean up manually: git worktree remove --force {wt_path}")
if has_unpushed or is_dirty:
reason = "unpushed commits" if has_unpushed else "uncommitted changes"
print(f"\n\033[33m⚠ Worktree has {reason}, keeping: {wt_path}\033[0m")
print(f" To clean up manually: git worktree unlock {wt_path}")
print(f" then: git worktree remove --force {wt_path}")
_active_worktree = None
return
# Remove worktree (even if working tree is dirty — uncommitted
# changes without unpushed commits are just artifacts)
# Clean and fully pushed — release our lock, then remove.
_unlock_worktree(repo_root, wt_path)
removed = False
try:
subprocess.run(
result = subprocess.run(
["git", "worktree", "remove", wt_path, "--force"],
capture_output=True, text=True, timeout=15, cwd=repo_root,
)
removed = result.returncode == 0
if not removed:
logger.debug(
"Failed to remove worktree %s: %s", wt_path, result.stderr.strip()
)
except Exception as e:
logger.debug("Failed to remove worktree: %s", e)
# Delete the branch
if not removed:
# Removal failed — keep the branch so the commits stay reachable.
print(f"\033[33m⚠ Could not remove worktree, keeping it (and branch "
f"{branch}): {wt_path}\033[0m")
_active_worktree = None
return
# Delete the branch only now that the worktree is actually gone.
try:
subprocess.run(
["git", "branch", "-D", branch],
@@ -1512,10 +1635,14 @@ def _run_checkpoint_auto_maintenance() -> None:
def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
"""Remove stale worktrees and orphaned branches on startup.
Age-based tiers:
Pruning may only ever delete clean, unlocked, fully-pushed worktrees:
- Under max_age_hours (24h): skip session may still be active.
- 24h72h: remove if no unpushed commits.
- Over 72h: force remove regardless (nothing should sit this long).
- Locked (a live or crashed hermes session): skip at ANY age.
- Dirty working tree (uncommitted changes): skip at ANY age.
- Unpushed commits: skip at ANY age.
The branch is only deleted after ``git worktree remove`` actually
succeeded, so commits never lose their easy reachability.
Also prunes orphaned ``hermes/*`` and ``pr-*`` local branches that
have no corresponding worktree.
@@ -1530,7 +1657,6 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
now = time.time()
soft_cutoff = now - (max_age_hours * 3600) # 24h default
hard_cutoff = now - (max_age_hours * 3 * 3600) # 72h default
for entry in worktrees_dir.iterdir():
if not entry.is_dir() or not entry.name.startswith("hermes-"):
@@ -1544,14 +1670,22 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
except Exception:
continue
force = mtime <= hard_cutoff # Over 72h — force remove
# A lock means a session (live, or crashed mid-work) owns this
# worktree — never touch it, regardless of age.
if _worktree_is_locked(repo_root, str(entry)):
logger.debug("Skipping locked worktree: %s", entry.name)
continue
if not force:
# 24h72h tier: only remove if no unpushed commits
if _worktree_has_unpushed_commits(str(entry), timeout=5):
continue # Has unpushed commits or can't check — skip
# Uncommitted changes may be work the user hasn't retrieved.
if _worktree_is_dirty(str(entry)):
logger.debug("Skipping dirty worktree: %s", entry.name)
continue
# Safe to remove
# Unpushed commits are definitely work — keep at any age.
if _worktree_has_unpushed_commits(str(entry), timeout=5):
continue
# Safe to remove: clean, unlocked, fully pushed.
try:
branch_result = subprocess.run(
["git", "branch", "--show-current"],
@@ -1559,16 +1693,24 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
)
branch = branch_result.stdout.strip()
subprocess.run(
remove_result = subprocess.run(
["git", "worktree", "remove", str(entry), "--force"],
capture_output=True, text=True, timeout=15, cwd=repo_root,
)
if remove_result.returncode != 0:
# Removal failed — keep the branch so the commits stay
# reachable.
logger.debug(
"Failed to remove worktree %s: %s",
entry.name, remove_result.stderr.strip(),
)
continue
if branch:
subprocess.run(
["git", "branch", "-D", branch],
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
logger.debug("Pruned stale worktree: %s (force=%s)", entry.name, force)
logger.debug("Pruned stale worktree: %s", entry.name)
except Exception as e:
logger.debug("Failed to prune worktree %s: %s", entry.name, e)
@@ -5783,14 +5925,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if not self._session_db:
return []
try:
sessions = self._session_db.list_sessions_rich(
from hermes_cli.session_listing import query_session_listing
return query_session_listing(
self._session_db,
source="cli",
exclude_sources=["tool"],
current_session_id=self.session_id,
include_all_sources=False,
include_unnamed=True,
limit=limit,
exclude_sources=["tool"],
)
except Exception:
return []
return [s for s in sessions if s.get("id") != self.session_id]
def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> bool:
"""Render recent sessions inline from the active chat TUI.
+153 -61
View File
@@ -5,6 +5,7 @@ Jobs are stored in ~/.hermes/cron/jobs.json
Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md
"""
import contextlib
import copy
import json
import logging
@@ -14,6 +15,19 @@ import threading
import os
import re
import uuid
# Cross-process advisory file locking for jobs.json critical sections.
# fcntl is Unix-only; on Windows fall back to msvcrt. Either may be absent,
# in which case _jobs_lock() degrades to in-process locking only (the old
# behaviour) rather than failing.
try:
import fcntl
except ImportError: # pragma: no cover - non-Unix
fcntl = None
try:
import msvcrt
except ImportError: # pragma: no cover - non-Windows
msvcrt = None
from datetime import datetime, timedelta
from pathlib import Path
from hermes_constants import get_hermes_home
@@ -41,10 +55,79 @@ JOBS_FILE = CRON_DIR / "jobs.json"
# In-process lock protecting load_jobs→modify→save_jobs cycles.
# Required when tick() runs jobs in parallel threads — without this,
# concurrent mark_job_run / advance_next_run calls can clobber each other.
_jobs_file_lock = threading.Lock()
_jobs_file_lock = threading.RLock()
_jobs_lock_state = threading.local()
OUTPUT_DIR = CRON_DIR / "output"
ONESHOT_GRACE_SECONDS = 120
def _jobs_lock_file() -> Path:
"""Return the advisory lock path for the current cron directory."""
return CRON_DIR / ".jobs.lock"
@contextlib.contextmanager
def _jobs_lock():
"""Serialize a load_jobs→modify→save_jobs critical section.
Combines the in-process threading lock (cheap mutual exclusion between
the gateway's parallel tick threads) with a cross-process advisory file
lock on ``<cron dir>/.jobs.lock`` (mutual exclusion between the gateway process
and standalone ``hermes`` CLI invocations, which previously shared no lock
at all a `cron pause` could be silently clobbered by a concurrent
gateway write, leaving a "paused" job still firing).
The flock is blocking, but every critical section that uses it is short
(field updates only no agent execution), so contention resolves in
milliseconds. If neither fcntl nor msvcrt is available the manager still
provides in-process locking, matching the historical behaviour.
Nested calls in the same thread reuse the held lock so legacy callers that
invoke save_jobs() inside a broader mutation section don't deadlock or try
to reacquire the advisory file lock.
"""
depth = getattr(_jobs_lock_state, "depth", 0)
if depth:
_jobs_lock_state.depth = depth + 1
try:
yield
finally:
_jobs_lock_state.depth -= 1
return
with _jobs_file_lock:
_jobs_lock_state.depth = 1
lock_fd = None
try:
try:
ensure_dirs()
lock_fd = open(_jobs_lock_file(), "a+", encoding="utf-8")
lock_fd.seek(0)
if fcntl is not None:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
elif msvcrt is not None:
getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_LOCK"), 1)
except (OSError, IOError) as e:
# Never let a locking failure take down cron writes — fall back to
# in-process-only protection (still held via _jobs_file_lock).
logger.warning("jobs.json cross-process lock unavailable (%s); "
"proceeding with in-process lock only", e)
try:
yield
finally:
if lock_fd is not None:
try:
if fcntl is not None:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
elif msvcrt is not None:
getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_UNLCK"), 1)
except (OSError, IOError):
pass
finally:
lock_fd.close()
finally:
_jobs_lock_state.depth = 0
# Fields on a cron job that must never change after creation. ``id`` is used
# as a filesystem path component under ``OUTPUT_DIR``; allowing it to be
# updated lets an unsafe value (``../escape``, absolute path, nested) leak
@@ -468,8 +551,8 @@ def load_jobs() -> List[Dict[str, Any]]:
)
def save_jobs(jobs: List[Dict[str, Any]]):
"""Save all jobs to storage."""
def _save_jobs_unlocked(jobs: List[Dict[str, Any]]):
"""Save all jobs to storage. Caller must hold _jobs_lock()."""
ensure_dirs()
fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_')
try:
@@ -487,6 +570,12 @@ def save_jobs(jobs: List[Dict[str, Any]]):
raise
def save_jobs(jobs: List[Dict[str, Any]]):
"""Save all jobs to storage."""
with _jobs_lock():
_save_jobs_unlocked(jobs)
def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:
"""Normalize and validate a cron job workdir.
@@ -670,9 +759,10 @@ def create_job(
"workdir": normalized_workdir,
}
jobs = load_jobs()
jobs.append(job)
save_jobs(jobs)
with _jobs_lock():
jobs = load_jobs()
jobs.append(job)
save_jobs(jobs)
return job
@@ -743,49 +833,50 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
f"Cron job field(s) cannot be updated: {', '.join(sorted(bad_fields))}"
)
jobs = load_jobs()
for i, job in enumerate(jobs):
if job["id"] != job_id:
continue
with _jobs_lock():
jobs = load_jobs()
for i, job in enumerate(jobs):
if job["id"] != job_id:
continue
# Validate / normalize workdir if present in updates. Empty string or
# None both mean "clear the field" (restore old behaviour).
if "workdir" in updates:
_wd = updates["workdir"]
if _wd in {None, "", False}:
updates["workdir"] = None
else:
updates["workdir"] = _normalize_workdir(_wd)
# Validate / normalize workdir if present in updates. Empty string
# or None both mean "clear the field" (restore old behaviour).
if "workdir" in updates:
_wd = updates["workdir"]
if _wd in {None, "", False}:
updates["workdir"] = None
else:
updates["workdir"] = _normalize_workdir(_wd)
updated = _apply_skill_fields({**job, **updates})
schedule_changed = "schedule" in updates
updated = _apply_skill_fields({**job, **updates})
schedule_changed = "schedule" in updates
if "skills" in updates or "skill" in updates:
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
updated["skills"] = normalized_skills
updated["skill"] = normalized_skills[0] if normalized_skills else None
if "skills" in updates or "skill" in updates:
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
updated["skills"] = normalized_skills
updated["skill"] = normalized_skills[0] if normalized_skills else None
if schedule_changed:
updated_schedule = updated["schedule"]
# The API may pass schedule as a raw string (e.g. "every 10m")
# instead of a pre-parsed dict. Normalize it the same way
# create_job() does so downstream code can call .get() safely.
if isinstance(updated_schedule, str):
updated_schedule = parse_schedule(updated_schedule)
updated["schedule"] = updated_schedule
updated["schedule_display"] = updates.get(
"schedule_display",
updated_schedule.get("display", updated.get("schedule_display")),
)
if updated.get("state") != "paused":
updated["next_run_at"] = compute_next_run(updated_schedule)
if schedule_changed:
updated_schedule = updated["schedule"]
# The API may pass schedule as a raw string (e.g. "every 10m")
# instead of a pre-parsed dict. Normalize it the same way
# create_job() does so downstream code can call .get() safely.
if isinstance(updated_schedule, str):
updated_schedule = parse_schedule(updated_schedule)
updated["schedule"] = updated_schedule
updated["schedule_display"] = updates.get(
"schedule_display",
updated_schedule.get("display", updated.get("schedule_display")),
)
if updated.get("state") != "paused":
updated["next_run_at"] = compute_next_run(updated_schedule)
if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
updated["next_run_at"] = compute_next_run(updated["schedule"])
if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
updated["next_run_at"] = compute_next_run(updated["schedule"])
jobs[i] = updated
save_jobs(jobs)
return _normalize_job_record(jobs[i])
jobs[i] = updated
save_jobs(jobs)
return _normalize_job_record(jobs[i])
return None
@@ -847,19 +938,20 @@ def remove_job(job_id: str) -> bool:
if not job:
return False
canonical_id = job["id"]
jobs = load_jobs()
original_len = len(jobs)
jobs = [j for j in jobs if j["id"] != canonical_id]
if len(jobs) < original_len:
# Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g.
# left over from before the create-time guard) fails closed without
# half-applying the removal.
job_output_dir = _job_output_dir(canonical_id)
save_jobs(jobs)
# Clean up output directory to prevent orphaned dirs accumulating
if job_output_dir.exists():
shutil.rmtree(job_output_dir)
return True
with _jobs_lock():
jobs = load_jobs()
original_len = len(jobs)
jobs = [j for j in jobs if j["id"] != canonical_id]
if len(jobs) < original_len:
# Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g.
# left over from before the create-time guard) fails closed without
# half-applying the removal.
job_output_dir = _job_output_dir(canonical_id)
save_jobs(jobs)
# Clean up output directory to prevent orphaned dirs accumulating
if job_output_dir.exists():
shutil.rmtree(job_output_dir)
return True
return False
@@ -874,7 +966,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,
``delivery_error`` is tracked separately from the agent error a job
can succeed (agent produced output) but fail delivery (platform down).
"""
with _jobs_file_lock:
with _jobs_lock():
jobs = load_jobs()
for i, job in enumerate(jobs):
if job["id"] == job_id:
@@ -948,7 +1040,7 @@ def advance_next_run(job_id: str) -> bool:
Returns True if next_run_at was advanced, False otherwise.
"""
with _jobs_file_lock:
with _jobs_lock():
jobs = load_jobs()
for job in jobs:
if job["id"] == job_id:
@@ -973,12 +1065,12 @@ def get_due_jobs() -> List[Dict[str, Any]]:
the job is fast-forwarded to the next future run instead of firing
immediately. This prevents a burst of missed jobs on gateway restart.
"""
with _jobs_file_lock:
with _jobs_lock():
return _get_due_jobs_locked()
def _get_due_jobs_locked() -> List[Dict[str, Any]]:
"""Inner implementation of get_due_jobs(); must be called with _jobs_file_lock held."""
"""Inner implementation of get_due_jobs(); must be called with _jobs_lock held."""
now = _hermes_now()
raw_jobs = load_jobs()
jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)]
@@ -1158,7 +1250,7 @@ def rewrite_skill_refs(
if not consolidated and not pruned_set:
return {"rewrites": [], "jobs_updated": 0, "jobs_scanned": 0}
with _jobs_file_lock:
with _jobs_lock():
jobs = load_jobs()
rewrites: List[Dict[str, Any]] = []
changed = False
+1
View File
@@ -316,6 +316,7 @@ as_hermes mkdir -p \
"$HERMES_HOME/cron" \
"$HERMES_HOME/sessions" \
"$HERMES_HOME/logs" \
"$HERMES_HOME/logs/gateways" \
"$HERMES_HOME/hooks" \
"$HERMES_HOME/memories" \
"$HERMES_HOME/skills" \
+65 -3
View File
@@ -17,6 +17,57 @@ from utils import atomic_json_write
logger = logging.getLogger(__name__)
DIRECTORY_PATH = get_hermes_home() / "channel_directory.json"
# User-maintained friendly-name overlay. The directory is fully regenerated
# from live adapters + session data on a timer, so hand-edits to
# channel_directory.json don't survive. Aliases declared here are re-applied
# on every build AND every load, giving durable human-friendly names (and
# letting you pre-name a chat before it has produced any traffic).
# Format: {"<platform>": {"<chat_id>": "<friendly name>", ...}, ...}
CHANNEL_ALIASES_PATH = get_hermes_home() / "channel_aliases.json"
def _load_channel_aliases() -> Dict[str, Dict[str, str]]:
if not CHANNEL_ALIASES_PATH.exists():
return {}
try:
with open(CHANNEL_ALIASES_PATH, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _apply_channel_aliases(platforms: Dict[str, Any]) -> None:
"""Overlay friendly names onto directory entries by chat_id.
Renames matching entries in place; injects a placeholder entry for an
aliased id that hasn't been discovered yet (so a freshly-created group is
addressable by name before its first message). Mutates *platforms*.
"""
aliases = _load_channel_aliases()
for plat_name, id_map in aliases.items():
if not isinstance(id_map, dict):
continue
entries = platforms.setdefault(plat_name, [])
if not isinstance(entries, list):
continue
for chat_id, friendly in id_map.items():
if not isinstance(friendly, str) or not friendly.strip():
continue
chat_id = str(chat_id)
friendly = friendly.strip()
matched = False
for e in entries:
if isinstance(e, dict) and e.get("id") == chat_id:
e["name"] = friendly
matched = True
if not matched:
entries.append({
"id": chat_id,
"name": friendly,
"type": "group" if str(chat_id).endswith("@g.us") else "dm",
"thread_id": None,
})
def _normalize_channel_query(value: str) -> str:
@@ -96,6 +147,9 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
except Exception:
pass
# Overlay user-maintained friendly names before persisting.
_apply_channel_aliases(platforms)
directory = {
"updated_at": datetime.now().isoformat(),
"platforms": platforms,
@@ -247,12 +301,20 @@ def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
def load_directory() -> Dict[str, Any]:
"""Load the cached channel directory from disk."""
if not DIRECTORY_PATH.exists():
return {"updated_at": None, "platforms": {}}
base = {"updated_at": None, "platforms": {}}
_apply_channel_aliases(base["platforms"])
return base
try:
with open(DIRECTORY_PATH, encoding="utf-8") as f:
return json.load(f)
data = json.load(f)
# Re-apply aliases on read so friendly names take effect immediately,
# even between timed rebuilds and for brand-new alias entries.
_apply_channel_aliases(data.setdefault("platforms", {}))
return data
except Exception:
return {"updated_at": None, "platforms": {}}
base = {"updated_at": None, "platforms": {}}
_apply_channel_aliases(base["platforms"])
return base
def lookup_channel_type(platform_name: str, chat_id: str) -> Optional[str]:
+4
View File
@@ -32,6 +32,7 @@ from typing import Any
_GLOBAL_DEFAULTS: dict[str, Any] = {
"tool_progress": "all",
"tool_progress_grouping": "accumulate", # "accumulate" = edit one bubble; "separate" = one msg per tool
"show_reasoning": False,
"tool_preview_length": 0,
"streaming": None, # None = follow top-level streaming config
@@ -238,6 +239,9 @@ def _normalise(setting: str, value: Any) -> Any:
if isinstance(value, str):
return value.lower() in {"true", "1", "yes", "on"}
return bool(value)
if setting == "tool_progress_grouping":
val = str(value).lower()
return val if val in ("accumulate", "separate") else "accumulate"
if setting == "tool_preview_length":
try:
return int(value)
+25 -24
View File
@@ -77,6 +77,13 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
return metadata
def _mark_notify_metadata(metadata: dict | None) -> dict:
"""Clone metadata and mark a user-visible reply as notify-worthy."""
notify_metadata = dict(metadata) if metadata else {}
notify_metadata["notify"] = True
return notify_metadata
def _reply_anchor_for_event(event) -> str | None:
"""Return reply_to id for platforms that need reply semantics.
@@ -3889,7 +3896,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id,
content=_text,
reply_to=_reply_anchor_for_event(event),
metadata=thread_meta,
metadata=_mark_notify_metadata(thread_meta),
)
if _eph_ttl > 0 and _r.success and _r.message_id:
self._schedule_ephemeral_delete(
@@ -3995,7 +4002,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id,
content=_text,
reply_to=_reply_anchor_for_event(event),
metadata=_thread_meta,
metadata=_mark_notify_metadata(_thread_meta),
)
if _eph_ttl > 0 and _r.success and _r.message_id:
self._schedule_ephemeral_delete(
@@ -4045,7 +4052,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id,
content=_text,
reply_to=_reply_anchor_for_event(event),
metadata=_thread_meta,
metadata=_mark_notify_metadata(_thread_meta),
)
if _eph_ttl > 0 and _r.success and _r.message_id:
self._schedule_ephemeral_delete(
@@ -4268,6 +4275,12 @@ class BasePlatformAdapter(ABC):
)
text_content = _recovered
# Final user-visible content (text, TTS, media, files) gets
# the existing notify=True marker. Clone once so typing/status
# metadata stays unmarked and progress bubbles remain
# thread-strict.
_final_thread_metadata = _mark_notify_metadata(_thread_metadata)
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
@@ -4307,7 +4320,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id,
audio_path=_tts_path,
caption=telegram_tts_caption,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
_tts_caption_delivered = bool(
telegram_tts_caption and getattr(tts_result, "success", False)
@@ -4322,23 +4335,11 @@ class BasePlatformAdapter(ABC):
if text_content and not _tts_caption_delivered:
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
_reply_anchor = _reply_anchor_for_event(event)
# Mark final response messages for notification delivery.
# Platform adapters that support per-message notification
# control (e.g. Telegram's disable_notification) use this
# flag to override silent-mode and ensure the final
# response triggers a push notification.
# Clone to avoid mutating the metadata shared with the
# typing-indicator task (which must remain unmarked).
if _thread_metadata is not None:
_thread_metadata = dict(_thread_metadata)
_thread_metadata["notify"] = True
else:
_thread_metadata = {"notify": True}
result = await self._send_with_retry(
chat_id=event.source.chat_id,
content=text_content,
reply_to=_reply_anchor,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
_record_delivery(result)
@@ -4367,7 +4368,7 @@ class BasePlatformAdapter(ABC):
await self.send_multiple_images(
chat_id=event.source.chat_id,
images=images,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
human_delay=human_delay,
)
except Exception as batch_err:
@@ -4409,7 +4410,7 @@ class BasePlatformAdapter(ABC):
await self.send_multiple_images(
chat_id=event.source.chat_id,
images=_batch,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
human_delay=human_delay,
)
except Exception as batch_err:
@@ -4424,19 +4425,19 @@ class BasePlatformAdapter(ABC):
media_result = await self.send_voice(
chat_id=event.source.chat_id,
audio_path=media_path,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
elif ext in _VIDEO_EXTS:
media_result = await self.send_video(
chat_id=event.source.chat_id,
video_path=media_path,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
else:
media_result = await self.send_document(
chat_id=event.source.chat_id,
file_path=media_path,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
if not media_result.success:
@@ -4454,13 +4455,13 @@ class BasePlatformAdapter(ABC):
await self.send_video(
chat_id=event.source.chat_id,
video_path=file_path,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
else:
await self.send_document(
chat_id=event.source.chat_id,
file_path=file_path,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
except Exception as file_err:
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
+6 -1
View File
@@ -678,8 +678,13 @@ class EmailAdapter(BasePlatformAdapter):
image_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an image URL as part of an email body."""
"""Send an image URL as part of an email body.
``metadata`` is accepted to honor the base-class contract; the
email body send doesn't use it.
"""
text = caption or ""
text += f"\n\nImage: {image_url}"
return await self.send(chat_id, text.strip(), reply_to)
+140 -21
View File
@@ -419,11 +419,13 @@ class TelegramAdapter(BasePlatformAdapter):
self._mention_patterns = self._compile_mention_patterns()
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
# Bot API 10.1 Rich Messages: when explicitly enabled, send final
# replies via sendRichMessage with the raw agent markdown so
# tables/task lists/etc. render natively. Disabled by default because
# several Telegram clients accept but render rich messages poorly.
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
# Bot API 10.1 Rich Messages: render constructs the legacy MarkdownV2
# path degrades (tables → bullet lists, task lists, <details>, block
# math) via sendRichMessage / editMessageText's rich_message param using
# the raw agent markdown. Enabled by default; users can opt out for
# clients that accept but render rich messages poorly via
# platforms.telegram.extra.rich_messages: false.
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True)
# Latched off after a capability failure on sendRichMessage /
# sendRichMessageDraft (e.g. older python-telegram-bot without the
# endpoint) so later sends skip the doomed rich attempt entirely.
@@ -979,18 +981,54 @@ class TelegramAdapter(BasePlatformAdapter):
return True
return False
def _needs_rich_rendering(self, content: str) -> bool:
"""Return True for markdown constructs that the legacy path degrades.
Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram
clients render a consistent font weight/spacing. The rich endpoint is
reserved for constructs where raw markdown materially improves output:
pipe tables (MarkdownV2 has no table syntax and rewrites them into
bullet lists), GFM task lists, collapsible ``<details>`` blocks, and
block math. Adapted from #45995 (@YonganZhang).
"""
if not content:
return False
if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()):
return True
if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content):
return True
if re.search(r"(?m)^<details\b|^</details>|^<summary\b|^</summary>", content):
return True
if "$$" in content:
return True
return False
def _rich_eligible(self, content: str) -> bool:
"""Capability/content eligibility for rich, ignoring ``expect_edits``.
Shared core of :meth:`_should_attempt_rich` minus the per-call
``expect_edits`` metadata gate. The rich EDIT-finalize path
(:meth:`_try_edit_rich`) needs this: a streamed preview is sent with
``expect_edits=True`` to stay on the editable path mid-stream, but the
FINAL edit should still upgrade to rich when the content warrants it.
"""
return bool(
getattr(self, "_rich_messages_enabled", True)
and not getattr(self, "_rich_send_disabled", False)
and content
and content.strip()
and self._needs_rich_rendering(content)
and not self._has_telegram_desktop_details_math_crash_shape(content)
and self._content_fits_rich_limits(content)
and self._bot_supports_rich()
)
def _should_attempt_rich(
self, content: str, metadata: Optional[Dict[str, Any]] = None
) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
and not (metadata or {}).get("expect_edits")
and content
and content.strip()
and not self._has_telegram_desktop_details_math_crash_shape(content)
and self._content_fits_rich_limits(content)
and self._bot_supports_rich()
not (metadata or {}).get("expect_edits")
and self._rich_eligible(content)
)
def prefers_fresh_final_streaming(
@@ -998,12 +1036,13 @@ class TelegramAdapter(BasePlatformAdapter):
) -> bool:
"""Whether to replace a streamed preview with a fresh rich final.
Keep this disabled for Telegram. The fresh-final path briefly shows two
copies of the final answer, then deletes the streaming preview after the
rich send succeeds. That is especially visible on clients that support
rich messages well, and it looks like duplicate delivery at the end of
every streamed turn. Until Telegram rich edits are wired directly, final
streamed replies should edit the existing preview in place.
Disabled for Telegram. The fresh-final path briefly shows two copies of
the final answer, then deletes the streaming preview after the rich send
succeeds it looks like duplicate delivery at the end of every streamed
turn (the reason #46206 reverted it). Rich finalize is instead handled
by editing the existing preview in place via Bot API 10.1's
``editMessageText`` ``rich_message`` parameter (see
:meth:`_try_edit_rich`), so no fresh re-send / delete is needed.
"""
return False
@@ -1019,7 +1058,7 @@ class TelegramAdapter(BasePlatformAdapter):
streams split exactly as before.
"""
if (
getattr(self, "_rich_messages_enabled", False)
getattr(self, "_rich_messages_enabled", True)
and not getattr(self, "_rich_send_disabled", False)
and self._bot_supports_rich()
):
@@ -1207,9 +1246,74 @@ class TelegramAdapter(BasePlatformAdapter):
message_id=str(message_id) if message_id is not None else None,
)
async def _try_edit_rich(
self,
chat_id: str,
message_id: str,
content: str,
) -> Optional[SendResult]:
"""Edit an existing message in place as a rich message (Bot API 10.1).
Uses ``editMessageText`` with the ``rich_message`` parameter so a
streamed preview can finalize as rich (tables/task lists/details/math)
WITHOUT a fresh send + delete no duplicate preview. Mirrors
:meth:`_try_send_rich`'s error contract:
- success ``SendResult(success=True, message_id=...)``
- permanent / capability error ``None`` (caller falls back to the
legacy MarkdownV2 edit; capability errors latch rich off)
- transient / unknown ``SendResult(success=False)`` with retry
semantics (the message may already be edited; do NOT legacy-resend)
"""
payload: Dict[str, Any] = {
"chat_id": int(chat_id),
"message_id": int(message_id),
"rich_message": self._rich_message_payload(content),
}
if getattr(self, "_disable_link_previews", False):
payload["link_preview_options"] = {"is_disabled": True}
try:
# Raw Bot API result; do not request return_type=Message (PTB does
# not fully model the 10.1 response shape yet — a post-edit parse
# error must not be mistaken for a failed edit).
await self._bot.do_api_request("editMessageText", api_kwargs=payload)
except Exception as exc:
if self._is_rich_fallback_error(exc):
if self._is_rich_capability_error(exc):
self._rich_send_disabled = True
# "Message is not modified" — content identical to the current
# rich message; treat as a successful no-op so the caller does
# not fall through to a redundant legacy edit.
if "not modified" in str(exc).lower():
return SendResult(success=True, message_id=message_id)
logger.debug(
"[%s] rich editMessageText rejected (%s) — falling back to MarkdownV2 edit",
self.name, exc,
)
return None
if "not modified" in str(exc).lower():
return SendResult(success=True, message_id=message_id)
err_str = str(exc).lower()
try:
from telegram.error import TimedOut as _TimedOut
except (ImportError, AttributeError):
_TimedOut = None
is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
is_connect_timeout = self._looks_like_connect_timeout(exc)
logger.warning(
"[%s] rich editMessageText transient failure (no legacy resend): %s",
self.name, exc,
)
return SendResult(
success=False,
error=str(exc),
retryable=(is_connect_timeout or not is_timeout),
)
return SendResult(success=True, message_id=message_id)
def _should_attempt_rich_draft(self, content: str) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", False)
getattr(self, "_rich_messages_enabled", True)
and not getattr(self, "_rich_send_disabled", False)
and not getattr(self, "_rich_draft_disabled", False)
and content
@@ -2555,6 +2659,21 @@ class TelegramAdapter(BasePlatformAdapter):
if not self._bot:
return SendResult(success=False, error="Not connected")
# Rich finalize (Bot API 10.1): when the completed content has
# constructs the legacy MarkdownV2 edit degrades (tables → bullet
# lists, task lists, <details>, block math) and rich is available,
# edit the preview IN PLACE via editMessageText's rich_message param.
# No fresh send + delete → no duplicate preview (the problem #46206
# reverted the fresh-final path for). Attempted before the 4,096
# overflow pre-flight because the rich text cap is 32,768 — a rich
# table that exceeds the MarkdownV2 limit must not be split into legacy
# chunks. Falls back to the legacy edit path (overflow split included)
# on capability/permanent rejection.
if finalize and self._rich_eligible(content):
rich_result = await self._try_edit_rich(chat_id, message_id, content)
if rich_result is not None:
return rich_result
# Pre-flight: if content already exceeds the limit, split-and-deliver
# without round-tripping a doomed edit.
if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
+18 -2
View File
@@ -846,13 +846,20 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
image_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Download image URL to cache, send natively via bridge."""
"""Download image URL to cache, send natively via bridge.
``metadata`` is accepted to honor the base-class contract the
batch sender ``send_multiple_images`` passes it through to every
send path. The bridge media call doesn't use it, matching the
sibling overrides (send_video / send_voice / send_document).
"""
try:
local_path = await cache_image_from_url(image_url)
return await self._send_media_to_bridge(chat_id, local_path, "image", caption)
except Exception:
return await super().send_image(chat_id, image_url, caption, reply_to)
return await super().send_image(chat_id, image_url, caption, reply_to, metadata)
async def send_image_file(
self,
@@ -1136,6 +1143,15 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
body = data.get("body", "")
if data.get("isGroup"):
body = self._clean_bot_mention_text(body, data)
# If this is a reply, include the quoted message text so the agent
# knows exactly what the user is responding to (fixes "approve" context issue)
quoted_text = str(data.get("quotedText") or "").strip()
if quoted_text and data.get("hasQuotedMessage"):
# Truncate long quoted text to keep prompts reasonable
if len(quoted_text) > 300:
quoted_text = quoted_text[:297] + "..."
body = f"[Replying to: \"{quoted_text}\"]\n{body}"
MAX_TEXT_INJECT_BYTES = 100 * 1024
if msg_type == MessageType.DOCUMENT and cached_urls:
for doc_path in cached_urls:
+274 -36
View File
@@ -402,6 +402,68 @@ async def _send_or_update_status_coro(adapter, chat_id, status_key, content, met
return await adapter.send(chat_id, content, metadata=metadata)
def _resolve_progress_thread_id(platform: Any, source_thread_id: Any, event_message_id: Any) -> Optional[str]:
"""Return thread/root ID that progress/status bubbles should target."""
platform_value = getattr(platform, "value", platform)
platform_key = str(platform_value or "").lower()
if source_thread_id:
return str(source_thread_id)
if platform_key in {"slack", "mattermost"} and event_message_id:
return str(event_message_id)
return None
def _has_platform_display_override(user_config: dict, platform_key: str, setting: str) -> bool:
"""Return True when display.platforms.<platform> explicitly sets setting."""
display = user_config.get("display") if isinstance(user_config, dict) else None
if not isinstance(display, dict):
return False
platforms = display.get("platforms")
if not isinstance(platforms, dict):
return False
platform_cfg = platforms.get(platform_key)
return isinstance(platform_cfg, dict) and setting in platform_cfg
def _resolve_gateway_display_bool(
user_config: dict,
platform_key: str,
setting: str,
*,
default: bool = False,
platform: Any = None,
require_platform_override_for: set[Any] | None = None,
) -> bool:
"""Resolve a boolean display setting with optional platform-only opt-in.
Some display features expose assistant scratch text rather than deliberate
user-facing output. For high-noise threaded chat surfaces such as
Mattermost, a global opt-in is too broad: they must be enabled with an
explicit display.platforms.<platform>.<setting> override.
"""
current_platform = _gateway_platform_value(platform or platform_key)
platform_only = {
_gateway_platform_value(candidate)
for candidate in (require_platform_override_for or set())
}
if (
current_platform in platform_only
and not _has_platform_display_override(user_config, platform_key, setting)
):
return False
from gateway.display_config import resolve_display_setting
value = resolve_display_setting(user_config, platform_key, setting, default)
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"true", "yes", "1", "on"}
if value is None:
return bool(default)
return bool(value)
def _telegramize_command_mentions(text: str, platform: Any) -> str:
"""Rewrite slash-command mentions to Telegram-valid command names.
@@ -1921,9 +1983,42 @@ def _format_gateway_process_notification(evt: dict) -> "str | None":
text += "]"
return text
if evt_type == "async_delegation":
# Reuse the shared rich formatter (self-contained task-source block).
from tools.process_registry import format_process_notification
return format_process_notification(evt)
return None
def _drain_gateway_watch_events(completion_queue) -> "list[dict]":
"""Drain gateway-owned watch events without spinning on requeued events.
Watch events are handled by the post-turn gateway drain. Process
completions are owned by their per-process watcher task, and async
delegation completions are owned by ``_async_delegation_watcher``.
Requeueing async events inside ``while not queue.empty()`` would make the
loop non-terminating, so detach the current batch first, then requeue any
events this drain does not own after the queue is empty.
"""
watch_events: list[dict] = []
requeue: list[dict] = []
while not completion_queue.empty():
try:
evt = completion_queue.get_nowait()
except Exception:
break
evt_type = evt.get("type", "completion")
if evt_type in {"watch_match", "watch_disabled"}:
watch_events.append(evt)
elif evt_type == "async_delegation":
requeue.append(evt)
# else: process completion events are handled by the watcher task
for evt in requeue:
completion_queue.put(evt)
return watch_events
# Module-level weak reference to the active GatewayRunner instance.
# Used by tools (e.g. send_message) that need to route through a live
# adapter for plugin platforms. Set in GatewayRunner.__init__().
@@ -5353,6 +5448,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# turn so the agent kicks off the new chat.
asyncio.create_task(self._handoff_watcher())
# Start background async-delegation watcher — drains completion events
# from delegate_task(background=true) subagents and injects each
# result back into its originating session as a new turn, covering the
# idle case where the subagent finishes with no agent turn running.
asyncio.create_task(self._async_delegation_watcher())
logger.info("Press Ctrl+C to stop")
return True
@@ -5989,6 +6090,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
except Exception as _e:
logger.debug("process_registry.kill_all (%s) error: %s", phase, _e)
try:
from tools.async_delegation import interrupt_all as _interrupt_async
_async_n = _interrupt_async(reason=f"gateway shutdown ({phase})")
if _async_n:
logger.info(
"Shutdown (%s): interrupted %d background delegation(s)",
phase, _async_n,
)
except Exception as _e:
logger.debug("async interrupt_all (%s) error: %s", phase, _e)
try:
from tools.terminal_tool import cleanup_all_environments
cleanup_all_environments()
@@ -7554,6 +7665,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "resume":
return await self._handle_resume_command(event)
if canonical == "sessions":
return await self._handle_sessions_command(event)
if canonical == "branch":
return await self._handle_branch_command(event)
@@ -8926,17 +9040,24 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
source, session_entry, reason="agent-result-compression",
)
# Prepend reasoning/thinking if display is enabled (per-platform)
# Prepend reasoning/thinking if display is enabled (per-platform).
# Mattermost requires explicit per-platform opt-in because this is
# scratch text, not ordinary final-answer content.
try:
from gateway.display_config import resolve_display_setting as _rds
_show_reasoning_effective = _rds(
_show_reasoning_effective = _resolve_gateway_display_bool(
_load_gateway_config(),
_platform_config_key(source.platform),
"show_reasoning",
getattr(self, "_show_reasoning", False),
default=bool(getattr(self, "_show_reasoning", False)),
platform=source.platform,
require_platform_override_for={Platform.MATTERMOST},
)
except Exception:
_show_reasoning_effective = getattr(self, "_show_reasoning", False)
_show_reasoning_effective = (
False
if source.platform == Platform.MATTERMOST
else getattr(self, "_show_reasoning", False)
)
if _show_reasoning_effective and response and not _intentional_silence:
last_reasoning = agent_result.get("last_reasoning")
if last_reasoning:
@@ -8992,18 +9113,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.error("Process watcher setup error: %s", e)
# Drain watch pattern notifications that arrived during the agent run.
# Watch events and completions share the same queue; completions are
# already handled by the per-process watcher task above, so we only
# inject watch-type events here.
# Watch events and completions share the same queue; process
# completions are already handled by the per-process watcher task
# above, so we only inject watch-type events here.
#
# Async-delegation completions ALSO ride this shared queue but are
# owned by the dedicated _async_delegation_watcher (started at
# boot), which covers both the idle and post-turn cases with a
# single consumer — so we leave them on the queue here.
try:
from tools.process_registry import process_registry as _pr
_watch_events = []
while not _pr.completion_queue.empty():
evt = _pr.completion_queue.get_nowait()
evt_type = evt.get("type", "completion")
if evt_type in {"watch_match", "watch_disabled"}:
_watch_events.append(evt)
# else: completion events are handled by the watcher task
_watch_events = _drain_gateway_watch_events(_pr.completion_queue)
for evt in _watch_events:
synth_text = _format_gateway_process_notification(evt)
if synth_text:
@@ -12262,6 +12382,74 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception as e:
logger.error("Watch notification injection error: %s", e)
def _enrich_async_delegation_routing(self, evt: dict) -> None:
"""Fill platform/chat_id/thread_id/chat_type on an async-delegation event.
Async-delegation completion events only carry ``session_key`` (the
daemon worker has no access to the per-message routing metadata the
terminal background watcher captures at spawn time). Parse the
session_key into the routing fields ``_build_process_event_source``
expects. Best-effort: a CLI-origin event (empty session_key) is left
as-is and simply won't route on the gateway.
"""
if evt.get("platform"):
return # already enriched
parsed = _parse_session_key(evt.get("session_key", "") or "")
if not parsed:
return
evt["platform"] = parsed.get("platform", "")
evt["chat_type"] = parsed.get("chat_type", "")
evt["chat_id"] = parsed.get("chat_id", "")
if parsed.get("thread_id"):
evt["thread_id"] = parsed["thread_id"]
async def _async_delegation_watcher(self, interval: float = 2.0) -> None:
"""Drain async-delegation completions and inject them as new turns.
Background subagents (``delegate_task(background=true)``) run on the
async-delegation daemon executor they have no per-process watcher
task, so their completion events would only be seen by the post-turn
queue drain. This watcher covers the IDLE case: when a background
subagent finishes while no agent turn is running, its result still
re-enters the originating session promptly.
Mirrors the CLI's idle ``process_loop`` drain. Stays silent when the
queue has nothing for us; ignores non-async event types (those are
handled by ``_run_process_watcher`` / the post-turn drain).
"""
await asyncio.sleep(3) # let platforms finish connecting
from tools.process_registry import process_registry as _pr
while self._running:
try:
# Peek the queue for async-delegation events. We must NOT
# consume watch/completion events here (other drains own them),
# so requeue anything that isn't ours.
requeue = []
async_events = []
while not _pr.completion_queue.empty():
try:
evt = _pr.completion_queue.get_nowait()
except Exception:
break
if evt.get("type") == "async_delegation":
async_events.append(evt)
else:
requeue.append(evt)
for evt in requeue:
_pr.completion_queue.put(evt)
for evt in async_events:
self._enrich_async_delegation_routing(evt)
synth_text = _format_gateway_process_notification(evt)
if not synth_text:
continue
try:
await self._inject_watch_notification(synth_text, evt)
except Exception as e:
logger.error("Async delegation injection error: %s", e)
except Exception as e:
logger.debug("Async delegation watcher error: %s", e)
await asyncio.sleep(interval)
async def _run_process_watcher(self, watcher: dict) -> None:
"""
Periodically check a background process and push updates to the user.
@@ -12318,7 +12506,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if session.exited:
# --- Agent-triggered completion: inject synthetic message ---
# Skip if the agent already consumed the result via wait/poll/log
from tools.process_registry import process_registry as _pr_check
from tools.process_registry import format_process_notification, process_registry as _pr_check
if agent_notify and not _pr_check.is_completion_consumed(session_id):
from tools.ansi_strip import strip_ansi
_raw = strip_ansi(session.output_buffer) if session.output_buffer else ""
@@ -12334,12 +12522,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_out = f"[… output truncated — showing last {len(_tail)} chars]\n{_tail}"
else:
_out = _raw
synth_text = (
f"[IMPORTANT: Background process {session_id} completed "
f"(exit code {session.exit_code}).\n"
f"Command: {session.command}\n"
f"Output:\n{_out}]"
)
synth_text = format_process_notification({
"type": "completion",
"session_id": session_id,
"command": session.command,
"exit_code": session.exit_code,
"completion_reason": getattr(session, "completion_reason", "exited"),
"termination_source": getattr(session, "termination_source", ""),
"output": _out,
})
if not synth_text:
break
source = self._build_process_event_source({
"session_id": session_id,
"session_key": session_key,
@@ -13489,6 +13682,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if _env_tp and not _tool_progress_configured
else (_resolved_tp or _env_tp or "all")
)
# Tool progress grouping: "accumulate" (edit one bubble) or "separate" (one msg per tool)
progress_grouping = resolve_display_setting(user_config, platform_key, "tool_progress_grouping") or "accumulate"
# Disable tool progress for webhooks - they don't support message editing,
# so each progress line would be sent as a separate message.
from gateway.config import Platform
@@ -13498,18 +13693,32 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# in chat platforms while opting into concise mid-turn updates.
interim_assistant_messages_enabled = (
source.platform != Platform.WEBHOOK
and bool(
resolve_display_setting(
user_config,
platform_key,
"interim_assistant_messages",
True,
)
and _resolve_gateway_display_bool(
user_config,
platform_key,
"interim_assistant_messages",
default=True,
platform=source.platform,
require_platform_override_for={Platform.MATTERMOST},
)
)
# thinking_progress is independent — if enabled, we need the progress
# queue even when tool_progress is off (thinking relay uses same infra).
# Mattermost requires a per-platform opt-in: global scratch-text display
# is too easy to leak into busy public threads.
_thinking_enabled = _resolve_gateway_display_bool(
user_config,
platform_key,
"thinking_progress",
default=False,
platform=source.platform,
require_platform_override_for={Platform.MATTERMOST},
)
needs_progress_queue = tool_progress_enabled or _thinking_enabled
# Queue for progress messages (thread-safe)
progress_queue = queue.Queue() if tool_progress_enabled else None
progress_queue = queue.Queue() if needs_progress_queue else None
last_tool = [None] # Mutable container for tracking in closure
last_progress_msg = [None] # Track last message for dedup
repeat_count = [0] # How many times the same message repeated
@@ -13615,6 +13824,24 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.debug("tool-progress onboarding hint failed: %s", _hint_err)
return
# "_thinking" is assistant scratch text between tool calls. It
# is never ordinary tool progress: only relay it when the platform
# explicitly opted into thinking_progress. Handle both legacy
# callback shapes: ("_thinking", text) and
# ("reasoning.available", "_thinking", text, ...).
if event_type == "_thinking" or tool_name == "_thinking":
if not _thinking_enabled:
return
thinking_text = preview if tool_name == "_thinking" else tool_name
msg = f"💬 {thinking_text}" if thinking_text else None
if msg:
progress_queue.put(msg)
return
# If tool_progress is off, only _thinking passes through (above).
# Regular tool calls are suppressed.
if not tool_progress_enabled:
return
# Only act on tool.started events (ignore tool.completed, reasoning.available, etc.)
if event_type not in {"tool.started",}:
@@ -13760,10 +13987,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# - Feishu only honors reply_in_thread when sending a reply, so topic
# progress uses the triggering event message as the reply target
# - Other platforms should use explicit source.thread_id only
if source.platform == Platform.SLACK:
_progress_thread_id = source.thread_id or event_message_id
else:
_progress_thread_id = source.thread_id
_progress_thread_id = _resolve_progress_thread_id(
source.platform, source.thread_id, event_message_id,
)
_progress_metadata = (
self._thread_metadata_for_source(source, event_message_id)
if _progress_thread_id == source.thread_id
@@ -13796,7 +14022,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
progress_lines = [] # Accumulated tool lines for the CURRENT editable bubble
progress_msg_id = None # ID of the current progress message to edit
can_edit = True # False once an edit fails (platform doesn't support it)
can_edit = progress_grouping != "separate" # "separate" = one message per tool (pre-v0.9 behavior)
_last_edit_ts = 0.0 # Throttle edits to avoid Telegram flood control
_PROGRESS_EDIT_INTERVAL = 1.5 # Minimum seconds between edits
@@ -14563,6 +14789,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_pdc = getattr(_status_adapter, "_post_delivery_callbacks", None)
if _pdc is not None:
_pdc[session_key] = _release_bg_review_messages
# Memory update notifications in chat. Config: display.memory_notifications
# off — no chat notification (still logged to stdout)
# on — generic "💾 Memory updated" (default)
# verbose — content preview: "💾 Memory Hermes Repo..."
_mem_notif = user_config.get("display", {}).get("memory_notifications")
if isinstance(_mem_notif, bool):
_mem_notif = "on" if _mem_notif else "off"
agent.memory_notifications = str(_mem_notif).lower() if _mem_notif else "on"
# ------------------------------------------------------------------
# Clarify callback: present a clarify prompt and block on a response.
@@ -14639,6 +14873,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
agent.clarify_callback = _clarify_callback_sync
# Show assistant thinking between tool calls — independent of
# tool_progress mode. Mattermost needs an explicit per-platform
# opt-in so global scratch-text display does not leak into threads.
agent.thinking_progress = _thinking_enabled
# Store agent reference for interrupt support
agent_holder[0] = agent
# Capture the full tool definitions for transcript logging
+150 -8
View File
@@ -394,20 +394,35 @@ class GatewaySlashCommandsMixin:
async def _handle_status_command(self, event: MessageEvent) -> str:
"""Handle /status command."""
from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model
source = event.source
session_entry = self.session_store.get_or_create_session(source)
connected_platforms = [p.value for p in self.adapters.keys()]
# Check if there's an active agent
# Check if there's an active agent. Keep the sentinel distinct: a
# starting/pending run should not be treated as a fully usable agent for
# model/context display, but it still occupies the session slot.
session_key = session_entry.session_key
is_running = session_key in self._running_agents
agent = self._running_agents.get(session_key)
is_running = agent is not None and agent is not _AGENT_PENDING_SENTINEL
# Count pending /queue follow-ups (slot + overflow).
adapter = self.adapters.get(source.platform) if source else None
queue_depth = self._queue_depth(session_key, adapter=adapter)
def _clean_str(value: Any) -> str:
return value.strip() if isinstance(value, str) and value.strip() else ""
def _int_value(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
title = None
session_row: dict[str, Any] = {}
# Pull token totals from the SQLite session DB rather than the
# in-memory SessionStore. The agent's per-turn token deltas are
# persisted into sessions_db (run_agent.py), not into SessionEntry,
@@ -422,17 +437,92 @@ class GatewaySlashCommandsMixin:
title = None
try:
row = self._session_db.get_session(session_entry.session_id)
if row:
if isinstance(row, dict):
session_row = row
db_total_tokens = (
(row.get("input_tokens") or 0)
+ (row.get("output_tokens") or 0)
+ (row.get("cache_read_tokens") or 0)
+ (row.get("cache_write_tokens") or 0)
+ (row.get("reasoning_tokens") or 0)
_int_value(row.get("input_tokens"))
+ _int_value(row.get("output_tokens"))
+ _int_value(row.get("cache_read_tokens"))
+ _int_value(row.get("cache_write_tokens"))
+ _int_value(row.get("reasoning_tokens"))
)
except Exception:
db_total_tokens = 0
# Resolve model/context for cockpit-style status. Prefer the live or
# cached agent because it carries the actual runtime route and context
# compressor. Fall back to persisted SessionDB metadata plus the
# SessionStore's last_prompt_tokens so /status remains useful between
# turns without making billing/account calls.
status_agent = agent if is_running else None
if status_agent is None:
cache_lock = getattr(self, "_agent_cache_lock", None)
cache = getattr(self, "_agent_cache", None)
if cache_lock is not None and cache is not None:
try:
with cache_lock:
cached = cache.get(session_key)
if cached:
status_agent = cached[0]
except Exception:
status_agent = None
model_name = ""
provider_name = ""
base_url = ""
context_used = 0
context_total = 0
if status_agent is not None and status_agent is not _AGENT_PENDING_SENTINEL:
model_name = _clean_str(getattr(status_agent, "model", ""))
provider_name = _clean_str(getattr(status_agent, "provider", ""))
base_url = _clean_str(getattr(status_agent, "base_url", ""))
ctx = getattr(status_agent, "context_compressor", None)
if ctx is not None:
context_used = _int_value(getattr(ctx, "last_prompt_tokens", 0))
context_total = _int_value(getattr(ctx, "context_length", 0))
model_name = model_name or _clean_str(session_row.get("model"))
provider_name = provider_name or _clean_str(session_row.get("billing_provider"))
base_url = base_url or _clean_str(session_row.get("billing_base_url"))
context_used = context_used or _int_value(getattr(session_entry, "last_prompt_tokens", 0))
user_config: dict[str, Any] = {}
if not model_name or not provider_name or not context_total:
try:
user_config = _load_gateway_config()
except Exception:
user_config = {}
if not model_name:
model_name = _resolve_gateway_model(user_config)
if not provider_name:
model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
if isinstance(model_cfg, dict):
provider_name = _clean_str(model_cfg.get("provider"))
if not context_total:
model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
configured_context = model_cfg.get("context_length") if isinstance(model_cfg, dict) else None
if isinstance(configured_context, int) and configured_context > 0:
context_total = configured_context
model_line = ""
if model_name:
if provider_name:
model_line = t("gateway.status.model_provider", model=model_name, provider=provider_name)
else:
model_line = t("gateway.status.model", model=model_name)
context_line = ""
if context_total:
pct = min(100, round((context_used / context_total) * 100)) if context_total else 0
context_line = t(
"gateway.status.context",
used=f"{context_used:,}",
total=f"{context_total:,}",
pct=f"{pct}",
)
elif context_used:
context_line = t("gateway.status.context_used", used=f"{context_used:,}")
lines = [
t("gateway.status.header"),
"",
@@ -443,6 +533,12 @@ class GatewaySlashCommandsMixin:
lines.extend([
t("gateway.status.created", timestamp=session_entry.created_at.strftime('%Y-%m-%d %H:%M')),
t("gateway.status.last_activity", timestamp=session_entry.updated_at.strftime('%Y-%m-%d %H:%M')),
])
if model_line:
lines.append(model_line)
if context_line:
lines.append(context_line)
lines.extend([
t("gateway.status.tokens", tokens=f"{db_total_tokens:,}"),
t("gateway.status.agent_running", state=t("gateway.status.state_yes") if is_running else t("gateway.status.state_no")),
])
@@ -2845,6 +2941,52 @@ class GatewaySlashCommandsMixin:
return t("gateway.resume.resumed_one", title=title, count=msg_count)
return t("gateway.resume.resumed_many", title=title, count=msg_count)
async def _handle_sessions_command(self, event: MessageEvent) -> str:
"""Handle /sessions — list previous sessions for gateway chats."""
if not self._session_db:
from hermes_state import format_session_db_unavailable
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
from hermes_cli.session_listing import (
format_gateway_session_listing,
parse_session_listing_args,
query_session_listing,
)
source = event.source
raw_args = event.get_command_args().strip()
try:
include_all, include_unnamed, target = parse_session_listing_args(raw_args)
except ValueError as exc:
return t("gateway.resume.parse_error", error=exc)
if target:
resume_event = dataclasses.replace(event, text=f"/resume {target}")
return await self._handle_resume_command(resume_event)
current_entry = self.session_store.get_or_create_session(source)
rows = query_session_listing(
self._session_db,
source=source.platform.value if source.platform else None,
current_session_id=current_entry.session_id,
include_all_sources=include_all,
include_unnamed=include_unnamed,
limit=10,
exclude_sources=["tool"],
)
if source.platform == Platform.MATRIX and not include_all:
rows = [
row for row in rows
if self._same_matrix_room(
source, self._gateway_session_origin_for_id(str(row.get("id") or ""))
)
]
return format_gateway_session_listing(
rows,
include_source=include_all,
title="Sessions" if include_unnamed else "Named Sessions",
)
async def _handle_branch_command(self, event: MessageEvent) -> str:
"""Handle /branch [name] — fork the current session into a new independent copy.
+15
View File
@@ -643,6 +643,21 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str,
live_cmdline = _read_process_cmdline(existing_pid)
if live_cmdline is not None or not _record_looks_like_gateway(existing):
stale = True
# Secondary defence against boot-time PID+start_time collisions:
# systemd spawns core services deterministically, so an unrelated
# process (e.g. cron) can land on the exact same PID and jiffy
# count as a previous gateway. If both start_times are known and
# match but the live process is not a gateway, and we can confirm
# that by reading its cmdline, the lock is stale.
if (
not stale
and existing.get("start_time") is not None
and current_start is not None
and not _looks_like_gateway_process(existing_pid)
):
live_cmdline = _read_process_cmdline(existing_pid)
if live_cmdline is not None:
stale = True
# Check if process is stopped (Ctrl+Z / SIGTSTP) — stopped
# processes still appear alive to _pid_exists but are not
# actually running. Treat them as stale so --replace works.
+43 -10
View File
@@ -197,6 +197,30 @@ class GatewayStreamConsumer:
# this response and route through edit-based for graceful degradation.
self._draft_failures = 0
def _metadata_for_send(
self,
*,
final: bool = False,
expect_edits: bool = False,
) -> dict | None:
"""Return per-send metadata for stream-created messages.
Mattermost treats notify-worthy sends as user-visible final content
when deciding whether a broken thread root may fall back flat. Preview
and progress sends keep their original metadata and remain thread-strict.
``expect_edits`` preserves the upstream Telegram streaming contract:
preview messages that may be edited later must stay on the editable
legacy send path, while fresh/fallback final sends can still use richer
final-message delivery.
"""
meta = dict(self.metadata) if self.metadata else {}
if expect_edits:
meta["expect_edits"] = True
if final:
meta["notify"] = True
return meta or None
@property
def already_sent(self) -> bool:
"""True if at least one message was sent or edited during the run."""
@@ -513,7 +537,11 @@ class GatewayStreamConsumer:
chunks_delivered = False
reply_to = self._message_id or self._initial_reply_to_id
for chunk in chunks:
new_id = await self._send_new_chunk(chunk, reply_to)
new_id = await self._send_new_chunk(
chunk,
reply_to,
final=got_done,
)
if new_id is not None and new_id != reply_to:
chunks_delivered = True
self._accumulated = ""
@@ -749,7 +777,13 @@ class GatewayStreamConsumer:
# Strip trailing whitespace/newlines but preserve leading content
return cleaned.rstrip()
async def _send_new_chunk(self, text: str, reply_to_id: Optional[str]) -> Optional[str]:
async def _send_new_chunk(
self,
text: str,
reply_to_id: Optional[str],
*,
final: bool = False,
) -> Optional[str]:
"""Send a new message chunk, optionally threaded to a previous message.
Returns the message_id so callers can thread subsequent chunks.
@@ -758,15 +792,11 @@ class GatewayStreamConsumer:
if not text.strip():
return reply_to_id
try:
meta = dict(self.metadata) if self.metadata else {}
# This chunk becomes the next edit target — adapters that support
# rich final sends (Telegram) must keep it on the editable path.
meta["expect_edits"] = True
result = await self.adapter.send(
chat_id=self.chat_id,
content=text,
reply_to=reply_to_id,
metadata=meta,
metadata=self._metadata_for_send(final=final, expect_edits=True),
)
if result.success and result.message_id:
self._message_id = str(result.message_id)
@@ -885,7 +915,7 @@ class GatewayStreamConsumer:
result = await self.adapter.send(
chat_id=self.chat_id,
content=chunk,
metadata=self.metadata,
metadata=self._metadata_for_send(final=True),
)
if result.success:
break
@@ -1242,7 +1272,7 @@ class GatewayStreamConsumer:
result = await self.adapter.send(
chat_id=self.chat_id,
content=text,
metadata=self.metadata,
metadata=self._metadata_for_send(final=True),
)
except Exception as e:
logger.debug("Fresh-final send failed, falling back to edit: %s", e)
@@ -1532,7 +1562,10 @@ class GatewayStreamConsumer:
chat_id=self.chat_id,
content=text,
reply_to=self._initial_reply_to_id,
metadata={**(self.metadata or {}), "expect_edits": True},
metadata=self._metadata_for_send(
final=finalize,
expect_edits=True,
),
)
if result.success:
if result.message_id:
+257 -17
View File
@@ -103,7 +103,12 @@ XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:acces
XAI_OAUTH_REDIRECT_HOST = "127.0.0.1"
XAI_OAUTH_REDIRECT_PORT = 56121
XAI_OAUTH_REDIRECT_PATH = "/callback"
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
# xAI/Grok OAuth access tokens are intentionally short-lived (about 6h in
# current SuperGrok flows). A two-minute refresh window is too narrow for
# gateway/cron workloads that may only touch the provider every 30 minutes,
# leaving brief but noisy credential-expiry gaps. Refresh up to one hour
# early so ordinary runtime calls keep the token warm without user reauth.
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 3600
QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
@@ -616,8 +621,8 @@ ZAI_ENDPOINTS = [
# (id, base_url, probe_models, label)
("global", "https://api.z.ai/api/paas/v4", ["glm-5"], "Global"),
("cn", "https://open.bigmodel.cn/api/paas/v4", ["glm-5"], "China"),
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
]
@@ -1079,8 +1084,13 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
return {"version": AUTH_STORE_VERSION, "providers": {}}
def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
auth_file = _auth_file_path()
def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = None) -> Path:
# target_path=None preserves the existing contract (write the active
# store at _auth_file_path()). An explicit path lets callers persist a
# specific store — e.g. the global-root write-through for rotating xAI
# OAuth grants (#43589) — reusing this function's atomic O_EXCL + 0o600
# write so the root auth.json gets the same TOCTOU-safe treatment.
auth_file = target_path if target_path is not None else _auth_file_path()
auth_file.parent.mkdir(parents=True, exist_ok=True)
# Tighten parent dir to 0o700 so siblings can't traverse to creds.
# No-op on Windows (POSIX mode bits not enforced); ignore failures.
@@ -3796,6 +3806,26 @@ def resolve_codex_runtime_credentials(
"last_refresh": None,
"auth_mode": "chatgpt",
}
pool_rate_limit = _codex_pool_rate_limit_status()
if pool_rate_limit:
reset_at = pool_rate_limit.get("reset_at")
if isinstance(reset_at, (int, float)) and reset_at > time.time():
remaining = int(reset_at - time.time())
message = (
f"Codex provider quota exhausted (429); retry after {remaining}s. "
"Credentials are still valid."
)
else:
message = (
"Codex provider quota exhausted (429). Credentials are still valid; "
"retry after the usage limit resets."
)
raise AuthError(
message,
provider="openai-codex",
code=CODEX_RATE_LIMITED_CODE,
relogin_required=False,
)
if read_error is not None:
raise read_error
raise AuthError(
@@ -3842,6 +3872,79 @@ def resolve_codex_runtime_credentials(
}
def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]:
"""Return metadata for a pool-only Codex credential in quota cooldown."""
def _parse_reset_at(value: Any) -> Optional[float]:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
numeric = float(value)
if numeric <= 0:
return None
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
try:
numeric = float(raw)
except ValueError:
numeric = None
if numeric is not None:
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
return None
try:
with _auth_store_lock():
auth_store = _load_auth_store()
pool = auth_store.get("credential_pool")
if not isinstance(pool, dict):
return None
entries = pool.get("openai-codex")
if not isinstance(entries, list):
return None
now = time.time()
for entry in entries:
if not isinstance(entry, dict):
continue
token = entry.get("access_token")
if not isinstance(token, str) or not token.strip():
continue
if entry.get("last_status") != "exhausted":
continue
code = entry.get("last_error_code")
reason = str(entry.get("last_error_reason") or "").lower()
message = str(entry.get("last_error_message") or "").lower()
is_rate_limited = (
code == 429
or "rate_limit" in reason
or "usage_limit" in reason
or "quota" in reason
or "rate limit" in message
or "usage limit" in message
or "quota" in message
)
if not is_rate_limited:
continue
reset_at = _parse_reset_at(entry.get("last_error_reset_at"))
if reset_at is not None and reset_at <= now:
continue
return {
"label": entry.get("label"),
"last_refresh": entry.get("last_refresh"),
"reset_at": reset_at,
"reason": entry.get("last_error_reason"),
"message": entry.get("last_error_message"),
}
except Exception:
logger.debug("Codex pool rate-limit lookup failed", exc_info=True)
return None
def _pool_codex_access_token() -> str:
"""Return the most-recent usable access_token from the openai-codex pool.
@@ -3886,13 +3989,64 @@ def _pool_codex_access_token() -> str:
# xAI Grok OAuth — tokens stored in ~/.hermes/auth.json
# =============================================================================
def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Return usable xAI OAuth state from provider state or credential pool."""
state = _load_provider_state(auth_store, "xai-oauth")
tokens = state.get("tokens") if isinstance(state, dict) else None
if isinstance(tokens, dict):
access_token = str(tokens.get("access_token", "") or "").strip()
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
if access_token and refresh_token:
return state
credential_pool = auth_store.get("credential_pool")
entries = (
credential_pool.get("xai-oauth")
if isinstance(credential_pool, dict)
else None
)
if isinstance(entries, list):
for entry in entries:
if not isinstance(entry, dict):
continue
access_token = str(entry.get("access_token", "") or "").strip()
refresh_token = str(entry.get("refresh_token", "") or "").strip()
if not access_token or not refresh_token:
continue
merged = dict(state or {})
merged["tokens"] = {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": str(entry.get("token_type") or "Bearer"),
}
if entry.get("last_refresh"):
merged["last_refresh"] = entry.get("last_refresh")
merged.setdefault("auth_mode", "oauth_pkce")
return merged
return state if isinstance(state, dict) else None
def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool:
tokens = state.get("tokens") if isinstance(state, dict) else None
return (
isinstance(tokens, dict)
and bool(str(tokens.get("access_token", "") or "").strip())
and bool(str(tokens.get("refresh_token", "") or "").strip())
)
def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
if _lock:
with _auth_store_lock():
auth_store = _load_auth_store()
else:
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "xai-oauth")
state = _xai_oauth_state_from_store(auth_store)
if not _xai_oauth_state_has_usable_tokens(state):
global_state = _xai_oauth_state_from_store(_load_global_auth_store())
if _xai_oauth_state_has_usable_tokens(global_state):
state = global_state
if not state:
raise AuthError(
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
@@ -3932,6 +4086,62 @@ def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
}
def _profile_has_own_xai_oauth_state(auth_store: Dict[str, Any]) -> bool:
"""True when this store has its OWN ``providers.xai-oauth`` block.
Distinguishes a profile that genuinely shadows the root xAI grant from
one that only *reads* root via ``_load_provider_state``'s fallback. Only
the latter needs the refresh write-through below.
"""
providers = auth_store.get("providers")
return isinstance(providers, dict) and isinstance(providers.get("xai-oauth"), dict)
def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None:
"""Persist a rotated xAI OAuth ``state`` into the global-root auth.json.
Best-effort write-through for the multi-profile rotation hazard (#43589):
xAI rotates the refresh_token on every refresh, so when a profile session
refreshes a grant it resolved from the root fallback, the rotated chain
must land back in root. Otherwise root keeps a now-revoked refresh token
and every other profile reading the stale root grant dies with
``invalid_grant`` once its access token expires.
Only updates ``providers.xai-oauth`` in the root store; never touches the
profile store (the caller already saved that). Swallows all errors a
failed write-through degrades to the pre-existing behavior (root stale),
it must never break the profile's own successful save.
"""
global_path = _global_auth_file_path()
if global_path is None:
# Classic mode (profile == root); the profile save already hit root.
return
# Seat belt: under pytest, refuse to write the real user's
# ~/.hermes/auth.json even when HERMES_HOME points at a profile path
# (mirrors the read-side guard in _load_global_auth_store). Uses the
# unmodified HOME env, not Path.home() which fixtures may monkeypatch.
if os.environ.get("PYTEST_CURRENT_TEST"):
real_home_env = os.environ.get("HOME", "")
if real_home_env:
real_root = Path(real_home_env) / ".hermes" / "auth.json"
try:
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
return
except Exception:
return
try:
if global_path.exists():
global_store = _load_auth_store(global_path)
else:
global_store = {}
if not isinstance(global_store, dict):
return
_store_provider_state(global_store, "xai-oauth", dict(state), set_active=False)
_save_auth_store(global_store, global_path)
except Exception as exc: # pragma: no cover - best effort
logger.debug("xAI OAuth: write-through to global root failed: %s", exc)
def _save_xai_oauth_tokens(
tokens: Dict[str, Any],
*,
@@ -3943,6 +4153,11 @@ def _save_xai_oauth_tokens(
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
with _auth_store_lock():
auth_store = _load_auth_store()
# A profile that lacks its own xai-oauth block is reading the root
# grant through _load_provider_state's fallback. When such a profile
# refreshes the (rotating) grant, we must write the rotated chain back
# to root too, or root is left holding a revoked refresh token (#43589).
write_through_to_root = not _profile_has_own_xai_oauth_state(auth_store)
state = _load_provider_state(auth_store, "xai-oauth") or {}
state["tokens"] = tokens
state["last_refresh"] = last_refresh
@@ -3953,6 +4168,8 @@ def _save_xai_oauth_tokens(
state["redirect_uri"] = redirect_uri
_save_provider_state(auth_store, "xai-oauth", state)
_save_auth_store(auth_store)
if write_through_to_root:
_write_through_xai_oauth_to_global_root(state)
def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:
@@ -5639,18 +5856,24 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]:
# subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms"
# was firing the refresh ~31× during one menu paint, racking up >13s of HTTP and burning
# single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json
# mtime so that `hermes auth login/logout/add/remove` invalidate naturally on the next call.
# path + mtime so that profile switches do not share a process memo and
# `hermes auth login/logout/add/remove` invalidate naturally on the next call.
_NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds
_nous_auth_status_cache: Optional[Tuple[float, Optional[float], Dict[str, Any]]] = None
_nous_auth_status_cache: Optional[Tuple[float, str, Optional[float], Dict[str, Any]]] = None
def _auth_file_mtime() -> Optional[float]:
def _auth_file_cache_key() -> Tuple[str, Optional[float]]:
auth_file = _auth_file_path()
try:
return _auth_file_path().stat().st_mtime
except FileNotFoundError:
return None
auth_file_key = str(auth_file.resolve(strict=False))
except Exception:
return None
auth_file_key = str(auth_file)
try:
return auth_file_key, auth_file.stat().st_mtime
except FileNotFoundError:
return auth_file_key, None
except Exception:
return auth_file_key, None
def invalidate_nous_auth_status_cache() -> None:
@@ -5682,18 +5905,19 @@ def get_nous_auth_status() -> Dict[str, Any]:
"""
global _nous_auth_status_cache
now = time.monotonic()
mtime = _auth_file_mtime()
auth_file_key, mtime = _auth_file_cache_key()
cached = _nous_auth_status_cache
if cached is not None:
cached_at, cached_mtime, cached_status = cached
cached_at, cached_auth_file_key, cached_mtime, cached_status = cached
if (
cached_mtime == mtime
cached_auth_file_key == auth_file_key
and cached_mtime == mtime
and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL
):
return dict(cached_status)
status = _compute_nous_auth_status()
_nous_auth_status_cache = (now, mtime, dict(status))
_nous_auth_status_cache = (now, auth_file_key, mtime, dict(status))
return status
@@ -5776,6 +6000,22 @@ def get_codex_auth_status() -> Dict[str, Any]:
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
"api_key": api_key,
}
rate_limit = _codex_pool_rate_limit_status()
if rate_limit:
return {
"logged_in": True,
"auth_store": str(_auth_file_path()),
"last_refresh": rate_limit.get("last_refresh"),
"auth_mode": "chatgpt",
"source": f"pool:{rate_limit.get('label') or 'unknown'}",
"rate_limited": True,
"error_code": CODEX_RATE_LIMITED_CODE,
"error": (
rate_limit.get("message")
or "Codex provider quota exhausted; retry after the usage limit resets."
),
"reset_at": rate_limit.get("reset_at"),
}
except Exception:
pass
+1
View File
@@ -510,6 +510,7 @@ _QUICK_STATE_FILES = (
"cron/jobs.json",
"gateway_state.json",
"channel_directory.json",
"channel_aliases.json",
"processes.json",
# Pairing stores (generic + per-platform JSONs outside state.db)
"pairing", # legacy location (gateway/pairing.py)
+35 -5
View File
@@ -225,7 +225,8 @@ class CLICommandsMixin:
print(" Usage: /snapshot [list|create [label]|restore <id>|prune [N]]")
def _handle_stop_command(self):
"""Handle /stop — kill all running background processes.
"""Handle /stop — kill all running background processes and
background (async) delegations.
Inspired by OpenAI Codex's separation of interrupt (stop current turn)
from /stop (clean up background processes). See openai/codex#14602.
@@ -235,13 +236,26 @@ class CLICommandsMixin:
processes = process_registry.list_sessions()
running = [p for p in processes if p.get("status") == "running"]
if not running:
# Background subagents dispatched via delegate_task(background=true)
# live in their own registry, not the process registry.
try:
from tools.async_delegation import active_count, interrupt_all
n_async = active_count()
except Exception:
n_async = 0
interrupt_all = None
if not running and not n_async:
print(" No running background processes.")
return
print(f" Stopping {len(running)} background process(es)...")
killed = process_registry.kill_all()
print(f" ✅ Stopped {killed} process(es).")
if running:
print(f" Stopping {len(running)} background process(es)...")
killed = process_registry.kill_all()
print(f" ✅ Stopped {killed} process(es).")
if n_async and interrupt_all is not None:
stopped = interrupt_all(reason="/stop")
print(f" ✅ Interrupted {stopped} background delegation(s).")
def _handle_agents_command(self):
"""Handle /agents — show background processes and agent status."""
@@ -261,6 +275,22 @@ class CLICommandsMixin:
if finished:
_cprint(f" Recently finished: {len(finished)}")
# Background (async) delegations — delegate_task(background=true)
try:
from tools.async_delegation import list_async_delegations
delegations = list_async_delegations()
except Exception:
delegations = []
running_d = [d for d in delegations if d.get("status") == "running"]
if delegations:
_cprint(f" Background delegations: {len(running_d)} running")
for d in delegations:
goal = (d.get("goal") or "")[:60]
_cprint(
f" {d.get('delegation_id', '?')} · "
f"{d.get('status', '?')} · {goal}"
)
agent_running = getattr(self, "_agent_running", False)
_cprint(f" Agent: {'running' if agent_running else 'idle'}")
+3 -2
View File
@@ -109,7 +109,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="[text | pause | resume | clear | status]"),
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
args_hint="[text | remove N | clear]"),
CommandDef("status", "Show session info", "Session"),
CommandDef("status", "Show session, model, token, and context info", "Session"),
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
CommandDef("profile", "Show active profile name and home directory", "Info"),
CommandDef("sethome", "Set this chat as the home channel", "Session",
@@ -1053,7 +1053,8 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
# the telegram-parity test reads it so an entry here is a deliberate
# "Slack-via-/hermes" decision, not a silent clamp.
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits"})
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "debug"})
def _sanitize_slack_name(raw: str) -> str:
+14 -1
View File
@@ -1428,6 +1428,12 @@ DEFAULT_CONFIG = {
"tui_agents_nudge": True,
"bell_on_complete": False,
"show_reasoning": False,
# Background self-improvement review notifications surfaced in chat.
# "off" — no chat notification (the review still runs and writes)
# "on" — generic "💾 Memory updated" line (default)
# "verbose" — include a compact content preview of what changed
# Per-platform overrides via display.platforms.<platform>.memory_notifications.
"memory_notifications": "on",
"streaming": False,
"timestamps": False, # Show [HH:MM] on user and assistant labels
"final_response_markdown": "strip", # render | strip | raw
@@ -1479,6 +1485,12 @@ DEFAULT_CONFIG = {
"tool_progress_command": False, # Enable /verbose command in messaging gateway
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
# How gateway tool-progress is grouped on platforms that support message
# editing: "accumulate" (default) edits one bubble in place; "separate"
# sends one message per tool (the pre-v0.9 behavior, noisier). Only
# applies where tool_progress is already enabled. Per-platform override
# via display.platforms.<platform>.tool_progress_grouping.
"tool_progress_grouping": "accumulate",
# Auto-delete system-notice replies (e.g. "✨ New session started!",
# "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
# that support message deletion (currently Telegram; other platforms
@@ -1775,6 +1787,7 @@ DEFAULT_CONFIG = {
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
# "low", "minimal", "none" (empty = inherit parent's level)
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
"max_async_children": 3, # max concurrent background (background=true) subagents; new dispatches rejected at capacity
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
# raise deliberately, each level multiplies API cost.
@@ -1990,7 +2003,7 @@ DEFAULT_CONFIG = {
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
"allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
"extra": {
"rich_messages": False, # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
"rich_messages": True, # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2
},
},
+76 -11
View File
@@ -28,11 +28,14 @@ from typing import Literal, Sequence
log = logging.getLogger(__name__)
# Only this prior state triggers automatic restart. Everything else
# Only this desired state triggers automatic restart. Everything else
# (startup_failed, starting, stopped, missing) registers the slot in
# the down state and waits for explicit user action — this avoids the
# crash-loop where a broken gateway keeps being restarted across
# `docker restart` cycles.
# `docker restart` cycles. Older installs only have gateway_state;
# newer lifecycle commands persist desired_state separately so a transient
# runtime state (draining/startup_failed) does not erase the operator's
# durable start/stop intent across pod/container recreation.
_AUTOSTART_STATES = frozenset({"running"})
# Stale runtime files we sweep before recreating service slots. These
@@ -104,7 +107,7 @@ def reconcile_profile_gateways(
container_argv=container_argv,
dry_run=dry_run,
)
default_prior_state = legacy_default_state or _read_prior_state(hermes_home)
default_prior_state = legacy_default_state or _read_desired_state(hermes_home)
default_should_start = default_prior_state in _AUTOSTART_STATES
if not dry_run:
_cleanup_stale_runtime_files(hermes_home)
@@ -139,7 +142,7 @@ def reconcile_profile_gateways(
)
continue
prior_state = _read_prior_state(entry)
prior_state = _read_desired_state(entry)
should_start = prior_state in _AUTOSTART_STATES
if not dry_run:
@@ -188,6 +191,7 @@ def _maybe_migrate_legacy_gateway_run_state(
import time
state_file.write_text(json.dumps({
"gateway_state": "running",
"desired_state": "running",
"timestamp": int(time.time()),
"migrated_from": "legacy-container-cmd",
}) + "\n")
@@ -203,8 +207,15 @@ def _read_container_argv() -> tuple[str, ...]:
return tuple(part.decode("utf-8", "replace") for part in raw.split(b"\0") if part)
def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
"""Return True for Docker commands equivalent to `gateway run`."""
def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]:
"""Strip the s6/wrapper prefix off PID 1 argv, leaving the hermes args.
The container PID 1 argv looks like
``/init /opt/hermes/docker/main-wrapper.sh <subcommand> [args...]`` and
the wrapper re-execs ``hermes <subcommand>``. Peel ``init``
``main-wrapper.sh`` ``hermes`` so callers can match on the bare
subcommand. Shared by the legacy-gateway and dashboard role detectors.
"""
args = list(argv)
if args and Path(args[0]).name == "init":
args = args[1:]
@@ -212,20 +223,58 @@ def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
args = args[1:]
if args and Path(args[0]).name == "hermes":
args = args[1:]
return args
def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
"""Return True for Docker commands equivalent to `gateway run`."""
args = _strip_container_argv_prefix(argv)
if "--no-supervise" in args:
return False
return len(args) >= 2 and args[0] == "gateway" and args[1] == "run"
def _read_prior_state(profile_dir: Path) -> str | None:
"""Read gateway_state.json's ``gateway_state`` field, or None if
missing or unparseable. Unparseable counts as "no prior state" so
we don't bork the whole reconciliation on a corrupt file."""
def _is_dashboard_container(argv: Sequence[str]) -> bool:
"""Return True when the container's command is the dashboard.
A dashboard-only container (``hermes dashboard ...``) never spawns or
supervises per-profile gateways that is the gateway container's job.
Reconciling profile gateway s6 slots there is not just wasted work: when
the gateway and dashboard containers share a bind-mounted HERMES_HOME,
both race to ``flock()`` the same ``logs/gateways/<profile>/lock`` files,
producing "Resource busy" failures and an s6-log restart storm. So the
dashboard container skips reconciliation entirely.
Detected from PID 1 argv (``/proc/1/cmdline``) rather than an operator
flag: the role is a fact about the container's command, not a tunable,
and a flag can be forgotten in a hand-written compose/k8s manifest
reintroducing the exact storm this prevents. Mirrors the argv handling
in :func:`_is_legacy_gateway_run_request`.
"""
args = _strip_container_argv_prefix(argv)
return bool(args) and args[0] == "dashboard"
def _read_desired_state(profile_dir: Path) -> str | None:
"""Read the persisted gateway desired state for reconciliation.
Newer state files carry ``desired_state``: operator intent written by
s6 lifecycle commands. Older files only carry ``gateway_state``; keep
that as a compatibility fallback so existing running/stopped profiles
preserve their behavior until the next explicit start/stop.
Missing or unparseable files count as "no desired state" so we don't
bork the whole reconciliation on a corrupt file.
"""
state_file = profile_dir / "gateway_state.json"
if not state_file.exists():
return None
try:
return json.loads(state_file.read_text()).get("gateway_state")
data = json.loads(state_file.read_text())
desired_state = data.get("desired_state")
if desired_state is not None:
return desired_state
return data.get("gateway_state")
except (OSError, json.JSONDecodeError):
log.warning(
"could not read %s; treating as no prior state", state_file,
@@ -378,6 +427,22 @@ _LOG_ROTATE_BYTES = 256 * 1024
def main() -> int:
"""Entry point invoked from /etc/cont-init.d/02-reconcile-profiles."""
# A dashboard-only container never spawns or supervises per-profile
# gateways, so reconciling their s6 slots here is pure waste — and
# actively harmful: when the gateway and dashboard containers share a
# bind-mounted HERMES_HOME, both race to flock() the same s6-log lock
# files under logs/gateways/<profile>/lock, producing "Resource busy"
# failures and a restart storm. Detect the role from PID 1 argv and
# skip reconciliation in the dashboard container. No operator flag:
# the role is a fact about the container's command, and a flag can be
# forgotten in a hand-written manifest, reintroducing the storm.
if _is_dashboard_container(_read_container_argv()):
print(
"reconcile: skipping (dashboard container — does not need "
"per-profile gateways)"
)
return 0
hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", "/run/service"))
actions = reconcile_profile_gateways(
+1
View File
@@ -796,6 +796,7 @@ def run_doctor(args):
"huggingface",
"lmstudio",
"nous",
"nvidia",
}
provider_accepts_vendor_slug = (
provider_policy_id in providers_accepting_vendor_slugs
+17 -2
View File
@@ -252,9 +252,24 @@ def run_dump(args):
except Exception:
profile = "(default)"
# Terminal backend
# Terminal backend — report the EFFECTIVE backend, not just config.yaml.
# ``terminal.backend`` in config.yaml is bridged to the TERMINAL_ENV env var,
# but a TERMINAL_ENV set directly in .env / the shell overrides config and is
# what terminal_tool actually uses (tools/terminal_tool.py reads TERMINAL_ENV).
# Reporting only the config value hides that override and sends users chasing
# the wrong cause when the agent runs in a docker/podman sandbox even though
# config says "local" (and vice-versa). run_dump() has already loaded .env,
# so os.environ reflects the real override here.
terminal_cfg = config.get("terminal", {})
backend = terminal_cfg.get("backend", "local")
config_backend = terminal_cfg.get("backend", "local")
env_backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower()
if env_backend and env_backend != str(config_backend).strip().lower():
backend = (
f"{env_backend} (TERMINAL_ENV overrides config.yaml "
f"terminal.backend={config_backend})"
)
else:
backend = config_backend
# OpenAI SDK version
try:
+30
View File
@@ -157,6 +157,36 @@ def build_models_payload(
max_models=max_models,
)
# --- Deduplicate: remove models from aggregators that overlap with
# user-defined providers. When a local proxy (e.g. litellm-proxy)
# serves a model whose name also appears in an aggregator's curated
# catalog, the picker would show the model under both providers.
# Selecting it from the aggregator row sets model.provider to the
# aggregator (e.g. openrouter) instead of the user's proxy — silently
# breaking the call. Filtering at the payload level keeps the
# aggregator rows honest: they only show models the user can't get
# from a more-specific provider. (#45954)
try:
from hermes_cli.providers import is_aggregator as _is_aggregator
except Exception:
_is_aggregator = None # type: ignore[assignment]
if _is_aggregator is not None:
user_models: set[str] = set()
for row in rows:
if row.get("is_user_defined"):
user_models.update(m.lower() for m in (row.get("models") or []))
if user_models:
for row in rows:
slug = row.get("slug", "")
if not _is_aggregator(slug):
continue
original = row.get("models") or []
filtered = [m for m in original if m.lower() not in user_models]
if len(filtered) < len(original):
row["models"] = filtered
row["total_models"] = len(filtered)
if include_unconfigured:
rows = list(rows) + _append_unconfigured_rows(rows, ctx)
if picker_hints:
+37 -4
View File
@@ -452,8 +452,19 @@ def _apply_profile_override() -> None:
if Path(hermes_home_env).parent.name == "profiles":
return
# 2. If no flag, check active_profile in the hermes root
if profile_name is None:
# 2. If no flag, check active_profile in the hermes root.
#
# EXCEPTION: a supervised s6 gateway child (exported by the container
# run-script as HERMES_S6_SUPERVISED_CHILD=1) must NOT follow the sticky
# active_profile. Each supervised slot has a fixed profile identity: named
# slots pass ``-p <name>`` explicitly (handled in step 1 above), and the
# reserved ``gateway-default`` slot runs bare ``hermes gateway run`` to mean
# "the root HERMES_HOME profile". If the reserved default child read
# active_profile here, switching the active profile (e.g. via the dashboard)
# would silently redirect the default gateway into that profile — yielding a
# duplicate gateway for the active profile and no real default gateway. See
# the "Docker & Profiles & Dashboard" report.
if profile_name is None and not os.environ.get("HERMES_S6_SUPERVISED_CHILD"):
try:
from hermes_constants import get_default_hermes_root
@@ -8259,10 +8270,16 @@ def _discard_lockfile_churn(git_cmd, repo_root):
)
if diff.returncode != 0:
return
dirty_package_dirs = {
Path(line.strip()).parent
for line in diff.stdout.splitlines()
if line.strip().endswith("package.json")
}
dirty = [
line.strip()
for line in diff.stdout.splitlines()
if line.strip().endswith("package-lock.json")
and Path(line.strip()).parent not in dirty_package_dirs
]
if not dirty:
return
@@ -10753,8 +10770,24 @@ def cmd_dashboard(args):
if getattr(args, "skip_build", False):
reexec_argv.append("--skip-build")
env = os.environ.copy()
# Drop the profile HERMES_HOME so the child binds the machine root.
env.pop("HERMES_HOME", None)
# Pin the child to the machine ROOT, not the launching profile's
# HERMES_HOME. We must resolve the root explicitly instead of just
# dropping HERMES_HOME: in the Docker layout the machine root is
# /opt/data (set via `ENV HERMES_HOME=/opt/data`), so an unset
# HERMES_HOME falls back to $HOME/.hermes = /opt/data/.hermes — an
# empty, auto-seeded home where the dashboard sees only the default
# profile and the install-method stamp is missing (so the Docker
# update-button guard also misfires). get_default_hermes_root()
# returns the root for both layouts: ~/.hermes for a standard install
# and /opt/data for Docker (it strips a trailing profiles/<name>).
# See the support report for the double-mount workaround this avoids.
try:
from hermes_constants import get_default_hermes_root
env["HERMES_HOME"] = str(get_default_hermes_root())
except Exception:
# Best-effort: if root resolution fails, fall back to the prior
# behaviour (drop HERMES_HOME) rather than block the reroute.
env.pop("HERMES_HOME", None)
# On Windows, os.execvpe() does not truly replace the process — it
# spawns via CreateProcess then the parent exits. Under Python 3.14+
# this can crash with STATUS_ACCESS_VIOLATION (0xC0000005) when
+1 -12
View File
@@ -1853,18 +1853,7 @@ def _model_flow_kimi(config, current_model=""):
print()
# Step 3: Model selection — show appropriate models for the endpoint
if is_coding_plan:
# Coding Plan models (kimi-k2.6 first)
model_list = [
"kimi-k2.6",
"kimi-k2.5",
"kimi-for-coding",
"kimi-k2-thinking",
"kimi-k2-thinking-turbo",
]
else:
# Legacy Moonshot models (excludes Coding Plan-only models)
model_list = _PROVIDER_MODELS.get("moonshot", [])
model_list = _PROVIDER_MODELS.get("kimi-coding" if is_coding_plan else "moonshot", [])
if model_list:
selected = _prompt_model_selection(
+11
View File
@@ -257,6 +257,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gemini-3.5-flash",
],
"zai": [
"glm-5.2",
"glm-5.1",
"glm-5",
"glm-5v-turbo",
@@ -281,6 +282,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"openai/gpt-oss-120b",
],
"kimi-coding": [
"kimi-k2.7-code",
"kimi-k2.6",
"kimi-k2.5",
"kimi-for-coding",
@@ -2368,6 +2370,15 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
if api_key:
live = _p.fetch_models(api_key=api_key)
if live:
if normalized in {"kimi-coding", "kimi-coding-cn"}:
curated = list(_PROVIDER_MODELS.get(normalized, []))
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 live
# Use profile's fallback_models if defined
if _p.fallback_models:
+40 -1
View File
@@ -455,6 +455,37 @@ def remove_wrapper_script(name: str) -> bool:
return False
def _migrate_profile_config_if_outdated(profile_dir: Path) -> None:
"""Bring a copied profile config.yaml up to the current schema.
Profile creation can clone a config file that predates schema tracking (no
``_config_version``) or that is simply older than the running Hermes. If we
leave it untouched, the first desktop/doctor view of the new profile shows a
scary ``v0 latest`` warning even though we just created the profile. Scope
the normal migration pipeline to the new profile and keep it non-interactive.
"""
config_path = profile_dir / "config.yaml"
if not config_path.exists():
return
try:
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from hermes_cli.config import check_config_version, migrate_config
token = set_hermes_home_override(str(profile_dir))
try:
current_ver, latest_ver = check_config_version()
if current_ver < latest_ver:
migrate_config(interactive=False, quiet=True)
finally:
reset_hermes_home_override(token)
except Exception:
# Profile creation should not fail because an old copied config could
# not be migrated. The next `hermes doctor --fix` can still surface the
# detailed error in the target profile.
pass
def find_alias_for_profile(profile_name: str) -> Optional[str]:
"""Return the alias name of the wrapper that activates *profile_name*, or None.
@@ -910,6 +941,14 @@ def create_profile(
except OSError:
pass # best-effort — the feature still works via the empty skills/ dir
# Cloned configs can be older than the running Hermes (or predate schema
# tracking entirely). Migrate config-only clones immediately so
# desktop/status surfaces don't warn that a just-created profile is
# v0/outdated. Leave --clone-all snapshots byte-for-byte apart from the
# explicit runtime/history stripping above.
if not clone_all:
_migrate_profile_config_if_outdated(profile_dir)
# Persist description if the caller provided one. Done last so a
# partial-create failure doesn't strand a description file in an
# incomplete profile.
@@ -1229,7 +1268,7 @@ def _maybe_register_gateway_service(profile_name: str) -> None:
if not mgr.supports_runtime_registration():
return # host backend; no-op
try:
mgr.register_profile_gateway(profile_name)
mgr.register_profile_gateway(profile_name, start_now=False)
except ValueError:
# Already registered (e.g. the container-boot reconciler ran
# first and brought up a stale slot). That's fine.
+93 -8
View File
@@ -77,6 +77,7 @@ class ServiceManager(Protocol):
profile: str,
*,
extra_env: dict[str, str] | None = None,
start_now: bool = True,
) -> None: ...
def unregister_profile_gateway(self, profile: str) -> None: ...
def list_profile_gateways(self) -> list[str]: ...
@@ -86,7 +87,8 @@ def detect_service_manager() -> ServiceManagerKind:
"""Detect which service manager is available in this environment.
Returns:
"s6" inside a container when /init is s6-svscan (Phase 2+)
"s6" s6-svscan is PID 1 (s6-overlay image; Docker, Podman, or a
Fly Firecracker microVM)
"windows" native Windows host
"launchd" macOS host
"systemd" Linux host with a working user/system bus
@@ -100,14 +102,20 @@ def detect_service_manager() -> ServiceManagerKind:
# Imports deferred so importing this module doesn't drag in the
# whole gateway dependency graph for callers that only need the
# Protocol type or validate_profile_name().
from hermes_constants import is_container
from hermes_cli.gateway import (
is_macos,
is_windows,
supports_systemd_services,
)
if is_container() and _s6_running():
# Gate on _s6_running() alone (PID 1 comm == s6-svscan AND /run/s6/basedir),
# NOT is_container(): the latter only detects Docker/Podman/lxc, so it is
# False on Fly's Firecracker microVMs even though s6-overlay is PID 1 there.
# That false negative made the whole s6 dispatch path inert on Fly, so
# `hermes gateway start/stop/restart` fell through to host code that spawns
# a foreground gateway competing with the supervised one. _s6_running() is
# already an s6-overlay-specific signal, so the container gate was redundant.
if _s6_running():
return "s6"
if is_windows():
return "windows"
@@ -175,6 +183,7 @@ class _RegistrationUnsupportedMixin:
profile: str,
*,
extra_env: dict[str, str] | None = None,
start_now: bool = True,
) -> None:
raise NotImplementedError(
f"{type(self).__name__} does not support runtime profile "
@@ -325,6 +334,62 @@ def get_service_manager() -> ServiceManager:
S6_DYNAMIC_SCANDIR = Path("/run/service")
S6_SERVICE_PREFIX = "gateway-"
def _profile_dir_for_gateway_service(name: str) -> Path:
"""Resolve ``gateway-<profile>`` to its persistent profile directory.
s6 lifecycle commands may be invoked from any active profile, including
``gateway stop --all``. Do not write the caller's HERMES_HOME blindly;
derive the shared profile root from the current HERMES_HOME and map the
service suffix to either the root default profile or
``<root>/profiles/<profile>``.
"""
import os
profile = name[len(S6_SERVICE_PREFIX):] if name.startswith(S6_SERVICE_PREFIX) else name
validate_profile_name(profile)
hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
if hermes_home.parent.name == "profiles":
root = hermes_home.parent.parent
else:
root = hermes_home
return root if profile == "default" else root / "profiles" / profile
def _write_gateway_desired_state(name: str, desired_state: str) -> None:
"""Persist durable s6 gateway intent next to runtime status.
``gateway_state`` remains the volatile runtime field written by the
gateway process. ``desired_state`` records the operator's start/stop
intent so container-boot reconciliation can restore the correct s6
want-up/want-down state after pod recreation even if the previous runtime
state was transient (draining, startup_failed, etc.). The write is
best-effort: a failed persistence attempt must not prevent immediate s6
lifecycle control.
"""
import json
import time
profile_dir = _profile_dir_for_gateway_service(name)
state_file = profile_dir / "gateway_state.json"
try:
if not profile_dir.exists():
return
try:
data = json.loads(state_file.read_text()) if state_file.exists() else {}
if not isinstance(data, dict):
data = {}
except (OSError, json.JSONDecodeError):
data = {}
data["desired_state"] = desired_state
data["updated_at"] = int(time.time())
tmp = state_file.with_suffix(state_file.suffix + ".tmp")
tmp.write_text(json.dumps(data, separators=(",", ":")) + "\n")
tmp.replace(state_file)
except OSError:
return
# s6-overlay installs its binaries under /command/ and only adds that
# directory to PATH for processes started under the supervision tree
# (services started by s6-svscan, cont-init.d scripts, etc.). Code
@@ -680,7 +745,17 @@ class S6ServiceManager:
f': "${{HERMES_HOME:=/opt/data}}"\n'
f'log_dir="$HERMES_HOME/logs/gateways/{prof}"\n'
f'mkdir -p "$log_dir"\n'
# The gateways/ parent must be chowned too (non-recursively):
# `mkdir -p` creates it root-owned on a root-context boot, and a
# leaf-only chown leaves it that way — every profile registered
# later then runs its log service as hermes and crash-loops on
# `mkdir: Permission denied`. The parent chown runs on every
# root-context boot, so it also heals volumes already poisoned
# by older images. Non-recursive on purpose: sibling profile
# dirs are each managed by their own log/run. See #45258.
f'chown hermes:hermes "$HERMES_HOME/logs/gateways" 2>/dev/null || true\n'
f'chown -R hermes:hermes "$log_dir" 2>/dev/null || true\n'
f'rm -f "$log_dir/lock"\n'
# Skip the drop when already non-root (CAP_SETGID).
f'[ "$(id -u)" = 0 ] || exec s6-log 1 n10 s1000000 T "$log_dir"\n'
f'exec s6-setuidgid hermes s6-log 1 n10 s1000000 T "$log_dir"\n'
@@ -743,6 +818,7 @@ class S6ServiceManager:
(permission denied on the supervise FIFO, timeout, etc.).
"""
self._run_svc("-u", "start", name)
_write_gateway_desired_state(name, "running")
def _supervised_pid(self, name: str) -> int | None:
"""Return the PID of the supervised gateway process, or None.
@@ -794,6 +870,7 @@ class S6ServiceManager:
except Exception:
pass
self._run_svc("-d", "stop", name)
_write_gateway_desired_state(name, "stopped")
def restart(self, name: str) -> None:
"""Restart a registered service (``s6-svc -t`` = SIGTERM).
@@ -803,6 +880,7 @@ class S6ServiceManager:
S6CommandError: s6-svc exited non-zero for any other reason.
"""
self._run_svc("-t", "restart", name)
_write_gateway_desired_state(name, "running")
def is_running(self, name: str) -> bool:
"""True iff ``s6-svstat`` reports the service as up."""
@@ -823,15 +901,15 @@ class S6ServiceManager:
profile: str,
*,
extra_env: dict[str, str] | None = None,
start_now: bool = True,
) -> None:
"""Create the s6 service directory for a profile gateway.
Triggers ``s6-svscanctl -a`` so s6-svscan picks the new directory
up immediately. The service is created in the *up* state to
register without auto-starting, follow up with ``stop(profile)``
(or pass the start flag via the future ``start_now=False`` arg,
which the Phase 4 reconciliation path uses via a ``down``
marker file written directly).
up immediately. When *start_now* is ``True`` (the default) the
service starts immediately; when ``False`` a ``down`` marker file
is written so s6-supervise leaves the service stopped until the
user explicitly runs ``hermes -p <profile> gateway start``.
Raises:
ValueError: if the profile name is invalid or the service
@@ -879,6 +957,13 @@ class S6ServiceManager:
# rationale.
_seed_supervise_skeleton(tmp_dir)
# When start_now is False, write a `down` marker so
# s6-supervise does not auto-start the service on rescan.
# Mirrors the same pattern in container_boot.py
# _register_gateway_slot when start=False.
if not start_now:
(tmp_dir / "down").touch()
tmp_dir.rename(svc_dir)
except Exception:
shutil.rmtree(tmp_dir, ignore_errors=True)
+97
View File
@@ -0,0 +1,97 @@
"""Shared session-listing helpers for CLI and gateway slash surfaces."""
from __future__ import annotations
from typing import Any
def parse_session_listing_args(raw_args: str) -> tuple[bool, bool, str]:
"""Parse `/sessions`-style args into listing flags plus a resume target.
Returns ``(include_all_sources, include_unnamed, target)``. ``list``/``ls``
and ``browse`` are display aliases; ``all``/``--all`` widens source scope;
``full``/``--full`` keeps unnamed sessions in the listing. Anything else is
treated as a target so `/sessions <id-or-title>` can delegate to `/resume`.
"""
import shlex
parts = shlex.split(raw_args or "")
include_all = False
include_unnamed = False
target_parts: list[str] = []
for part in parts:
lower = part.strip().lower()
if lower in {"list", "ls", "browse"}:
continue
if lower in {"all", "--all"}:
include_all = True
continue
if lower in {"full", "--full"}:
include_unnamed = True
continue
target_parts.append(part)
return include_all, include_unnamed, " ".join(target_parts).strip()
def query_session_listing(
session_db: Any,
*,
source: str | None,
current_session_id: str | None = None,
include_all_sources: bool = False,
include_unnamed: bool = False,
limit: int = 10,
exclude_sources: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Return session rows for interactive listing surfaces.
This is the shared selection policy behind CLI/gateway session browsing:
source-scoped by default, optionally global, hide unnamed sessions unless
the caller asks for a full listing, and never include the current session.
"""
query_source = None if include_all_sources else source
fetch_limit = max(limit * 4, limit)
rows = session_db.list_sessions_rich(
source=query_source,
exclude_sources=exclude_sources,
limit=fetch_limit,
)
result: list[dict[str, Any]] = []
for row in rows:
if current_session_id and row.get("id") == current_session_id:
continue
if not include_unnamed and not row.get("title"):
continue
result.append(row)
if len(result) >= limit:
break
return result
def format_gateway_session_listing(
rows: list[dict[str, Any]],
*,
include_source: bool = False,
title: str = "Sessions",
) -> str:
"""Render a compact Markdown-ish session list for gateway messengers."""
if not rows:
return (
"No sessions found.\n"
"Use `/title My Session` to name this chat, or `/sessions full` "
"to include unnamed sessions."
)
lines = [f"📋 **{title}**", ""]
for idx, row in enumerate(rows, start=1):
session_id = str(row.get("id") or "")
title_text = str(row.get("title") or "")
preview = str(row.get("preview") or "")[:40]
source = str(row.get("source") or "")
source_part = f" `{source}`" if include_source and source else ""
preview_part = f" — _{preview}_" if preview else ""
lines.append(f"{idx}. **{title_text}**{source_part} — `{session_id}`{preview_part}")
lines.append("")
lines.append("Resume: `/resume <session id>` or `/resume <number>` from `/resume`.")
lines.append("More: `/sessions all`, `/sessions full`, `/sessions all full`.")
return "\n".join(lines)
+1 -1
View File
@@ -93,7 +93,7 @@ _DEFAULT_PROVIDER_MODELS = {
"gemini-3.1-pro-preview", "gemini-3-pro-preview",
"gemini-3-flash-preview", "gemini-3.1-flash-lite-preview",
],
"zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"zai": ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"kimi-coding": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"kimi-coding-cn": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"stepfun": ["step-3.5-flash", "step-3.5-flash-2603"],

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