Compare commits

..

382 Commits

Author SHA1 Message Date
teknium1
f6b0b09988 feat(web): make grounded citations + summary streaming toggleable via config.yaml
- web.citations: auto (default) | always | off
  * auto: new CITATION_GUIDANCE_AUTO instructs the model to cite inline
    only for research/report/fact-finding-shaped requests and answer
    naturally for incidental lookups (Perplexity's leaked prompts handle
    query-class exemptions the same way - instruction, not classifier)
  * always: unconditional guidance (previous behavior)
  * off: no source ids, no guidance - byte-identical to pre-feature output
- web.summary_stream: true (default) | false - gates the live CLI
  summarization box
- Tool schema descriptions made mode-agnostic ('results MAY carry a
  source field... follow guidance when present') so the static schema
  stays byte-stable for a conversation regardless of config
- Docs: configuration.md 'Grounded citations' section
- 7 new tests covering mode resolution + off-mode output shape
2026-06-12 12:25:20 -07:00
teknium1
853d32fb90 feat(web): Perplexity-style grounded citations + live summary streaming box
Grounding (web_search + web_extract):
- Process-wide source registry assigns stable numbered citation ids
  (url -> [n]) shared across search and extract calls, so the model only
  ever emits small integers it received - it cannot hallucinate a URL
  (the structural trick behind Perplexity's grounding)
- Every search/extract result carries its marker in a 'source' field;
  responses with content include a compact citation_guidance block
  modeled on Perplexity's leaked prompts (per-sentence inline [n] cites,
  max 3 per sentence, cite-while-writing, never invent ids, Sources list)
- Summarizer prompts hardened per ALCE (arXiv:2305.14627) and WebGPT
  (arXiv:2112.09332): preserve citable facts as verbatim quotes, keep
  figures exact, never blend outside knowledge, flag gaps explicitly

Live summary streaming (CLI):
- New tools/summary_display.py: tiny callback registry + single display
  slot; no-op everywhere a front-end doesn't register (gateway, cron,
  subagents, tests)
- web_extract summarization now tries a streaming call first when a
  display is attached, mirroring tokens into a dim 'Summarizing . <url>'
  box in the CLI (same UX as reasoning blocks); any streaming failure
  falls back to the existing non-streaming retry/fallback path
- Parallel page summaries race for the slot; only one streams, others
  run silently - no interleaved boxes
2026-06-12 03:24:42 -07:00
y0shualee
9c50521704 fix(desktop): complete backend PATH for Homebrew Codex
macOS Desktop backend processes can still miss Apple Silicon Homebrew paths even after adding Hermes-managed Node and venv bins. That leaves `/codex-runtime on` unable to find a Homebrew-installed `codex` binary at `/opt/homebrew/bin/codex`.

Add a small testable backend env helper that builds the dashboard subprocess environment in one place. It prepends Hermes-managed Node and venv bins, appends missing POSIX sane PATH entries individually, preserves caller precedence without duplicates, and keeps Windows PATH casing/delimiters intact.

Wire both source-checkout and active-install backend descriptors through the helper, and add Node regression coverage to the desktop platform test suite.
2026-06-12 03:03:44 -07:00
Teknium
88dbf95105 fix(dashboard): profile-scope Channels endpoints and seed per-profile .env (#44792)
Two halves of the same community report (dashboard Profile Builder):

1. A fresh dashboard/CLI-created profile got no .env file unless cloned,
   so it silently inherited API keys and messaging tokens from the shell
   environment / root install. create_profile() now seeds a placeholder
   .env (0600) for non-clone profiles, matching the SOUL.md seeding.

2. The Channels endpoints (/api/messaging/platforms GET/PUT/test) were
   not profile-scoped: they read/wrote the dashboard process's own .env
   via load_env()/save_env_value() regardless of the global profile
   switcher. They now accept the standard optional profile param (body
   beats query on the PUT, matching other scoped writes) and run inside
   _profile_scope(). When scoped, the payload no longer falls back to
   os.environ or load_gateway_config()'s env-override layer — both carry
   the ROOT install's credentials and would misreport them as the
   profile's. /api/messaging/platforms added to PROFILE_SCOPED_PREFIXES
   so the sidebar switcher scopes the Channels page automatically.
2026-06-12 02:09:28 -07:00
loongfay
e20e0bd744 feat(Yuanbao): support wechat forward msg (#43508)
* feat(yuanbao): support wechat forward msg

* feat(yuanbao): support wechat forward msg

---------

Co-authored-by: loongfay <izhaolongfei@gmail.com>
2026-06-12 02:06:47 -07:00
Teknium
0fd34e8c5a fix(teams): cache document/video/audio attachments and classify as DOCUMENT (#44778)
The Teams adapter only handled image/* attachments — documents (the
application/vnd.microsoft.teams.file.download.info consent-free download
payload and any direct-URL non-image attachment) never reached media_urls
at all, so run.py's document-context injection had nothing to surface.
Completes the class-wide sweep from PR #44695 (Signal/Email/SimpleX).

- download.info attachments: fetch the pre-authed SharePoint downloadUrl
  (SSRF-guarded, same guard chain as base.py cache_*_from_url) and route
  through cache_media_bytes
- direct-URL non-image attachments: same fetch + classify path
- skip Teams' text/html message-body mirror and adaptive-card attachments
- DOCUMENT > PHOTO > VIDEO > AUDIO precedence for mixed attachments,
  matching the Email precedence rationale from #44695
2026-06-12 02:05:41 -07:00
Siddharth Balyan
7ba5df0d52 feat(billing): /credits command — balance + portal top-up handoff (#44776)
* feat(billing): /usage → portal top-up browser handoff

Add the terminal side of the billing slice (phase 2a): start a top-up by
throwing the user to the portal billing page with the top-up modal open. The
terminal does not confirm, poll, or track payment — checkout completes in the
browser and the next /usage shows the new balance.

- nous_account.py: parse organisation.slug/name from /api/oauth/account into
  NousPortalAccountInfo; add nous_portal_topup_url() building the org-pinned
  {base}/orgs/{slug}/billing?topup=open with a null-slug fallback to the legacy
  {base}/billing?topup=open (never /orgs/None/...).
- portal_cli.py: 'hermes portal topup' — fresh account fetch, identity line
  (Topping up as <email> / org <name>), browser open with printed-URL fallback,
  no-wait closing copy. No polling/confirmation (deferred to 2b).
- account_usage.py: the shared /usage credits block now links the org-pinned
  top-up URL (auto-opens the modal) + points to the command.

Depends on NAS #409 (organisation.slug/name + ?topup=open). Do not merge until
that is live on the target env; until then /api/oauth/account returns
organisation: { id } only and the URL falls back to legacy.

* feat(billing): /credits command for balance + top-up handoff

Replace the standalone `hermes portal topup` subcommand with an in-session
/credits slash command — a focused money surface (balance in, top-up out) that
works in the CLI, TUI, and every messaging platform from one registry entry.

- commands.py: register /credits (Info category). Slack is at its 50-slash cap,
  so /credits is routed via /hermes credits on Slack only (new
  _SLACK_VIA_HERMES_ONLY set) to avoid clamping a canonical command off the
  native list and breaking Telegram parity; native everywhere else.
- account_usage.py: build_credits_view() — one portal fetch → balance lines +
  identity line + org-pinned top-up URL + depleted flag, consumed by all
  surfaces. Reuses the same snapshot/URL builder as /usage so numbers match.
- cli.py: _show_credits() — balance block + identity line + 3-button panel
  (Open top-up / Copy link / Cancel) via the existing prompt_toolkit modal.
  ASK, never auto-launch; headless falls back to printing the URL.
- gateway/slash_commands.py: _handle_credits_command() — renders the block +
  tappable top-up URL + no-wait copy; works on button and plain-text platforms.
- /usage credits line now points to /credits.
- Retire `hermes portal topup` (portal_cli.py back to baseline); the engine
  (slug/name parse + nous_portal_topup_url) stays as the shared core.

No polling, no payment confirmation (billing phase 2a). Depends on NAS #409.

* fix(credits): /credits works in the TUI slash-worker (non-interactive)

In the TUI, /credits runs in the slash-worker subprocess where there is no
live prompt_toolkit app and stdin is the JSON-RPC pipe. _show_credits called
the 3-button modal unconditionally, which fell back to reading stdin →
exception → slash.exec rejected → the command produced no output (only the
pre-existing 'Credit access paused' banner showed).

- _show_credits: when self._app is None (TUI worker / piped / non-interactive),
  render the text variant — balance block + tappable top-up URL + no-wait line,
  same affordance as the messaging surfaces — and skip the modal entirely. The
  3-button panel still renders in the interactive CLI.
- Depleted banner copy: 'run /usage for balance' → 'run /credits to top up'
  now that /credits is the dedicated money surface (+ tests).
- Regression tests: _show_credits with self._app=None renders text and never
  invokes the modal; logged-out path.

* feat(tui): credits.view RPC for the /credits tappable top-up button

Add a credits.view JSON-RPC method returning the structured CreditsView
(logged_in, balance_lines, identity_line, topup_url, depleted) so the TUI can
render a clickable <Link> top-up button instead of plain text. Account-
independent (portal fetch gated on a logged-in Nous account), fail-open to
{logged_in: false} on any hiccup. Mirrors session.usage's credits-block pattern.

Frontend (TUI-local /credits command + Ink component) lands separately.

* feat(tui): /credits command with keyboard-driven top-up confirm

TUI-local /credits: fetches the structured balance via the credits.view RPC,
prints the balance + identity + top-up URL, then arms the EXISTING confirm
overlay (Enter = open top-up in browser via openExternalUrl, Esc = cancel).
Reuses ConfirmReq — no new overlay component/state/input handler. Headless
(openExternalUrl returns false) falls back to printing the URL.

- gatewayTypes.ts: CreditsViewResponse.
- commands/credits.ts: the command (mirrors /status's rpc+guarded pattern).
- registry.ts: register creditsCommands.
- test: balance+overlay armed, headless fallback, no-url, logged-out (4 cases).

Matches the CLI /credits 'Enter to open' affordance. Phase 2a: no polling.
2026-06-12 08:51:10 +00:00
Teknium
4474873d2c feat(cli): persist resolved approval/clarify prompts in scrollback (#44702)
Modal prompt panels (dangerous-command approval, clarify questions)
live in the prompt_toolkit layout and vanish on the next repaint,
leaving no trace of the question or the decision in chat history.

Emit a dim one-line summary after each prompt resolves:
  ⚠ Approval: <command> → allowed for session
  ? Clarify: <question> → <answer>

Gated on display.persist_prompts (default true). Detail and outcome
are whitespace-collapsed and capped at 120 chars.
2026-06-12 01:14:35 -07:00
Teknium
8e5b7592f8 refactor(agent): hoist MEDIA-directive regex to module level
Avoid recompiling the pattern on every _serialize_for_summary call; name it
beside _PATH_MENTION_RE with the #14665 rationale.
2026-06-12 01:14:28 -07:00
Tranquil-Flow
286ecd26d8 fix(agent): strip MEDIA directives from compressor summarizer input (#14665) 2026-06-12 01:14:28 -07:00
Teknium
8b2a3c9c51 chore: add kdunn926 to AUTHOR_MAP 2026-06-12 01:07:50 -07:00
Teknium
74180ebf0b fix(gateway): classify SimpleX non-image/non-audio files as DOCUMENT
SimpleX tagged unknown files application/octet-stream in media_types
but classification only handled audio/image, leaving msg_type TEXT —
run.py never injected the document context. Same bug class as #12845.
2026-06-12 01:07:50 -07:00
Teknium
f03f161b39 fix(gateway): classify email document attachments as DOCUMENT
Email cached document attachments and placed them in media_urls, but
msg_type only flipped on image attachments — documents stayed TEXT and
run.py's document-context injection (gated on MessageType.DOCUMENT)
silently dropped them. Same bug class as Signal #12845. DOCUMENT wins
over PHOTO for mixed attachments since image handling keys off per-path
mime types while document injection gates strictly on message_type.
2026-06-12 01:07:50 -07:00
Teknium
1e29ab38c7 fix(gateway): classify Signal video attachments + catch-all DOCUMENT fallback
Widen the salvaged #12851 fix to match the established classification
pattern (WhatsApp/Slack/BlueBubbles/Mattermost): video/* -> VIDEO, and
any remaining MIME type falls through to DOCUMENT instead of TEXT, so
exotic types still trigger run.py's document-context injection.
2026-06-12 01:07:50 -07:00
Kyle Dunn
8e821cd2f5 test(gateway): verify Signal inbound text attachment sets MessageType.DOCUMENT 2026-06-12 01:07:50 -07:00
Kyle Dunn
ffef9da9b7 test(gateway): verify Signal inbound PDF attachment sets MessageType.DOCUMENT 2026-06-12 01:07:50 -07:00
Kyle Dunn
8207ae888d fix(gateway): add Signal message type classification for documents 2026-06-12 01:07:50 -07:00
teknium1
05470aa1b6 feat(messaging): expose action='unreact' in send_message + react dispatch tests
Follow-up for salvaged PR #44486: the adapter shipped remove_reaction but
the tool only exposed 'react'. Generalize _handle_react(remove=) and add
tool-level dispatch tests for react/unreact (missing from the original PR).
2026-06-12 01:07:38 -07:00
underthestars-zhy
b4e95a2efe fix(photon): add clarifying comments for Windows-safe os.kill usage 2026-06-12 01:07:38 -07:00
underthestars-zhy
23305cfeab fix(photon): normalize DM chat keys in last-inbound reaction tracker
Inbound events key the tracker by the DM chat GUID (any;-;+1555...),
but home-channel react calls address the same space by bare E.164 —
normalize both to the phone so add_reaction's last-inbound default
resolves regardless of which form the caller uses (mirrors the
sidecar's phoneTargetFromSpaceId).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:07:38 -07:00
underthestars-zhy
156f4fba92 feat(photon): add agent-facing emoji reaction support
Add `action='react'` to `send_message` tool and expose `add_reaction`/
`remove_reaction` on the Photon adapter.

- Track latest inbound message id per chat (`_last_inbound_by_chat`,
  bounded to 200 entries) so the agent can react without threading
  message ids through tool calls
- New `add_reaction`/`remove_reaction` public methods on PhotonAdapter;
  unlike the lifecycle tapbacks, these are not gated by PHOTON_REACTIONS
- `send_message` gains `action='react'` with `emoji` and optional
  `message_id` params; resolves target via existing channel-directory
  and home-channel logic; requires a live gateway adapter
2026-06-12 01:07:38 -07:00
underthestars-zhy
a23c0b378c fix(photon): use per-call httpx client in _sidecar_call
Prevents "Future attached to a different loop" errors when
_sidecar_call is invoked from a worker thread via _run_async in
send_message_tool. The persistent _http_client remains in use for
the inbound streaming loop, which always runs on the gateway's loop.
2026-06-12 01:07:38 -07:00
underthestars-zhy
9bfff6e16c chore(photon): bump spectrum-ts to 3.1.0 2026-06-12 01:07:38 -07:00
underthestars-zhy
a652131c42 fix(photon): stop gateway restarts from orphaning the sidecar on its port
A hard gateway exit (crash, SIGKILL, supervisor restart) left the
detached Node sidecar running with a token the next gateway run doesn't
know, so it could never be told to /shutdown. Every replacement spawn
then died on EADDRINUSE, failing each 30→300s reconnect attempt while
the orphan kept consuming the inbound gRPC stream.

Two layers:
- Lifetime binding: the adapter now holds the sidecar's stdin as a
  pipe, and the sidecar (PHOTON_SIDECAR_WATCH_STDIN=1) shuts down on
  stdin EOF — fired by the OS on any parent death, including SIGKILL.
- Startup reaping: before spawning, the adapter probes the port and
  terminates a stale listener, but only after verifying its command
  line is a Photon sidecar; a foreign listener raises a clear error
  instead of being signalled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:07:38 -07:00
underthestars-zhy
573c4e6511 feat(photon): upgrade to spectrum-ts 3.0.0 (pinned) with markdown + reactions
Pin spectrum-ts to exactly 3.0.0 (was ^1.18.0 plus an `npm install
spectrum-ts@latest` on every setup) so breaking SDK majors can't take
down fresh installs silently; `hermes photon setup` now runs `npm ci`.
Upgrade procedure documented in the README.

Migrate resolveSpace to the v3 namespace API: `im.space.create(phone)`
for DMs and `im.space.get(id)` for everything else — group spaces are
now rehydratable from their persisted id after a sidecar restart, which
v1 could not do.

Markdown: replies go out via the v3 `markdown()` builder (iMessage
renders natively; other Spectrum platforms degrade to plain text).
`PHOTON_MARKDOWN=false` reverts to the stripped plain-text path.

Reactions, behind PHOTON_REACTIONS (default off): lifecycle tapbacks
(👀 while processing, 👍/👎 on completion) via new sidecar /react and
/unreact endpoints with per-target reaction-handle tracking, and user
tapbacks on bot-sent messages routed to the agent as synthetic
`reaction:added:<emoji>` events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:07:38 -07:00
underthestars-zhy
0a963d8c9a feat(photon): add telemetry toggle via hermes photon telemetry 2026-06-12 01:07:38 -07:00
Teknium
c196269d8d fix(credits): suppress usage gauge when top-up funds exist + add display.credits_notices toggle (#44716)
The subscription-cap usage gauge (50/75/90% bands) ignored purchased
(top-up) credits: a sub user with top-up funds got a sticky warn banner
at 90% of their cap — permanently at >=100%, alongside grant_spent —
despite being fully able to keep inferencing. The cap is the wrong
denominator for an account that can keep spending.

- evaluate_credits_notices: purchased_micros > 0 suppresses the usage
  band (grant_spent already covers the cap-reached + top-up case with
  the remaining balance). A top-up landing mid-session clears any
  showing band; spending top-up down to 0 resumes the gauge.
- New display.credits_notices config (default true): false silences all
  credits notices. State capture and /usage are unaffected. Read once
  per agent (cached) in _emit_credits_notices, fail-open true.
- Docs: configuration.md display block.
2026-06-12 01:06:46 -07:00
ethernet
906bee9cf7 fix(nix): natively compile and correctly stage node-pty for desktop app
- Add ELECTRON_SKIP_BINARY_DOWNLOAD=1 to nix/lib.nix to prevent offline download failures.
- Manually trigger native compilation of node-pty via npm rebuild --build-from-source in buildPhase.
- Run stage-native-deps.cjs to copy the natively compiled binary into build/native-deps.
- Flatten native-deps and install-stamp.json to the root of the output derivation in installPhase, matching electron-builder's extraResources behavior so main.cjs can find it at process.resourcesPath + '/native-deps/node-pty'.
- Add doCheck=true and a strict checkPhase to fail fast if the staged native binary is missing.
2026-06-12 03:55:09 -04:00
kshitij
046f444ddc Merge pull request #44738 from kshitijk4poor/salvage/memory-sync-multimodal-content
fix(memory): flatten multimodal content before provider sync
2026-06-12 00:40:31 -07:00
kshitijk4poor
15439bee47 refactor(memory): reuse _summarize_user_message_for_log instead of forking it
The original fix added agent/memory_manager.py:flatten_message_content, but
that helper was a near-exact duplicate of
agent/codex_responses_adapter.py:_summarize_user_message_for_log — same
None/str/list dispatch, same {text,input_text,output_text}/{image_url,input_image}
part sets, the identical [N image(s)] marker, and the same str() fallback. The
only difference was the join separator (newline for memory vs space for the
log/trajectory previews the existing helper already serves), and that helper is
already imported into agent/turn_finalizer.py — the same file whose call site the
memory fix touches.

Parameterize the existing helper with sep=' ' (default preserves every current
logging/trajectory caller byte-for-byte) and call it with sep='\n' at the memory
boundary; drop the forked flatten_message_content. Repoints the unit tests to the
consolidated helper and adds a case locking the default space-join.

Single source of truth for multimodal-content flattening; no behavior change for
the fix or for existing callers.
2026-06-12 12:49:18 +05:30
Erosika
87893fe4cb fix(memory): flatten multimodal content before provider sync
Multimodal turns carry message content as a list of typed parts
({type: "text"|"image_url", ...}). _sync_external_memory_for_turn
passed that list straight into MemoryManager.sync_all, and providers
feed it to regexes — Honcho's sync_turn calls sanitize_context, where
re.sub raised 'expected string or bytes-like object, got list'. Every
turn with an attached image silently never synced.

Flatten to plain text at the boundary: text parts joined, images noted
as an [N image(s)] marker so the attachment isn't erased from recall.
Fixing here covers all providers instead of patching each plugin.

(cherry picked from commit 705bdb6ffe)
2026-06-12 12:46:28 +05:30
brooklyn!
d810f2b262 Merge pull request #44676 from NousResearch/bb/fix-schema-ref-default
fix(tools): strip default from $ref nodes in tool schemas
2026-06-12 01:21:14 -05:00
teknium1
b3f5e17bb9 fix(tui): wrap long approval commands in the Ink overlay
Sibling site of the CLI approval-panel fix: the TUI ApprovalPrompt
rendered each command line with wrap="truncate-end", so a long
single-line command lost its tail at terminal width. Wrap to the
panel width via wrapAnsi before applying the 10-line preview cap.
2026-06-11 23:05:08 -07:00
墨綠BG
81cdbbddc8 🐛 fix(cli): wrap approval preview hints 2026-06-11 23:05:08 -07:00
墨綠BG
d6df38bb6b 🐛 fix(cli): wrap long approval commands in prompt 2026-06-11 23:05:08 -07:00
Teknium
c7bee8f961 refactor(agent): drop unused tail_start param from _derive_auto_focus_topic
The parameter was reserved-but-unused (del'd immediately); YAGNI. Test
call site updated.
2026-06-11 23:03:52 -07:00
konsisumer
434c684bfa fix(agent): focus automatic compression on recent user turns 2026-06-11 23:03:52 -07:00
Teknium
db7714d5f1 Merge pull request #44331 from NousResearch/hermes/hermes-6b48295e
feat(whatsapp): WhatsApp Business Cloud API adapter (salvage #43921)
2026-06-11 22:48:06 -07:00
Kyssta
343803b23c fix(cli): use subprocess on Windows for dashboard profile re-exec (#44282) (#44446)
Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
2026-06-11 22:41:39 -07:00
Kyssta
a942bfd9cc fix(gateway): reset _last_flushed_db_idx when reusing cached agent (#44327) (#44518)
Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
2026-06-11 22:41:34 -07:00
kshitij
a35b370284 Merge pull request #44674 from kshitijk4poor/fix/slack-reactions-plugin-registry-bookkeeping
fix(plugins,slack): registry bookkeeping fixes + ack reaction events (salvage #42561)
2026-06-11 22:32:59 -07:00
Brooklyn Nicholson
b2d151abe2 fix(tools): strip default from $ref nodes in tool schemas
Fireworks-hosted Kimi rejects tool requests when nullable MCP/Pydantic
schemas collapse to {"$ref": "...", "default": null}. Strip that sibling
during global schema sanitization so gateway and CLI calls succeed again.
2026-06-12 00:30:51 -05:00
kshitijk4poor
44bd478039 fix(plugins): credit shared hook/middleware/tool names to every plugin
list_plugins() attribution diffed registry names against all already-loaded
plugins, so when a plugin registered a hook / middleware / tool name an
earlier plugin had already used, the shared name was credited to the first
plugin only and later plugins under-reported (0 hooks) in hermes plugins
list. commands_registered right beside it already attributed correctly by
plugin ownership.

Snapshot per-registry counts before register() and attribute the entries
this plugin's register() actually added (per-registration delta). Add a
regression test: two plugins registering the same hook name are each
credited with 1 hook.
2026-06-12 10:57:25 +05:30
kshitijk4poor
889a13696b fix(plugins): clear _plugin_platform_names on force-rediscover
discover_and_load(force=True) cleared every per-plugin registry except
_plugin_platform_names, which register_platform() populates. A platform
plugin disabled between force-rediscovers left a stale name behind, so the
set diverged from the real platform_registry / _plugins state and never
shrank across repeated force passes.

Add the missing clear() and a regression test that seeds every per-plugin
registry, forces a rediscover, and asserts they all empty (so a future
registry addition can't silently leak across a force pass either).
2026-06-12 10:55:44 +05:30
Veritas-7
82d570165e fix(slack): ack reaction lifecycle events
Register no-op Slack event handlers for inbound reaction_added and reaction_removed events so Slack Bolt does not log unhandled-request warnings for events Hermes does not consume.
2026-06-12 10:54:07 +05:30
kshitij
c574170050 Merge pull request #44664 from kshitijk4poor/salvage/slack-plugin-action-handlers
feat(plugins): expose register_slack_action_handler API (salvage #20589)
2026-06-11 22:14:44 -07:00
kshitijk4poor
e4c168b1f4 chore: map bcsmith528 contributor email for attribution 2026-06-12 10:39:05 +05:30
Brad Smith
08e8bedae8 fix(gateway): keep plugin action wrapper signature to (ack, body, action)
The previous implementation captured loop vars via default arguments::

    async def _wrapped(ack, body, action, _cb=_cb, _plugin_name=_plugin_name):

slack_bolt's ``kwargs_injection`` introspects each listener's signature
via ``inspect.signature`` and passes ``None`` for any parameter name it
doesn't recognise (see ``slack_bolt/kwargs_injection/async_utils.py``
``build_async_required_kwargs``). That clobbered ``_cb`` to ``None`` at
dispatch time, so the wrapped plugin handler became ``NoneType`` —
``await _cb(...)`` then raised ``'NoneType' object is not callable`` and
no plugin action handler ever fired.

Replace the default-arg trick with a small closure factory so the
wrapper's public signature is exactly ``(ack, body, action)``. Add a
regression test that introspects the wrapped function's signature.

Found via real Slack click on a Block Kit button registered through
``ctx.register_slack_action_handler`` — gateway log showed
``[Slack] Plugin 'None' action handler raised: 'NoneType' object is
not callable`` despite the registration log line confirming the
handler was wired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 10:36:14 +05:30
Brad Smith
62e937bf2b feat(plugins): expose register_slack_action_handler API
Plugins that post Block Kit messages with interactive elements (buttons,
overflow menus, datepickers, etc.) had no documented way to receive the
resulting click events. The plugin API exposed register_tool, register_hook,
register_command, register_platform, and register_context_engine, but
nothing for slack_bolt action handlers. The only workaround was to
monkey-patch SlackAdapter.connect from inside register(), which is
fragile and breaks on every Hermes update.

This change adds:

* PluginContext.register_slack_action_handler(action_id, callback) —
  validates inputs and queues the handler on the PluginManager.
  action_id accepts whatever slack_bolt.App.action() accepts (literal
  string, compiled re.Pattern, or constraint dict).
* PluginManager.get_slack_action_handlers() — accessor used by the
  Slack adapter at connect time.
* SlackAdapter.connect — after wiring its built-in approval and
  slash-confirm buttons, iterates the plugin-registered handlers
  and registers each via self._app.action(matcher)(callback). Each
  callback is wrapped defensively so a misbehaving plugin cannot
  crash slack_bolt's dispatch loop, with a best-effort ack on
  exception so Slack stops retrying the click.
* Defensive fallback when the plugin layer is unhealthy: a
  RuntimeError from get_plugin_manager() is logged and swallowed
  rather than blocking the gateway from starting.
* Test coverage in tests/gateway/test_slack_plugin_action_handlers.py
  for input validation, multi-plugin registration, the connect-time
  wiring, defensive exception handling, and the plugin-loader-
  failure fallback path.
* Documentation in website/docs/guides/build-a-hermes-plugin.md
  describing the new API alongside the existing register_command /
  dispatch_tool documentation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 10:36:14 +05:30
brooklyn!
24f74eb888 fix(desktop): make file-preview source + markdown selectable (#44648)
body sets user-select:none for native feel and opts text back in only via
[data-selectable-text='true']; the preview's source and rendered-markdown
panes never set it, so code couldn't be selected or copied. Tag the Shiki
code column and the markdown root. The attribute stays off the SourceView
grid root so the gutter keeps its select-none and line numbers don't bleed
into copied text.
2026-06-12 04:15:06 +00:00
brooklyn!
6e41ca956b fix(desktop): bundle JetBrains Mono for the terminal pane (#44642)
The terminal listed JetBrains Mono only as a late fallback and shipped no
webfont, so on machines without SF Mono/Menlo xterm measured the grid on the
regular system face while styled SGR spans fell back to a font with different
advances — glyphs squeezed and overlapped.

Bundle the regular/bold/italic woff2 (Apache-2.0, the faces the dashboard
already ships), put the family first in the xterm stack, pin the weights, and
warm every face before mount (fonts.ready only settles already-requested
faces; bold/italic aren't asked for until styled output paints, past atlas
init). Vite emits them as hashed assets under dist/** with base './', so the
fonts ship in the asar and every install path inherits them.
2026-06-12 04:11:51 +00:00
brooklyn!
6db65e687c Merge pull request #44627 from NousResearch/bb/desktop-tool-row-copy-affordance
fix(desktop): move tool-row copy control into expanded body
2026-06-11 22:32:52 -05:00
Brooklyn Nicholson
09bcf5a937 fix(desktop): move tool-row copy control into expanded body
The per-row copy control lived in the header's trailing slot as a 24px
button that depended on a `group-hover/tool-row` group that exists nowhere
in the tree. It therefore stayed `opacity-0` yet remained clickable — an
invisible hit-target straddling the disclosure caret and duration, making
the caret hard to click without firing a copy.

Move copy into the expanded body's top-right (matching the code-block
convention) where it can't fight the caret for the right edge, and make it
actually visible (subtle at rest, full on hover/focus). The header right
edge now belongs solely to the duration label + caret.

Tradeoff: copy is only reachable once a row is expanded; rows with no
expandable body no longer surface a copy control.
2026-06-11 22:27:39 -05:00
brooklyn!
4d67ac6172 Merge pull request #44596 from NousResearch/bb/desktop-rtl-bidi
feat(desktop): auto-detect RTL/bidi text direction in chat
2026-06-11 21:44:13 -05:00
Brooklyn Nicholson
6c00077d38 feat(desktop): auto-detect RTL/bidi text direction in chat
Arabic/Hebrew/Persian/Urdu chat text rendered left-to-right and
left-aligned, and mixed RTL/English technical messages (the common case)
read backwards. Resolve each chat block's base direction from its own
first strong character (UAX#9) with pure CSS, scoped to the chat
surfaces only:

- `unicode-bidi: plaintext` + `text-align: start` on assistant prose
  blocks (p, h1-h6, li, blockquote), the user bubble's text lines, and
  both composers (main + edit share the composer-rich-input slot). RTL
  blocks read and right-align RTL; English stays LTR; mixed
  conversations resolve per block. `text-align: start` is required
  because the user bubble hardcodes `text-left`.
- Inline `code` and KaTeX are pinned `direction: ltr; unicode-bidi:
  isolate`, so the bidi first-strong heuristic skips them: a sentence
  that *starts* with a command (`./run.sh ...`) followed by Arabic
  still resolves RTL, and the command's own neutrals keep their order.
- Fenced code surfaces (code-card, user fences) are pinned LTR so they
  never mirror or right-align inside an RTL list item or blockquote.

`direction` is never forced, so app chrome, layout, and list indent
stay LTR per the issue's request not to flip the whole UI. English-only
content is byte-for-byte unchanged.

Salvaged and unified from #44065 and #44169; verified in Chromium that
isolate removes inline code from the paragraph direction vote (the
code-first case), making the JS dir-resolution in #44065 unnecessary.

Fixes #44150

Co-authored-by: Adolanium <Adolanium@users.noreply.github.com>
Co-authored-by: Adalsteinn Helgason <AIalliAI@users.noreply.github.com>
2026-06-11 21:06:26 -05:00
brooklyn!
9e484f052a Merge pull request #44559 from NousResearch/bb/persistent-terminal-env
fix(terminal): advertise persistent env state
2026-06-11 20:07:11 -05:00
Brooklyn Nicholson
ab06ef8ed6 fix(coding): teach agents terminal env state persists
Tell coding agents to activate shell setup once per session instead of re-sourcing it before every command, and pin the existing LocalEnvironment env-snapshot behavior with regression tests.
2026-06-11 19:50:08 -05:00
brooklyn!
afe53708ee Merge pull request #44545 from NousResearch/hermes-worktree-code
fix(coding): don't expose primary worktree path in coding context
2026-06-11 19:35:18 -05:00
Teknium
5affecb443 fix(mcp): capability-gate tools/list so prompt-only MCP servers can connect (#44550)
Port from anomalyco/opencode#31271: only call tools/list when the server
advertises the 'tools' capability in InitializeResult.capabilities.

Previously, _discover_tools() unconditionally called session.list_tools()
right after initialize. Prompt-only / resource-only servers (which omit
the tools capability per the MCP spec) raise McpError(-32601 Method not
found), which aborted the connection — burning all 3 initial-connect
retries and permanently failing the server even though its prompts and
resources were perfectly usable. The 180s keepalive had the same problem:
it probed with list_tools(), so even a successfully connected prompt-only
server would be torn down on the first keepalive cycle.

Changes:
- MCPServerTask._advertises_tools(): capability check with a legacy
  fallback (no captured InitializeResult -> behave as before)
- _discover_tools(): skip tools/list for non-tool servers
- keepalive: use the universal ping request for non-tool servers
- _refresh_tools(): guard against tools/list_changed from non-tool servers

E2E verified with a real stdio prompt-only FastMCP-style server: on main
it fails all 3 connection attempts with Method-not-found; with this fix
it connects, lists prompts, answers ping keepalives, and shuts down
cleanly.
2026-06-11 17:34:49 -07:00
ethernet
96cc7ee1e3 fix(coding): don't provide worktree root in context
this makes the agent frequently edit files in the wrong worktree.
what the agent doesn't know can't hurt it.
2026-06-11 20:27:06 -04:00
brooklyn!
880107ab24 Merge pull request #44529 from NousResearch/bb/desktop-profile-fallout
fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads
2026-06-11 19:06:00 -05:00
brooklyn!
4ddb03390a fix(desktop): collect + persist API key for custom OpenAI endpoints (#43896)
The desktop "Local / custom endpoint" onboarding never collected an API
key and /api/model/set silently dropped one, so an auth-gated endpoint
(e.g. a hosted vLLM behind a key) could never enumerate models — and
Settings' "Set up custom endpoint" routed `custom` into a non-existent
OAuth flow, booting the user back to the first screen (the reported loop).

Backend (web_server.py):
- /api/providers/validate accepts an optional api_key and sends it as a
  Bearer header when probing a custom endpoint's /v1/models.
- /api/model/set accepts api_key, persists it to model.api_key (same
  switch/preserve lifecycle as base_url), and registers a named
  custom_providers entry via _save_custom_provider — matching the
  `hermes model` CLI flow so the endpoint shows up as a ready picker row.

Desktop:
- ApiKeyForm shows an optional API key field for the local/custom option;
  the key is threaded through saveOnboardingLocalEndpoint → validate +
  setModelAssignment.
- New onboarding `localEndpoint` intent + startManualLocalEndpoint(); the
  Settings "Set up custom endpoint" button now opens the local-endpoint
  form (URL + key) instead of the OAuth dead-end.
- Added localApiKeyPlaceholder i18n key (en + types + zh).

Tests: api_key lifecycle on _apply_main_model_assignment, key persistence
+ custom_providers registration on /api/model/set, Bearer-header probe;
onboarding store forwards + persists the key.
2026-06-12 00:03:55 +00:00
brooklyn!
c6007e5c1a Merge pull request #44534 from NousResearch/bb/approval-allow-permanent
fix(approval): carry allow_permanent to TUI + desktop approval prompts
2026-06-11 18:49:58 -05:00
Austin Pickett
e2145a5c9c fix(ui-tui): stabilize embedded dashboard chat gateway (#44528)
Cherry-picked from #39840 by @flyinhigh and rebased cleanly on main.

- Defer config fetch in createGatewayEventHandler until gateway.ready to
  avoid render-phase RPC that can mutate transcript state and trigger
  React error 301 in embedded dashboard PTYs.
- Use undici WebSocket fallback when globalThis.WebSocket is unavailable
  (Node attach mode and sidecar mirror sockets).
- Add regression tests for both fixes.

Co-authored-by: flyinhigh <flyinhigh@users.noreply.github.com>
2026-06-11 19:47:53 -04:00
Brooklyn Nicholson
55a18e6860 chore(approval): tighten allow_permanent comments + DRY the no-always opt set
Collapse the verbose multi-line rationale comments across the TUI/desktop/
backend approval surfaces into single-line "why" notes, and derive
APPROVAL_OPTS_NO_ALWAYS from APPROVAL_OPTS instead of re-listing it.
No behavior change.
2026-06-11 18:42:59 -05:00
Brooklyn Nicholson
b097d7b033 refactor(desktop): use native fetch in dashboard-token
Node >=18 / Electron 40 ship fetch; the hand-rolled http/https.request
plumbing buys nothing. AbortSignal.timeout replaces the socket timeout,
protocol guard and >=400 rejection semantics preserved. 13/13 unit
tests and the live web_server.py repro both green over the new
transport.
2026-06-11 18:41:16 -05:00
Brooklyn Nicholson
cc726aad68 refactor(desktop): fold served-token adoption + foreign-backend refusal into one helper
Both spawn paths (startHermes, spawnPoolBackend) duplicated the same
resolve -> log-fallback -> foreign-check -> throw dance. Collapse it into
adoptServedDashboardToken(baseUrl, spawnToken, {childAlive, label}) in
dashboard-token.cjs; childAlive is a thunk so liveness is sampled after
the fetch. Drop the redundant backendPool.delete in the pool's throw
path (the child exit/error handlers already own pool eviction).

Validated end-to-end against a real web_server.py backend, not just
units: token-injection regex vs the actual served index.html, foreign
refusal (dead child + live squatter), benign drift adoption, and the
401-vs-200 token auth split on /api/sessions.
2026-06-11 18:33:05 -05:00
Brooklyn Nicholson
81436e143e fix(approval): carry allow_permanent to TUI + desktop approval prompts
When a tirith content-security warning is present the approval backend
forces allow_permanent=False and silently downgrades an "always" choice to
session scope (the persistence loop in check_all_command_guards only honors
"always" → permanent when no tirith finding exists). But the gateway notify
payload that drives the TUI and the Electron desktop app never carried that
flag, so both surfaces always rendered "Always allow" — offering a permanent
allow the backend would quietly refuse to persist.

Plumb allow_permanent end-to-end:
- tools/approval.py: include `allow_permanent: not has_tirith` in the gateway
  approval_data the notify callback emits as `approval.request`.
- ui-tui: thread `allowPermanent` through the event handler, gateway types,
  and ApprovalReq; ApprovalPrompt drops the "always" option (and renumbers the
  quick-pick keys) when it's false.
- apps/desktop: thread `allow_permanent` through the gateway payload type, the
  per-session approval store, and the inline ApprovalBar, which now hides the
  "Always allow…" dropdown item when permanent allow is disallowed — reusing
  the existing DropdownMenu / confirm-Dialog UI.

The desktop/TUI render path for approvals already landed in #38578 (the root
cause of approvals not surfacing in the GUI); this completes the salvage of
#37856 by carrying allow_permanent across both surfaces. #37856's original
thread-local _block() approach is dropped: desktop/TUI approvals resolve via
approval.respond → resolve_gateway_approval (the per-session queue), not the
_block()/request_id correlation, so a worker-thread callback waiting on _block
would never be released by the real UI.

Tests: gateway notify payload carries allow_permanent (True without tirith,
False with a tirith warning); ui-tui approvalAction reduced option set +
event-handler allowPermanent propagation; desktop store round-trip + the
ApprovalBar showing/hiding "Always allow".

Supersedes #37856
Closes #37812

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
2026-06-11 18:23:59 -05:00
Mani Saint-Victor, MD
9ff0ba0827 fix(desktop): prevent backend port-squat boot loop and pickPort self-collision
Two fixes to the Electron desktop launch path, with the port-reservation logic extracted into a unit-tested module:

1. hermes:bootstrap:reset ("Reload and retry") only cleared connectionPromise, leaving the live backend alive; the orphan kept binding PORT_FLOOR (9120) so the next startHermes() hit EADDRINUSE / "Object has been destroyed" and the window looped. Await teardownPrimaryBackendAndWait() so the reset stops the old backend before restarting.

2. pickPort() probes-then-closes a socket before the real bind happens in a separate Python child, so two concurrent spawns (primary + pool backend) could both be handed PORT_FLOOR and one died with EADDRINUSE. The reservation bookkeeping is extracted into electron/port-pool.cjs (PortPool): pickPort() reserves the chosen port until the child exits and releases it on every exit/error/throw-before-spawn path, closing the TOCTOU window.

PortPool is dependency-injected (probe passed in) and socket-free, unit-tested in electron/port-pool.test.cjs (8 cases) and wired into the test:desktop:platforms script.

(cherry picked from commit d4133945b9)
2026-06-11 18:22:54 -05:00
Brooklyn Nicholson
e3ed7722b5 fix(desktop): refuse a foreign backend's session token after readiness
The served-token fallback adopts whatever token the dashboard HTML
injects. That is correct when our own child regenerated the token (env
pin lost across a shell-wrapped spawn), but wrong when the readiness
probe answered from a process we did not spawn: /api/status is public,
so an orphaned dashboard squatting the port passes waitForHermes while
our child dies on the bind conflict. Silently adopting that process's
token would authenticate the renderer against a foreign backend,
possibly on the wrong profile.

Discriminate on child liveness: the desktop pins
HERMES_DASHBOARD_SESSION_TOKEN on every spawn, so a live child always
serves our token. Served-token mismatch + dead child = foreign backend;
fail the boot loudly instead of connecting. Mismatch + live child keeps
the adopt-served-token salvage from #43720.
2026-06-11 18:18:22 -05:00
Evis
7a2d498b9d fix(desktop): route profile session reads
(cherry picked from commit 64aaf58f5e)
2026-06-11 18:09:24 -05:00
Jeff
e96fe06e49 fix(desktop): use served dashboard token for websocket auth
(cherry picked from commit f8209f91d3)
(cherry picked from commit 72290f0809)
2026-06-11 18:07:19 -05:00
Gille
9102d4a588 fix(dashboard): show Windows 11 in host panel (#44511) 2026-06-11 19:06:29 -04:00
Andrew Fiebert
d221e369b8 fix(desktop): recover from transient assistant-ui index-lookup crash (#44493)
`@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
throws — rather than returning undefined — when a subscriber reads an index
the message/parts list no longer has. During high-frequency store replacement
(switching sessions mid-stream, gateway reconnect replay) a subscriber from
the previous, longer list is still in React's notification queue and reads one
slot past the new, shorter array before it can unmount. The throw
(`Index N out of bounds (length: N)`, the classic index === length off-by-one)
unwinds all the way to the root error boundary and blanks the entire window,
even though the store self-heals on the very next consistent snapshot.

Wrap each virtualized message group in a tiny boundary that swallows ONLY this
transient lookup race and auto-recovers when the message signature changes
(the existing list-mutation key). Any other error re-throws to the root
boundary, so genuine bugs still surface.

Upstream-tracked and unresolved: assistant-ui/assistant-ui#4051, #3652.

Co-authored-by: mollusk <mollusk@users.noreply.github.com>
2026-06-11 22:52:37 +00:00
brooklyn!
b1fe2107d6 fix(desktop): keep named-profile desktop backends per-profile (#44510)
Desktop spawns its dashboard backend with `--profile <name>` and
`HERMES_DESKTOP=1`. cmd_dashboard's unified-launch routing treats any
named profile as a request for the shared machine dashboard: it re-execs
as the default profile (dropping HERMES_HOME) or, when one is already
listening, prints "Machine dashboard already running ... Managing profile
'<name>'" and exits 0. Either way the desktop-spawned child exits before
the app sees a ready backend, so Desktop retries forever — the Windows
named-profile boot loop in the post-mortem.

Skip the machine-dashboard reroute when HERMES_DESKTOP=1 so desktop pool
backends stay per-profile (which is what the pool expects). Carved out of
#44478.

Co-authored-by: AJ <yspdev@gmail.com>
2026-06-11 22:47:28 +00:00
brooklyn!
73969771a5 fix(desktop): discover MCP tools for dashboard /api/ws backends (#44512)
The desktop chat surface talks to the dashboard's in-process /api/ws
gateway, which builds agents through tui_gateway.server._make_agent. That
path only snapshots the existing tool registry — MCP discovery is started
by tui_gateway/entry.py (the stdio TUI), which the dashboard process never
runs. So a profile's configured MCP servers never connect under the
desktop app and sessions show no MCP tools.

Start a shared background MCP discovery thread at dashboard startup (via
hermes_cli.mcp_startup, bounded so a slow/dead server can't block boot),
and have _make_agent briefly join that thread in addition to the existing
entry-owned TUI thread before snapshotting tools.

Carved out of #44478.

Co-authored-by: AJ <yspdev@gmail.com>
2026-06-11 22:45:45 +00:00
Austin Pickett
2ee69d0579 fix(skills): let ClawHub index build walk past the 12s browse budget (#44500)
The deploy-site skills index crawl was capped at ~3k ClawHub entries
because CATALOG_WALK_BUDGET_SECONDS applied to max_items=0 walks too.
Only enforce the wall-clock budget for bounded browse requests and pass
limit=0 from build_skills_index so CI walks the full catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 18:03:11 -04:00
Austin Pickett
021ed69141 docs: finish Automation Blueprints terminology rebrand (#44470)
* docs: finish Automation Blueprints terminology rebrand

Replace leftover "Automation Templates" wording from the Cron Recipes
rebrand, rename the copy-paste cookbook guide to Automation Recipes, and
point the marketing gallery link at the blueprints catalog.

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

* docs: use Automation Blueprints instead of Recipes in guide

Rename the cookbook guide from automation-recipes to
automation-blueprints so sidebar and copy match the product term.

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

* docs: rename automation-blueprints-catalog to automation-blueprints

Drop the -catalog suffix from the reference page slug and title, and
move the copy-paste cookbook to automation-blueprint-examples so the
main Automation Blueprints doc is unambiguous.

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

* Revert "docs: rename automation-blueprints-catalog to automation-blueprints"

This reverts commit 605f1eeab5.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 17:22:22 -04:00
Teknium
6c752ca3a5 refactor(agent): tighten SUMMARY_PREFIX wording and fix stale doc references
Legibility pass on the consolidated prefix: collapse the topic-overlap rule
from three overlapping sentences into one WINS sentence + one discard/no-wrap-up
sentence (same constraints, less dilution), fix the module docstring to
describe the headings that actually shipped, and correct the #10896 comment's
heading name (Historical Pending User Asks).
2026-06-11 13:57:13 -07:00
Teknium
acb2954d82 fix(agent): freeze carveout-era SUMMARY_PREFIX for renormalization
The prompt consolidation above retires the carveout-era prefix. Without a
frozen copy in _HISTORICAL_SUMMARY_PREFIXES, summaries persisted by
pre-upgrade builds would lose detection (_is_context_summary_content) and
renormalization (_strip_summary_prefix) — the exact regression class the
tuple exists to prevent. Adds contract tests covering every frozen prefix.

Refs #41607 #38364 #42812
2026-06-11 13:57:13 -07:00
kyssta-exe
8f8cad7ec5 fix(agent): strengthen compression preamble against stale task execution (#41607) 2026-06-11 13:57:13 -07:00
konsisumer
d5e2fbf244 fix(agent): frame compaction handoff sections as historical context 2026-06-11 13:57:13 -07:00
brooklyn!
484f484c25 fix(desktop): carve sidebar nav rows out of the titlebar drag region (#44453)
A WSL2 user reported the top two left-sidebar items being unclickable
while the rest of the UI works. That symptom shape matches an
-webkit-app-region:drag hit-test band eating clicks, not GPU/compositing:
the shell's titlebar drag strips (app-shell.tsx) span the top 34px and
the nav group clears them by only 6px, and drag regions win hit-testing
over DOM regardless of pointer-events. Linux WCO (Electron >=32) is the
newest implementation and has known region quirks (electron#43030).

Apply the same no-drag carve-out the codebase already uses for sticky
user bubbles (USER_BUBBLE_BASE_CLASS in thread.tsx) to the sidebar nav
buttons. Harmless on every platform: the rows were never meant to be
draggable surface.
2026-06-11 15:10:09 -05:00
teknium1
114e265737 fix(plugins): don't cache a failed discovery sweep as discovered
Root-cause hardening for the stranded-empty-registry failure behind
'No web search/extract provider configured': discover_and_load() set
_discovered=True before scanning, so a sweep that raised partway was
swallowed by callers as a warning and every later call early-returned
against an empty registry for the process lifetime. The flag now acts
only as a re-entrancy guard and is reset when the sweep raises, so the
next call retries discovery.
2026-06-11 12:56:44 -07:00
xxxigm
32a73010bb test(web): cover keyless default surviving a failed plugin sweep
Pins the invariant that _ensure_web_plugins_loaded registers the keyless
Parallel default (and the wider bundled set) even when the general plugin
discovery raises, that the direct-registration fallback honors plugins.disabled,
and that it stays a no-op on the healthy path.
2026-06-11 12:56:44 -07:00
xxxigm
93764b9303 fix(web): guarantee the keyless web default registers even if discovery doesn't
web_search/web_extract are documented to work with zero setup via the bundled
keyless Parallel free-MCP backend, but that only holds when the bundled
plugins/web/* providers are registered. The dispatch relied entirely on the
general plugin sweep to do that; when the sweep finishes without registering
them (its exception swallowed as a warning, a packaged layout where it ran
before the bundled tree was importable, or a stale empty-discovery cache), the
registry is empty and BOTH tools dead-end on "No web {search,extract} provider
configured" — despite needing no setup at all.

_ensure_web_plugins_loaded now verifies the keyless default landed after the
sweep and, if not, registers the bundled web providers directly against the
registry. Idempotent, a no-op on the healthy path (one dict lookup), and honors
an explicit plugins.disabled entry.
2026-06-11 12:56:44 -07:00
Austin Pickett
c3464ecf45 fix(discord): recover from runtime gateway task exits (#44383)
* fix(discord): recover from runtime gateway task exits

Salvaged from #39416 (AMEOBIUS) — cherry-picked only the task-exit
recovery; the original PR was 1081 commits behind with 28 unrelated
commits.

A post-ready discord.py WebSocket crash left the gateway split-brained:
producers stayed active while Discord stopped responding. After this fix
the adapter calls _set_fatal_error(retryable=True) + _notify_fatal_error()
so the existing GatewayRunner reconnect watcher replaces the dead adapter.

Also adds _wait_for_ready_or_bot_exit() so startup failures (SOCKS/proxy
errors, invalid tokens) surface fast instead of burning the full ready
timeout. Because connect() no longer waits via asyncio.wait_for on that
path, test_connect_releases_token_lock_on_timeout is updated to trigger
the timeout through the new helper (same lock-release contract).

3 tests pass (2 new runtime-failure tests + the updated timeout test);
test_discord_connect.py and test_discord_slash_commands.py green.

Co-Authored-By: ameobius <ameobius@local.host>

* fix(test): patch _wait_for_ready_or_bot_exit in timeout cancel test

connect() no longer uses asyncio.wait_for for the ready handshake, so
test_connect_timeout_cancels_bot_task was hanging for 30s in CI.

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

---------

Co-authored-by: ameobius <ameobius@local.host>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:39:01 -04:00
ethernet
e080365a7a fix(tui): new weird typeerror 2026-06-11 15:36:39 -04:00
ethernet
5e5308d34d fix(node): fix @types/node version
TODO lock to a specific node/npm version.
this is a fix for a diff between 10 and 11.
2026-06-11 15:36:39 -04:00
teknium1
08b1c44a53 fix(discord): extend bot-task cancellation to connect()'s generic exception branch
Follow-up to #44389: the generic 'except Exception' branch in connect()
had the same orphaned-task hazard as the timeout branch. Extract the
cancel-and-await logic into _cancel_bot_task() and call it from all
three sites (timeout branch, exception branch, disconnect()).

Also adds deaneeth to AUTHOR_MAP.
2026-06-11 12:09:18 -07:00
Dineth Hettiarachchi
020ef76cf1 fix(discord): cancel _bot_task on connect() timeout to prevent zombie client
When connect() times out waiting for the Discord ready event, the background
asyncio.Task running client.start() was not cancelled. discord.py's internal
reconnect loop can ignore client.close() while a WebSocket handshake is in
flight, so the orphaned task eventually completes and fires on_ready.

A later successful reconnect then leaves two live Discord clients in the same
process — each with its own on_message handler and MessageDeduplicator instance
— so every @mention creates two threads because the per-adapter dedup caches
cannot catch cross-client duplicates.

Fix: explicitly cancel and await _bot_task in two places:
1. The asyncio.TimeoutError handler inside connect() — catches the case where
   the adapter's own inner wait_for fires before the gateway's outer timeout.
2. The start of disconnect() — the load-bearing path, always reached via
   _dispose_unused_adapter regardless of which timeout fired first.

Root cause confirmed from production logs: a Jun 8 network outage caused three
consecutive connect() timeouts. The first attempt's bot_task completed its
handshake 4 minutes later ("Connected as") with no preceding watcher line,
then the watcher's real reconnect also connected 90 seconds after that. The two
clients ran continuously for 41+ hours, confirmed by the same user message
appearing as two separate inbound events in two different thread IDs 357ms apart.

Regression tests added to tests/gateway/test_discord_connect.py:
- test_connect_timeout_cancels_bot_task: simulates a connect() timeout with a
  NeverReadyBot and asserts _bot_task is None afterward
- test_disconnect_cancels_running_bot_task: injects a live zombie task, calls
  disconnect(), and asserts the task is cancelled and the attribute cleared
2026-06-11 12:09:18 -07:00
Teknium
13650ab7f8 fix(gateway): audio attachment note no longer steers the agent into punting
Sibling site of the PDF/DOCX note fixed in PR #44175: the audio file
attachment context note led with "Ask the user what they'd like you to
do with it", steering the model into asking instead of transcribing.
Rewritten to instruct the agent to transcribe/process the file itself
when the request involves its content, only asking when intent is
genuinely unclear. Contract assertion added to the existing audio
attachment note test.
2026-06-11 11:58:19 -07:00
xxxigm
4e9be3ee32 test(gateway): cover document context note for PDF/DOCX vs text
Pin the contract for _build_document_context_note: text documents confirm the
inlined content and record the path; binary documents (PDF/DOCX/XLSX/octet-
stream) tell the agent to extract the text itself and never instruct it to ask
the user to paste the contents.
2026-06-11 11:58:19 -07:00
xxxigm
e7ae145ac4 fix(gateway): guide the agent to read attached PDF/DOCX instead of punting
When a user attached a binary document (PDF, DOCX, XLSX, …) in chat, the
context note prepended to the turn said "Ask the user what they'd like you to
do with it." That steered the model into asking the user to paste the
contents rather than extracting the text it is fully capable of reading — so
attached PDFs/DOCX appeared "unreadable" to the agent.

Rewrite the binary-document note to tell the agent the file is a non-text
format saved at the given path and to extract its text itself (e.g. via the
terminal tool or the ocr-and-documents skill) before answering. Text
documents (whose content is already inlined by the platform adapter) keep
their existing note. The note construction is pulled into a small
`_build_document_context_note` helper so it is unit-testable.
2026-06-11 11:58:19 -07:00
Austin Pickett
ce99a81123 fix(dashboard): suppress unicode-animations postinstall during npm ci
Set CI=1 in _run_npm_install_deterministic so the package's /dev/tty
postinstall demo is skipped during hermes dashboard web UI builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 11:49:08 -07:00
xxxigm
743c55efa3 fix(desktop): stop file tree throwing "Cannot have two HTML5 backends" on remount (#43541)
* fix(desktop): stop file tree throwing "two HTML5 backends" on remount

The Agent Workspace file tree (react-arborist) shows a permanent "TREE ERROR"
with `[error-boundary:file-tree] Cannot have two HTML5 backends at the same
time.` react-arborist mounts its own react-dnd DndProvider + HTML5Backend per
<Tree>. react-dnd v14 keeps that manager on a global, ref-counted singleton
context and nulls it when the count reaches 0. The tree is keyed on
`${cwd}:${collapseNonce}`, so changing folder / collapsing forces a fresh
<Tree>; during the remount the singleton can be torn down and recreated while
the previous HTML5Backend still owns `window.__isReactDndHtml5Backend`, so the
new backend's setup() throws. The error boundary then sticks, because "Try
again" just remounts into the same race.

Pass arborist a stable, app-lifetime `dndManager` (new getFileTreeDndManager
singleton) so it reuses one backend for the life of the app and never
double-claims the window flag. Drag/drop is already disabled on this tree;
this only changes how the (unused) dnd backend is provisioned.

Promotes dnd-core and react-dnd-html5-backend to explicit deps (already present
transitively via react-arborist's react-dnd 14.x line, so they dedupe to one
instance).

* fix(nix): bump npmDepsHash for desktop dnd deps

Adding dnd-core / react-dnd-html5-backend changed the workspace
package-lock.json, so the single workspace-root npmDepsHash in
nix/lib.nix was stale and the nix build failed. Regenerate it
(hash from the failing nix CI job's 'got:' value).

* fix(nix): update npmDepsHash for merged lockfile

After merging main, the workspace lockfile combined main's dep
changes with the desktop dnd additions, so the npmDepsHash needed
recomputing again. Hash from the nix lockfile-check job.

* fix(nix): use fetchNpmDeps hash for desktop dnd lockfile

prefetch-npm-deps reported sha256-lVnybH9RE/... but fetchNpmDeps
wants sha256-mYgKXE/FL4hnkrEvpVv+ULM/oeyIfO2AM9Ol8OrfWm0= for the
merged workspace lockfile. Use the nix build 'got:' hash so CI passes.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-06-11 11:47:34 -07:00
liuhao1024
93a2f680fd fix(desktop): preserve explicit hide-all choice in model visibility dialog (#43496)
When a user toggles off the last visible model for a provider group, the
effectiveVisibleKeys() function treated the missing provider prefix as
'never customized' and re-added the default models on the next render,
causing all models to snap back to enabled.

Fix: store a sentinel key (e.g. 'provider::') when the last model for a
provider is toggled off. The sentinel distinguishes 'user hid everything'
from 'user never customized', preventing the default-fallback path from
re-adding models the user explicitly chose to hide.

Fixes #43485
2026-06-11 13:27:38 -05:00
brooklyn!
8505e9d669 fix(desktop): disable spellcheck on composer inputs (#44415)
Turn off browser spellcheck, autocorrect, and autocomplete on the main chat composer and message-edit composer so code, paths, and slash commands are not flagged or altered.
2026-06-11 18:03:23 +00:00
brooklyn!
a4f179c509 fix(agent): steer GPT/Codex family to V4A for single-file edits too (#44411)
The coding-posture brief told GPT/Codex models to use patch mode='patch'
(V4A) for structured/multi-file changes but mode='replace' "for a single
small swap". That second nudge points those models at a format their
first-party harness never taught them.

Verified against openai/codex (current main): apply_patch is the ONLY file
editor in codex-rs — zero occurrences of str_replace/old_string anywhere in
the repo; the grammar (core/src/tools/handlers/apply_patch.lark) is exactly
the V4A dialect our patch_parser implements; the shipped model prompts
(gpt_5_codex, gpt-5.2-codex, gpt-5.1-codex-max + instruction templates)
explicitly say to use apply_patch "for single file edits"; and the tool is
gated per model via ModelInfo.apply_patch_tool_type, i.e. OpenAI ships
V4A-for-everything as model metadata.

The GPT-family line now steers to mode='patch' for all edits, single-file
included. The replace-family line (Claude + open-weight) is unchanged —
Claude Code's FileEdit is old_string/new_string/replace_all exact string
replacement (confirmed from Anthropic's shipped sdk-tools.d.ts, the only
file editor in its tool union), matching our mode='replace'.
2026-06-11 17:52:52 +00:00
Teknium
cb29e8a82e refactor(cron): rebrand Cron Recipes -> Automation Blueprints
Product rename across every surface: module/file names (blueprint_catalog,
tools/blueprints, blueprint_cmd), slash command /cron-recipe -> /blueprint
(alias /bp), dashboard API /api/cron/blueprints, desktop deep-link
hermes://blueprint/<key>, docs catalog page + extract script, and the
skill frontmatter block metadata.hermes.blueprint. No behavior change.
2026-06-11 10:49:47 -07:00
Teknium
3c489fda81 fix(commands): unpin /reset from Slack priority aliases — registry hit the 50-cap
CI tests the PR merged with current main, where the new /memory canonical
command filled Slack's 50-slash cap: with btw/bg/reset all pinned ahead of
canonicals, the last canonical (/debug) got clamped and the Telegram-parity
test failed. Canonical commands must win slots over alias spellings — /new
keeps its native slot and 'reset' stays reachable via /hermes reset.

Also updates test_includes_aliases_as_first_class_slashes to assert the
pinned-alias contract (_SLACK_PRIORITY_ALIASES survive) instead of a
specific unpinned alias's survival, which was the same change-detector
pattern the docstring already warned about.
2026-06-11 10:49:47 -07:00
Teknium
e8b757845d fix(cron-recipes): pre-release hardening — honest cadences, strict slot names, surface-aware UX
Review fixes for the Cron Recipes stack before release:

- hydration-move: */90 in the cron minute field silently wraps to hourly
  (croniter-verified) — 90/120-minute options never fired at their stated
  cadence. Replaced with an hour-field step (0 9-17/2 * * 1-5) and an
  interval_hours slot whose options (1/2/3h) all fire as labeled.
- fill_recipe: reject unknown slot names. A typo'd 'tiem=07:15' used to
  silently create the job at the 08:00 default; now it 422s on the dashboard
  form and errors on the slash/deep-link paths with the valid slot list.
- deliver slot: non-strict enum (options are suggestions, scheduler
  validates downstream) so slack/whatsapp/etc. users aren't locked out;
  GET /api/cron/recipes rewrites its options from cron_delivery_targets()
  so the dashboard form only offers configured platforms; help text no
  longer claims dashboard-created jobs deliver to 'the chat you set this
  up from' (the endpoint strips origin — they go to the home channel).
- gateway: success/accept messages no longer point at /cron (cli_only);
  surface-aware hint instead. Conversational fill now sends the
  'Setting up X — I'll ask you a couple of things…' ack before the agent
  turn, matching the CLI experience.
- important-mail catalog entry: reference the urgency classifier by module
  path (python3 -m cron.scripts.classify_items) instead of baking an
  absolute host path into the job prompt — stale after relocation and
  nonexistent on remote terminal backends. cron/scripts is now a real
  package and ships in the wheel (pyproject packages.find).
- export_recipe: interval schedules round-trip again — parse_schedule
  stores 'minutes' but the renderer only read 'seconds', so every interval
  job exported as the silent '0 9 * * *' fallback.
- skills_hub install: say so when a recipe suggestion is dropped
  (latched dedup or pending cap) instead of printing nothing.

Targeted tests: 58 cron/recipe + 261 web_server pass; E2E-validated all
14 recipes fill+parse, hydration cadences via croniter, typo rejection on
slash + endpoint paths, surface-aware hints, and interval export round-trip.
2026-06-11 10:49:47 -07:00
teknium1
e976faac7a feat(cron-recipes): /cron-recipe <name> seeds a conversational fill
Reworks the chat-line UX: pick a recipe by name and the agent asks you for
what it needs, one question at a time, instead of forcing you to hand-type a
slot=val command line.

- /cron-recipe                  -> lists the catalog
- /cron-recipe <name>           -> forgiving name match (exact/prefix/substring/
                                   fuzzy; ambiguous lists candidates), then seeds
                                   the agent with a natural-language fill request
                                   built from the recipe's typed slots + schedule
                                   and prompt templates. The agent asks for each
                                   value one at a time and calls the EXISTING
                                   cronjob tool. No new tool.
- /cron-recipe <name> slot=val  -> unchanged deterministic path (fill_recipe ->
                                   create_job) for the dashboard/docs/power user.

Mechanism (no new plumbing, invariant-safe — the seed enters as a normal user
turn, never a synthetic injection):
- shared handler returns RecipeCommandResult{text, agent_seed}; match_recipe()
  and build_recipe_seed() are the new shared pieces.
- gateway: dispatch rewrites event.text to the seed and falls through to the
  agent (the same pattern /steer uses).
- CLI: handler sets a one-shot self._pending_agent_seed; the interactive loop
  consumes it right after process_command() and runs it as the next turn.

The typed-slot schema stays the single source of truth (still validates the
form/inline path via fill_recipe); the agent path just renders those slots into
the questions to ask. Docs updated to lead with the name-then-ask flow.
2026-06-11 10:49:47 -07:00
teknium1
1593ca5406 feat(cron): Cron Recipes — parameterized automation templates across every surface
A 'recipe' is a one-place definition of an automation that every surface
renders natively. The slot schema (cron/recipe_catalog.py) is the single
source of truth; four renderers consume it, and all paths end at the same
cron.jobs.create_job — no second job engine.

Form where there's a screen, conversation where there's a chat line:
- Dashboard / GUI app: a Recipes sub-tab on the Cron page renders each
  recipe's typed slots as a form (time-picker, enum dropdown, free-text);
  submit POSTs /api/cron/recipes/instantiate which fills + creates the job.
- CLI / TUI / messengers: /cron-recipe lists the catalog, shows a recipe's
  fields, or fills + creates from a pasted 'key slot=val' command. The shared
  handler (hermes_cli/cron_recipe_cmd.py) names any missing/invalid slot so
  the agent can ask a targeted follow-up.
- Docs: a generated Cron Recipes catalog page (website, .mdx + React cards)
  shows each recipe with a copy-paste command and a 'Send to App' button.
- Desktop: a hermes:// URL scheme (Electron single-instance lock +
  setAsDefaultProtocolClient + open-url/second-instance) routes
  hermes://cron-recipe/<key>?slot=val into the chat composer pre-filled.

Typed slots (time/enum/text/weekdays) with defaults: users never type raw
cron — recipes parameterize time-of-day and weekday sets and translate to
cron expressions; a free-text 'schedule' slot is the full-flexibility escape
hatch. Consent-first throughout: nothing schedules without an explicit submit
or send.

Core:
- cron/recipe_catalog.py — CronRecipe + RecipeSlot, 5 curated recipes,
  recipe_form_schema / recipe_slash_command / recipe_deeplink /
  recipe_catalog_entry renderers, fill_recipe (validate + translate to
  create_job kwargs).
- hermes_cli/cron_recipe_cmd.py — shared /cron-recipe handler (CLI + TUI +
  gateway never drift). CommandDef + dispatch in commands.py / cli.py /
  gateway/run.py.

Dashboard: GET /api/cron/recipes + POST /api/cron/recipes/instantiate
(web_server.py), CronRecipes.tsx gallery+form, Segmented sub-tab on CronPage,
api.ts methods + types.

Desktop: hermes:// scheme end to end (main.cjs deep-link router + ready-queue,
preload onDeepLink/signalDeepLinkReady, global.d.ts types, desktop-controller
composer prefill, electron-builder protocols key).

Docs: extract-cron-recipes.py generator wired into prebuild.mjs,
cron-recipes-catalog.mdx + CronRecipesCatalog React component, sidebar entry.
Generated index json gitignored like skills.json.

Tests: 23 core (catalog/slots/schedule-resolution/validation/renderers/command
handler/generator) + 5 web_server endpoint tests. E2E verified end to end:
slot fill -> create_job -> persisted job with correct schedule/deliver/origin.
2026-06-11 10:49:47 -07:00
teknium1
9a09ea69fb feat(cron): Suggested Cron Jobs — one surface for proposed automations
Hermes can propose automations and let the user accept them with one tap
via /suggestions, instead of making them assemble cron jobs by hand. Every
proposal — wherever it originates — flows through one surface.

Sources (the 'where suggestions come from'):
- catalog: curated starter automations (daily briefing, important-mail
  monitor, weekly review, workday-start reminder) via /suggestions catalog
- recipe: installing a skill that carries a metadata.hermes.recipe block
  registers a suggestion instead of auto-scheduling
- usage / integration: reserved for the background-review detector and
  account-connect triggers (sources defined; emitters land next)

Pieces:
- cron/suggestions.py — the store. add/list/accept/dismiss, dedup+latch by
  key (dismissed proposals never re-offered), pending cap so it can't become
  a nag wall. Accepting calls the existing cron.jobs.create_job — there is
  NO second job engine. Mirrors jobs.py storage (atomic writes, lock, 0600).
- cron/suggestion_catalog.py — the curated set. The important-mail monitor
  entry is where the old proactive-monitor poll->classify->surface engine
  lives now (cron/scripts/classify_items.py + the 'monitor' aux task), as ONE
  catalog automation rather than a standalone feature.
- tools/recipes.py — recipe<->job bridge; register_recipe_suggestion() makes
  a recipe source 'recipe' of this surface. recipe_to_job_spec() is the single
  translation both the direct and suggestion paths share.
- hermes_cli/suggestions_cmd.py — shared /suggestions handler (CLI + gateway
  never drift); /suggestions [accept N|dismiss N|catalog|clear].
- Wired: CommandDef + CLI dispatch (cli.py) + gateway dispatch (gateway/run.py)
  + aux 'monitor' task (config.py) + recipe-install hook (skills_hub.py).

Consent-first throughout: nothing auto-schedules; acceptance is always
explicit; dismissals latch.

Supersedes #41122 (proactive-monitor) and #41127 (recipes): both fold in here
as a catalog entry and a suggestion source respectively.

Tests: store (dedup/cap/accept/dismiss/latch), catalog seeding+idempotency,
recipe->suggestion bridge, command handler, aux config. E2E: recipe SKILL.md
-> parsed -> suggested -> accepted -> real cron job persisted to jobs.json.
2026-06-11 10:49:47 -07:00
Teknium
4d6a133a9f fix(agent): gate skill-index demotion behind the opt-in focus mode (#44387)
The coding posture's names-only demotion of non-coding skill categories
(#44342) applied under the default auto mode, silently changing the skill
index for every user in a git repo. Index changes must be opt-in: demotion
now only fires under agent.coding_context=focus, alongside the toolset
collapse. auto/on leave the skill index untouched; focus semantics are
unchanged (demoted, never hidden; deny-list keeps coding-adjacent and
custom categories at full entries).
2026-06-11 10:00:57 -07:00
Teknium
c7bfc938d5 fix(dashboard): Config page header shows the switched profile's config.yaml path (#44374)
The Config page read config_path from /api/status, which is machine-global
and always reports the profile the dashboard process was started under.
After switching profiles with the global switcher, the header kept showing
the old profile's path (e.g. /root/.hermes/profiles/worker_1/config.yaml)
even though reads/writes correctly targeted the new profile.

Fix: /api/config/raw now returns the resolved path alongside the YAML
(resolved inside _profile_scope, so it follows ?profile=). ConfigPage
prefers that scoped path and only falls back to /api/status for old
servers. ProfileKeyedRoutes already remounts the page on switch, so the
header refreshes immediately.
2026-06-11 09:46:15 -07:00
yoniebans
9121834b31 fix(desktop): scope remote workspace defaults 2026-06-11 09:41:35 -07:00
yoniebans
56a0f48ba6 fix(desktop): tighten remote filesystem wiring 2026-06-11 09:41:35 -07:00
yoniebans
8878484f85 feat(desktop): wire remote filesystem browsing 2026-06-11 09:41:35 -07:00
yoniebans
db79e90130 feat(desktop): add filesystem routing facade 2026-06-11 09:41:35 -07:00
yoniebans
51f47f9a97 feat(desktop): add read-only remote filesystem API 2026-06-11 09:41:35 -07:00
helix4u
e71d746820 fix(mcp): avoid false failed startup status 2026-06-11 09:01:52 -07:00
Teknium
5508f4bc54 fix(cli): utf-8 decode for whatsapp-bridge npm install capture (sibling of #43790) 2026-06-11 09:00:55 -07:00
helix4u
b2043cf157 fix(tui): decode startup subprocess output as utf-8 2026-06-11 09:00:55 -07:00
helix4u
dca11b6650 fix(mcp): preserve stdio argv passthrough 2026-06-11 08:59:55 -07:00
brooklyn!
ee1a744ace fix(agent): demote non-coding skill categories to names-only — never hide skills (#44342)
Real-world failure with the original index pruning: under the default auto
posture, an agent-created ops skill in a demoted category vanished from the
prompt's skill index mid-project, and the agent silently fell back to a
stale sibling skill instead. The "discovery-only" premise didn't hold —
models do not reach for skills_list to rediscover what the index stops
showing them, and agent-created skills are the model's accumulated project
memory (runbooks, pitfalls, operating rules).

Gating pruning behind the opt-in focus mode was the wrong fix too: users
opening a worktree don't know the config exists, so the index-noise win
would effectively never ship.

Instead, the coding posture now DEMOTES non-coding categories rather than
hiding them: each demoted category renders as a single names-only line
("gaming [names only]: allthemons10-ops, mc-backup") with a footer note
explaining the omitted descriptions. Every skill name stays in the prompt,
so memory-anchored recall ("load <name>") keeps working in every mode,
while the description noise is still cut. Applies in auto/on/focus alike;
the general posture demotes nothing. Deny-list semantics unchanged —
unknown/custom categories and coding-adjacent ones keep full entries.

API renamed to match the honest semantics: hidden_skill_categories →
compact_skill_categories, build_skills_system_prompt(hidden_categories=) →
compact_categories=.
2026-06-11 10:25:42 -05:00
teknium1
52c7976f40 fix(whatsapp-cloud): review follow-ups for #43921
- nous_subscription: gate the STT managed-default flip on openai-audio
  entitlement and skip when a local backend (faster-whisper or custom
  command) works; new _local_stt_backend_available() helper + tests
- whatsapp_cloud: WHATSAPP_CLOUD_{DM_POLICY,ALLOW_FROM,GROUP_POLICY,
  GROUP_ALLOW_FROM} env overrides so both adapters can run in parallel;
  normalize allowlist entries (JID/punctuation) to bare wa_id
- whatsapp_cloud: wrap per-message event build in try/except (dedup-marked
  wamids would be silently dropped on Meta's batch retry otherwise)
- whatsapp_cloud: validate media_id before URL/filename interpolation,
  delete transient .ogg after voice upload, FIFO-cap interactive-button
  state dicts and per-chat wamid cache
- whatsapp_common: '# **Title**' headers no longer double-wrap asterisks
- setup wizard: read access token / app secret via getpass on TTYs
- docs: new WHATSAPP_CLOUD_* gating env vars
2026-06-11 07:51:01 -07:00
Teknium
2ecb4e62bb Merge remote-tracking branch 'origin/main' into hermes/hermes-6b48295e 2026-06-11 07:38:25 -07:00
Teknium
9c051f57c3 fix(dashboard): Anthropic API Key entry checks ANTHROPIC_API_KEY, not Claude Code creds; hide deprecated tool-progress env vars (#44286)
Two dashboard fixes:

1. The 'Anthropic API Key' OAuth catalog entry's status fn read
   ~/.claude/.credentials.json (which has its own dedicated claude-code
   entry) and never checked ANTHROPIC_API_KEY at all. It now checks the
   Hermes PKCE file, then the registry env-var order (ANTHROPIC_API_KEY
   -> ANTHROPIC_TOKEN -> CLAUDE_CODE_OAUTH_TOKEN) via get_env_value, so
   keys from .env, the shell, or Bitwarden (injected into the process
   env by load_hermes_dotenv) are all reported, with a '(from Bitwarden)'
   source suffix when applicable.

2. Deprecated HERMES_TOOL_PROGRESS / HERMES_TOOL_PROGRESS_MODE removed
   from OPTIONAL_ENV_VARS so the keys page and setup checklists stop
   offering them. Moved to _EXTRA_ENV_KEYS so .env sanitization and
   reload_env still recognize them for existing users (gateway back-compat
   fallback unchanged).
2026-06-11 07:18:15 -07:00
Teknium
e24c935cf3 fix(bedrock): fall back to non-streaming InvokeModel when IAM denies InvokeModelWithResponseStream (#44293)
IAM policies scoped to bedrock:InvokeModel only (a common least-privilege
setup) reject converse_stream() with AccessDeniedException. The agent loop
hard-prefers streaming and the denial never matched the 'stream not
supported' auto-fallback, so InvokeModel-only users looped on AccessDenied
forever.

- agent/bedrock_adapter.py: new is_streaming_access_denied_error()
  detector (ClientError code check + wrapped-SDK message match);
  call_converse_stream() falls back to converse() on denial.
- agent/chat_completion_helpers.py: bedrock_converse streaming branch
  retries inline via converse() and sets _disable_streaming so later
  turns skip the doomed stream attempt; the chat-completions retry
  block also recognizes the denial for the AnthropicBedrock SDK path
  (message pre-check avoids importing bedrock_adapter — and its lazy
  boto3 install — for unrelated providers).

Both paths print a one-line notice telling the user which IAM action
restores streaming.
2026-06-11 07:15:30 -07:00
b1af653bf6 fix(desktop): Harden local file tree paths (#43618)
* fix(desktop): Harden local file tree paths

Normalize Electron local path handling across file tree, preview, media, and git-root flows. Reject malformed and Windows device paths, recheck sensitive files after realpath resolution, and preserve external symlink traversal with stable renderer errors.

* fix(desktop): Address file tree review feedback
2026-06-11 10:05:59 -04:00
Omar Baradei
e372803554 fix(desktop): refresh session model metadata on switch (#43977)
Co-authored-by: Omar Baradei <omar@kostudios.io>
2026-06-11 10:05:32 -04:00
Austin Pickett
d0e017bac8 fix(gateway): gate oversized Telegram voice/audio before download (#44245)
* fix(gateway): gate oversized Telegram voice/audio before download

Adds a pre-download size check to the Telegram voice and audio inbound
paths. Files that exceed _max_doc_bytes (default 20 MB) are rejected
before get_file() is called, preventing silent OOM-style stalls on large
uploads. A human-readable note is appended to the event text so the
model can explain the limit to the user.

Also extends 403 entitlement detection in recover_with_credential_pool
to cover two additional cases: 'oauth authentication is currently not
allowed for this organization' and Anthropic anthropic_messages-mode 403s,
both of which should be treated as entitlement failures rather than
transient errors.

Tests: 7 new cases in test_telegram_voice_v0_regressions.py covering
the size gate (accept, reject, note text) and the STT-failure notice path.

Salvaged from #40487 (cryptopafi) — cherry-picked the Telegram voice
policy and 403 entitlement fixes; LiveKit/Discord/uv.lock workstreams
left for separate PRs.

* test(gateway): drop orphaned voice tests not backed by this PR

The cherry-picked test file from #40487 included 3 tests for STT-failure
notice and voice-mode (_handle_voice_command 'on' -> voice_only) behavior
that this PR intentionally does NOT salvage (those belong to the LiveKit/
voice-policy workstreams left in #40487). They fail on both this branch
and clean main because the feature code isn't present.

Keep only the 2 tests backed by code actually in this PR:
- test_telegram_audio_size_gate_rejects_oversized_media_before_download
  (covers the _telegram_media_size_allowed guard this PR adds)
- test_voice_tts_is_explicit_audio_reply_opt_in (matches current main)

Removed now-unused imports (MessageEvent, MessageType, AsyncMock).
2026-06-11 10:01:51 -04:00
Teknium
a09343cc96 feat(dashboard): SKILL.md editor on Skills page + attach-skill selector in cron modals (#44231)
Headless/VPS users (dashboard-over-Tailscale, no comfortable SSH) could
list/toggle/install skills and create/edit cron jobs, but not author a
custom skill or link one to a cron job — the UI set WHEN a job runs, but
not WHICH skill it uses.

- Skills page: 'New skill' button + per-row edit pencil open a SKILL.md
  editor dialog (frontmatter + body, server-side validation via the same
  _create_skill/_edit_skill path as the agent's skill_manage tool).
- New endpoints: GET /api/skills/content, POST /api/skills,
  PUT /api/skills/content — all profile-scoped via _profile_scope(),
  which now also retargets tools.skill_manager_tool's import-time
  SKILLS_DIR binding.
- Cron page: skills multi-select in both create and edit modals (parity
  with hermes cron --skill / edit --add-skill); CronJobCreate gains a
  skills field; job cards show an attached-skills badge. update_job
  already accepted skills in updates.
- Tests: 17 new endpoint tests (content read, create/edit validation +
  profile scoping + auth gate, cron skills round-trip).
2026-06-11 06:10:27 -07:00
Teknium
f456f302df fix(gateway): refuse to write service definitions with a temp-dir HERMES_HOME (#44267)
* fix(gateway): refuse to write service definitions with a temp-dir HERMES_HOME

A test/E2E harness that exports HERMES_HOME=/tmp/... and touches any
gateway service write path (install, start self-heal, restart's
refresh_systemd_unit_if_needed) bakes the throwaway home into the
production systemd unit / launchd plist. The gateway then restarts
'healthy' but pointed at an empty temp home — no platforms enabled,
deaf to every message (live incident 2026-06-11: /tmp/hermes-e2e-41264
poisoned the unit during a PR-review E2E probe; the post-update restart
produced a 7-hour zombie gateway).

The existing safety belt only sniffed pytest-shaped markers
(/pytest-of-, /hermes_test). Add a structural guard:
_temp_home_in_service_definition() extracts HERMES_HOME from the
generated systemd unit or launchd plist and refuses the write (with
actionable guidance) when it resolves under tempfile.gettempdir(),
/tmp, /var/tmp, or the macOS /private variants. Wired into all five
write sites: systemd refresh + install, launchd refresh + install +
start self-heal.

* test: patch unit generator in install tests tripped by temp-home guard

CI runs hermetic with HERMES_HOME under a tmp dir, so the real
generate_systemd_unit() output now (correctly) trips the new temp-home
write guard in three install tests. Patch the generator with synthetic
non-temp content — same pattern the existing pytest-marker guard tests
use.
2026-06-11 06:10:08 -07:00
Teknium
8972a151a4 feat(cli,tui): show time since last final agent response on the status bar (#44265)
Adds an idle clock to the context/status bar in both the prompt_toolkit CLI
and the Ink TUI: once a turn completes, a dim '✓ <elapsed>' segment shows how
long the session has been idle since the last final agent response. Hidden
while a turn is live (the per-prompt elapsed timer covers that) and before
the first turn completes.

- cli.py: track _last_turn_finished_at when the agent thread exits, surface
  it via _format_idle_since() in the snapshot, render in both the wide
  fragments path and the plain-text fallback.
- ui-tui: stamp lastTurnEndedAt when busy flips false after a live turn,
  thread it through appStatus -> StatusRule, render via a ticking IdleSince
  segment sharing the duration breakpoint/width budget.
2026-06-11 06:06:19 -07:00
Teknium
a2d7f538d4 fix(delegate): stop subagent tool completion lines leaking into parent CLI display (#44223)
Commit 550b72dd8 changed the concurrent-path tool-result rendering gate
from 'not agent.quiet_mode' to 'tool_progress_mode != off'. Subagents are
constructed with quiet_mode=True but inherit the default
tool_progress_mode='all', so every child tool call during delegate_task
started printing raw ' Tool N completed in Xs - {json...}' lines into
the parent's display, bypassing the curated tree-view relay in
_build_child_progress_callback.

Fix: require BOTH gates — quiet_mode must be off AND tool_progress_mode
must not be 'off' — restoring subagent silence while preserving the
#33860 fix (CLI verbose + tool-progress off stays suppressed). The same
combined gate is applied to the three sibling print sites in
tool_executor.py (concurrent header/args, sequential args, sequential
completion) so the whole class is consistent.
2026-06-11 05:10:10 -07:00
Teknium
9c16ca8790 fix(dashboard): normalize model assignments + confirm-modal for backup import (#44237)
Two beta-reported dashboard bugs:

1. Models page: 'Use as -> Main model' on an analytics card sends
   entry.provider, which falls back to the model's VENDOR prefix
   (modelVendor('anthropic/claude-opus-4.6') == 'anthropic') when the
   session row has no billing_provider. That persisted
   provider: anthropic + default: anthropic/claude-opus-4.6 — a
   vendor-prefixed OpenRouter slug on the NATIVE Anthropic provider.
   New sessions then 400 against api.anthropic.com and the user reads
   it as 'changing models does nothing'. Unknown vendors (moonshotai,
   poolside, ...) were worse: a provider that can never resolve
   credentials.

   Fix: _normalize_main_model_assignment() at the single write
   chokepoint — maps non-provider vendor names back to the user's
   current aggregator (else openrouter), and runs the model through
   normalize_model_for_provider() so the persisted name matches the
   target provider's API format. Wired into both /api/model/set and
   the profile-scoped _write_profile_model.

2. System page: 'Restore from backup' spawns hermes import with
   stdin=DEVNULL, so the CLI's interactive 'Continue? [y/N]' overwrite
   prompt hits EOF and auto-aborts whenever a config already exists
   (always, when the dashboard is running). Fix: ConfirmDialog in the
   dashboard owns the consent, then the endpoint passes --force so the
   restore runs non-interactively.

Validated live: dashboard on a temp HERMES_HOME, repro'd both failure
modes pre-fix (vendor-slug write verified via config.yaml + tui
session.create; import 'Aborted.' in action-import.log), then verified
post-fix (normalized writes, modal -> --force -> restored marker file).
2026-06-11 05:07:58 -07:00
Chris
4717989c10 fix(matrix): isolate room context and restore reliable inbound dispatch (#18505)
* fix(matrix): isolate room context and inbound dispatch

* test(matrix): cover room isolation and dispatch regressions

* docs(matrix): document room isolation and session scope

* fix(matrix): stabilize CI requirement checks

* test(matrix): isolate mautrix stubs in requirements tests

* fix(matrix): port room-scoped status and resume to slash commands mixin

Move Matrix /status scope output and /resume same-room guards from the
pre-refactor gateway/run.py into gateway/slash_commands.py so PR #18505
foundation behavior survives the upstream god-file decomposition.

Uses i18n keys for Matrix resume/status messages. Preserves upstream
session.py fixes (role_authorized, DM user_id isolation).

* docs(matrix): explain inbound dispatch via handle_sync loop

Document why Hermes uses an explicit sync loop with handle_sync() rather than
client.start(), aligning with upstream #7914 diagnostics while preserving
Hermes background maintenance tasks.

* fix(i18n): add Matrix resume/status keys to all locale catalogs

The Matrix /resume and /status slash-command keys added in the foundation
PR must exist in every supported locale file. tests/agent/test_i18n.py
asserts key and placeholder parity across catalogs.

Non-English locales use English strings as interim placeholders until
community translators can localize them.

* fix(matrix): restore gateway authz for allowed_users; honor config require_mention

Revert the early MATRIX_ALLOWED_USERS gate in _on_room_message so inbound
sender authorization stays in gateway authz like main. Parse require_mention
from config.extra (platforms.matrix / top-level matrix yaml) with env fallback,
matching thread_require_mention and fixing Forge when require_mention is set
only in profile config.yaml.

* fix(matrix): harden status scope and allowlisted DMs

* fix(matrix): use session store lookup for resume scope
2026-06-11 07:41:43 -04:00
Teknium
73dd584995 fix(mcp): propagate HERMES_HOME override onto the MCP event loop (#44220)
* fix(mcp): propagate HERMES_HOME override onto the MCP event loop

Closes the known limit documented in #44007: tasks scheduled via
run_coroutine_threadsafe are created INSIDE the MCP loop thread, so they
copy that thread's context — a per-request profile scope (dashboard
?profile= endpoints, e.g. the MCP 'Test server' probe) silently vanished
for anything resolving get_hermes_home() inside the coroutine. Most
visible symptom: OAuth token-store paths (HERMES_HOME/mcp-tokens/)
resolved against the process home instead of the selected profile, so
testing an OAuth MCP cross-profile read the wrong tokens.

_run_on_mcp_loop now wraps scheduled coroutines with the caller's
context-local override (_wrap_with_home_override): set inside the task's
own context on the loop, reset on completion — task-local, so concurrent
calls carrying different scopes don't interfere, and the loop thread's
default context stays untouched. No-op (coroutine passes through
unwrapped) when no override is active, i.e. every non-dashboard caller.

web_server's probe comment updated from 'known limit' to 'covered'.

Tests: override propagation (direct + factory form), OAuth token-path
resolution on the loop, loop-context cleanliness after scoped calls,
no-op passthrough. 225 green across mcp_tool + unification suites.

* test(mcp): concurrent different-scope calls don't interfere
2026-06-11 04:37:01 -07:00
Teknium
3edd09a46f fix(whatsapp): restart stale bridge processes instead of silently reusing them (#44205)
A long-lived Baileys bridge survives gateway restarts AND hermes update:
connect() adopted any bridge already listening with status connected, and
disconnect() only kills bridges the adapter spawned itself. Users who
updated to get inbound media support kept talking to a bridge process
serving months-old bridge.js — images and voice notes still arrived as
placeholders with no cached file path (refs #19105 follow-up reports).

Three fixes in the same stale-bridge class:

- Staleness handshake: bridge.js reports a sha256 self-hash in /health
  (scriptHash); connect() compares it against bridge.js on disk and
  restarts the bridge on mismatch. Pre-handshake bridges report no hash
  and are treated as stale, so every existing stale bridge gets recycled
  exactly once on the next gateway start.
- npm dep refresh: deps reinstall when package.json changes (stamp file
  in node_modules), not only when node_modules is missing — a Baileys
  pin bump now actually lands.
- Cache-dir passthrough: the gateway passes profile-aware
  HERMES_{IMAGE,AUDIO,DOCUMENT}_CACHE_DIR to the bridge instead of the
  bridge hardcoding ~/.hermes/image_cache etc., fixing media paths under
  HERMES_HOME overrides, profiles, and the new cache/ layout.
2026-06-11 03:47:29 -07:00
Teknium
875aa8f162 feat(dashboard): unify multi-profile management — one machine dashboard, global profile switcher (#44007)
* feat(dashboard): unify multi-profile management — one machine dashboard, global profile switcher

The dashboard becomes a machine-level management surface with one
write-target selector, replacing per-profile dashboard fragmentation.

Backend:
- profile param (query or body) on /api/config (get/put/raw), /api/env
  (get/put/delete/reveal), /api/mcp/servers (list/add/remove/test/enabled),
  /api/mcp/catalog (list/install), /api/model/info, /api/model/set —
  all scoped through the existing _profile_scope() context manager
- model/set restructured: expensive-model warning (await) runs before the
  scope; the config write runs sync inside the scope in a worker thread
- MCP catalog installs + git-bootstrap entries spawn 'hermes -p <profile>'
- chat PTY: ?profile= on /api/pty points the child's HERMES_HOME at the
  profile dir (its own gateway subprocess, config/skills/memory/state.db
  all profile-bound); in-process gateway attach skipped when scoped

CLI launch unification:
- '<profile> dashboard' routes to the machine dashboard: attach (open
  browser at ?profile=) when one is listening, else re-exec pinned to the
  default profile with --open-profile preselecting the launcher
- --isolated preserves the old dedicated per-profile server behavior
- start_server(initial_profile=...) appends ?profile= to the auto-open URL

Frontend:
- ProfileProvider + sidebar ProfileSwitcher: ONE global selector, URL-
  persisted (?profile=), mirrored into fetchJSON which auto-appends the
  param to the scoped endpoint families (explicit params win)
- app-wide amber banner names the managed profile
- SkillsPage's page-local selector (from the skills-scoping PR) folded
  into the global context — single source of truth
- ChatPage threads the scope into the PTY WS URL; switching profiles
  remounts the terminal into a fresh scoped session

Omitted profile keeps legacy behavior everywhere.

* docs(dashboard): document machine-level multi-profile management

- web-dashboard.md: 'Managing multiple profiles' section (switcher, URL
  deep-links, unified launch, --isolated, scoped Chat, what stays
  per-profile) + --isolated in the options table
- profiles.md: 'From the dashboard' subsection + set-as-active vs
  switcher clarification
- cli-commands.md: --isolated flag + profile-alias launch example

* fix(dashboard): address profile-unification review findings

Review findings (dev review on PR #44007):

1. HIGH — stale page state on profile switch: pages load data on mount
   and didn't consume the profile scope, so a page opened under profile A
   kept showing A's state while writes silently targeted the newly
   selected B. Fixed structurally: ProfileKeyedRoutes wraps the routed
   page tree and keys it by the selected profile, remounting every page
   (fresh state + refetch) on switch. ChatPage keeps its own remount
   (channel keyed on scopedProfile).

2. HIGH — /api/model/auxiliary read was unscoped while /api/model/set
   wrote scoped (Models page could show default's aux pins while editing
   worker's). Endpoint now takes profile + _profile_scope, added to
   PROFILE_SCOPED_PREFIXES, HTTPException re-raise so ghost profiles 404
   instead of 500. Regression test asserts read/write symmetry with
   differing worker/default aux config.

3. MEDIUM — tools post-setup spawned unscoped from the profile-aware
   drawer. Now spawns 'hermes -p <profile> tools post-setup <key>'
   (same mechanism as hub installs); drawer threads its profile prop.
   Most hooks install machine-level artifacts where the scope is inert,
   but hooks reading config/env now see the drawer's HERMES_HOME.

4. LOW — ty warnings: env Optional asserts before subscript/membership,
   fastapi import replaced with web_server.HTTPException re-use.

298 tests green across the four affected suites; tsc -b + vite build
green; aux scoping E2E-verified with real imports.

* fix(dashboard): address second profile-unification review (gille)

1. BLOCKER — profile scope dropped on sidebar navigation: ProfileProvider
   derived the selection from the current URL, and nav links are bare
   paths, so clicking Config from /skills?profile=worker silently reset
   the write target. State is now the source of truth; an effect
   re-asserts ?profile= onto the new location after every navigation
   (URL stays a synchronized projection for deep links/refresh), and an
   incoming URL param (e.g. 'Manage skills & tools' links) still wins.

2. BLOCKER — /api/model/options unscoped while model/set wrote scoped:
   the picker context (current model/provider, custom providers,
   per-profile .env auth state) now loads inside _profile_scope; added
   to PROFILE_SCOPED_PREFIXES. Test: a worker-only current-model pin
   appears in the scoped payload and not the unscoped one.

3. BLOCKER — MCP test-server probe escaped the scope after the config
   read: the probe now re-enters _profile_scope inside the worker thread
   so env-placeholder expansion resolves against the selected profile's
   .env. Known limit (documented): the probe's dedicated MCP event-loop
   thread doesn't inherit the contextvar (OAuth token paths). Test
   asserts get_hermes_home() inside the probe == the worker profile dir.

4. BLOCKER — broad excepts swallowed unknown-profile 404s: /api/model/info
   degraded to 200-with-empty-model-info and /api/mcp/catalog to a
   silently-empty catalog. Both re-raise HTTPException; 404 regression
   tests added for info/options/catalog.

Polish: scope banner clears the fixed mobile header (mt-14 lg:mt-0);
--open-profile hidden via argparse.SUPPRESS (internal re-exec flag);
attach-path test now asserts the opened ?profile= URL.

(Stale-page-state + /api/model/auxiliary findings from this review were
already fixed in 92bcd1568 — the review ran against e600f6951.)

35 tests in the two new suites + 274 in the adjacent ones, all green;
tsc -b + vite build green; scoping E2E-verified with real imports.

* docs(dashboard)+fix: self-review pass — Profiles page section, REST profile-param tip, body-beats-query precedence

Docs:
- web-dashboard.md: add the missing 'Profiles' subsection to Pages
  (cards, create/builder, manage-skills jump, set-as-active vs switcher
  distinction, editors); REST API section gets a profile-scoped-endpoints
  tip documenting ?profile= / body profile / 404 semantics / /api/pty
- (profiles.md + cli-commands.md were already updated in e600f6951)

Precedence fix: scoped endpoints taking BOTH a query param and a body
field now resolve body.profile first. The SPA's fetchJSON injects the
query param from the GLOBAL switcher; an explicit body.profile (e.g.
Profile Builder flows writing into a specific new profile) is the more
specific intent and must not be overridden by whatever the sidebar
happens to be set to. Matches the documented 'explicit beats global'
contract in api.ts.

Verified: 304 tests green across the four suites; tsc -b + vite build
green; docusaurus build green (only pre-existing broken-link warnings,
none from this PR's pages).
2026-06-11 03:29:33 -07:00
Teknium
85503dceca Merge pull request #44038 from NousResearch/hermes/hermes-fb4ee8ce
fix(cli): show quick commands in /help output
2026-06-11 03:04:30 -07:00
kshitij
955fa40062 Merge pull request #44085 from kshitijk4poor/review/pr-43754-ssh-update
fix(update): avoid SSH auth for passive official checks
2026-06-11 01:12:03 -07:00
liuhao1024
0d3e2cc539 fix(desktop): deduplicate sidebar rows by compression lineage in mergeSessionPage (#43487)
When auto-compression rotates the session tip (old #4 → new #5), the
incoming page carries the new tip but the previous list still holds the
old one. The old tip's id differs from the new tip's id, so the existing
id-only dedup in mergeSessionPage() preserves both as separate sidebar
rows.

Add lineage-level dedup: build a set of incoming lineage keys
(`_lineage_root_id ?? id`) and filter survivors whose lineage key
matches any incoming row. This mirrors the existing sessionPinId()
logic used for pin stability.

Fixes #43483
2026-06-11 01:02:27 -07:00
kshitij
c94e93a648 Merge pull request #44084 from kshitijk4poor/salvage/windows-winget-stale-reg
fix(install/windows): repair stale winget registration + refresh/merge PATH after every package manager
2026-06-11 00:25:15 -07:00
kshitij
39f40ece70 Merge pull request #44074 from kshitijk4poor/fix/archive-compressed-session-lineages-salvage
fix(sessions): archive compressed conversation lineages
2026-06-11 00:24:00 -07:00
kshitijk4poor
0edeee14c6 test(desktop): cover official-SSH remote detection for passive updates
Extract the remote-detection helpers (canonicalGitHubRemote, isSshRemote,
isOfficialSshRemote) from main.cjs into a testable update-remote.cjs sibling
module and add a node:test suite, wired into test:desktop:platforms.

main.cjs requires('electron') at load, so its inline helpers weren't unit
testable. The Python side of #43754 shipped a regression test; this gives the
desktop side the same coverage for the security-critical detection that keeps
passive update checks off the SSH origin (avoiding FIDO2/passkey touch
prompts). Tests assert SSH/HTTPS forms canonicalize equal, official SSH is
detected case-insensitively, and forks / other hosts / the HTTPS remote are
NOT misclassified.
2026-06-11 12:53:19 +05:30
kshitij
b4fbf7b93c Merge pull request #44082 from kshitijk4poor/fix/backup-staging-and-nested-skill-dirs
fix(backup): stage SQLite snapshots beside output zip (all paths) and stop excluding nested hermes-agent skill dirs
2026-06-11 00:20:52 -07:00
kshitijk4poor
9662b76d59 fix(install/windows): merge PATH in Update-ProcessPathForPackages instead of overwriting
Follow-up to the winget stale-registration fix. Update-ProcessPathForPackages
rebuilt $env:Path wholesale from the persisted User+Machine hives (plus winget's
Links dir), discarding any process-only PATH entries added earlier in the
installer run. Since the helper now runs after every package manager, that
wholesale replace is more likely to clobber a process-local entry than the
original winget-branch-only version was.

Merge instead: seed from the current process PATH, then append hive and
winget-Links entries not already present, with a case-insensitive,
order-preserving dedupe. Behaviour on a clean box is unchanged (the hive entries
are simply appended); the difference is that pre-existing process-only entries
now survive the refresh.
2026-06-11 12:49:58 +05:30
xxxigm
899acfe42f fix(install/windows): repair stale winget registration; refresh PATH after every package manager
When ripgrep/ffmpeg is missing, `winget install <id>` on a package winget
already has registered is treated as an upgrade: it finds no newer version and
exits 0x8A15002B (-1978335189, APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE)
without ensuring the binary is actually present. The installer only logged that
code and judged success by `Get-Command rg`, so a stale registration (files
removed outside winget, or a missing alias shim) became a permanent dead-end —
winget kept reporting "already installed" and the user could never reinstall.

Detect that exit code and retry once with `--force` to repair the registration
so the shim reappears.

Also refresh the process PATH after the choco and scoop fallbacks (not just
winget) via a shared helper, so a successful fallback install — or any install
on a box without winget — is no longer misreported as "not installed".
2026-06-11 12:47:59 +05:30
kshitijk4poor
ed2b9e43c8 fix(backup): stage SQLite snapshots beside output zip in pre-update path too
The pre-update / pre-migration backup path (_write_full_zip_backup) had the
same /tmp staging bug as run_backup: a small tmpfs at the default tempfile
location silently drops large *.db files from the archive. Route its SQLite
staging temp files to the output zip's directory as well, and add regression
tests (mutation-verified) for both staging paths.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-11 12:45:40 +05:30
helix4u
cedd9b6d47 fix(update): avoid SSH auth for passive official checks 2026-06-11 12:45:07 +05:30
liuhao1024
dd40600e0a fix(backup): stage SQLite snapshots alongside output zip and stop excluding nested hermes-agent skill dirs
Two bugs in the backup routine:

1. SQLite safe-copy used tempfile.NamedTemporaryFile() which defaults to
   the system temp directory (/tmp).  When /tmp is a small tmpfs and the
   database is large, the copy silently fails and the resulting zip is
   missing state.db, kanban.db, and response_store.db.

   Fix: pass dir=out_path.parent so the temp file is staged alongside the
   output zip on the same filesystem.

2. _EXCLUDED_DIRS contained "hermes-agent" which matched at ANY path
   depth, accidentally excluding the Hermes Agent skill directory at
   skills/autonomous-ai-agents/hermes-agent/.

   Fix: special-case "hermes-agent" to only match when it is the first
   path component (the root-level code checkout).  All other excluded dir
   names continue to match at any depth.

Regression tests added for both fixes.
2026-06-11 12:43:39 +05:30
kshitijk4poor
5e81113d09 chore: map dschnurbusch contributor email for attribution 2026-06-11 12:34:12 +05:30
Dan Schnurbusch
04b3f19538 fix(sessions): archive compressed conversation lineages 2026-06-11 12:31:10 +05:30
Teknium
b8e2c16579 Merge origin/main into salvage branch (resolve AUTHOR_MAP conflict) 2026-06-10 23:25:54 -07:00
kshitij
4829f8d2c5 Merge pull request #44047 from kshitijk4poor/salvage/desktop-stop-stale-session
fix(desktop): recover stale session before stop
2026-06-10 23:23:38 -07:00
teknium1
cb2c13055e fix(gateway): scrub _HERMES_GATEWAY from POSIX detached restart watcher too
Follow-up to the salvaged #41264 (Windows watcher): the setsid/bash detached
restart watcher on Linux/macOS inherits _HERMES_GATEWAY=1 the same way, so
the CLI's self-restart loop guard silently refuses 'hermes gateway restart'
and the gateway never comes back. Scrub the marker from the watcher env on
the POSIX branch as well, and extend the setsid test to assert it.
2026-06-10 23:22:43 -07:00
鼬君夏纪
264ac72b67 fix(gateway,windows): preserve restart watcher env 2026-06-10 23:22:43 -07:00
helix4u
f38f7a3870 fix(desktop): recover stale session before stop
Desktop already recovers from a stale runtime session id when
`prompt.submit` returns `session not found` after a gateway restart or
sleep/wake. The stop path did not have the same recovery: `cancelRun`
called `session.interrupt` once with the stale runtime id, then surfaced
`Stop failed / session not found`.

This makes stop/cancel mirror the prompt recovery path. If
`session.interrupt` reports `session not found` and the selected stored
session id is available, Desktop resumes that durable session, updates
the active runtime ref with the recovered id, and retries
`session.interrupt` once against the recovered runtime id.

Salvaged from #43941 — rebased onto current main, dropping the unrelated
`package-lock.json` (@types/node 24.13.1->24.13.2) and `nix/lib.nix`
hash churn. That bump is a local npm 11 re-resolution artifact, not a CI
requirement: repo CI runs node 22 (npm 10) and main is green at
@types/node 24.13.1, so the lockfile and nix hash do not need to change.

Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>
2026-06-11 11:45:08 +05:30
Teknium
2450fd7066 chore: add mvanhorn to AUTHOR_MAP 2026-06-10 22:56:17 -07:00
Matt Van Horn
0b5b7ddfd2 fix(cli): show quick commands in /help output
User-defined quick_commands from config.yaml now appear in the /help
output under a "Quick Commands" section, between skill commands and tips.

Fixes https://github.com/NousResearch/hermes-agent/issues/4090

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-10 22:55:52 -07:00
Shannon Sands
fa7f24e898 Enable webhooks from dashboard page 2026-06-10 22:55:06 -07:00
Teknium
13f1efdd15 fix(gateway): collapse repeated terminal headers in consecutive tool progress blocks (#43968)
When the agent runs several terminal commands back-to-back, each
progress line repeated the '💻 terminal' header above its fenced code
block, cluttering the progress bubble. Now only the first terminal call
in a streak emits the header; subsequent consecutive terminal calls
render adjacent code blocks. Any other tool (or non-block preview)
resets the streak so the next terminal call gets a fresh header.
2026-06-10 22:30:27 -07:00
brooklyn!
4d22b82933 Merge pull request #43959 from NousResearch/hermes/salvage-composer-drafts
fix(desktop): per-thread composer drafts on decoupled lifecycle (salvage #43660, supersedes #43939)
2026-06-11 00:12:23 -05:00
Brooklyn Nicholson
419c8a98a9 Merge remote-tracking branch 'origin/main' into hermes/salvage-composer-drafts 2026-06-11 00:07:07 -05:00
brooklyn!
975edd4140 fix(cli): omit --workspace when subpackage has its own package-lock.json (#42973) (#43986)
* fix(cli): omit --workspace when subpackage has its own package-lock.json

When ui-tui/ (or web/) contains its own package-lock.json, _workspace_root()
returns the subpackage directory itself.  Passing --workspace ui-tui in that
case fails because npm cannot find a workspace named 'ui-tui' inside ui-tui/.

Fix: skip the --workspace flag when npm_cwd equals the target directory,
running a plain 'npm install' from the standalone project root instead.

Applies the same fix to both _make_tui_argv (TUI) and _build_web_ui (web).

Fixes #42973

* test(cli): fix web workspace-scope fixture + cover own-lockfile fallback (#42973)

The web half of the #42977 fix broke test_npm_install_uses_workspace_web_scope,
which built its fixture with no lockfile anywhere. Without a root lockfile,
_workspace_root(web_dir) already returns web_dir, so the new
"() if npm_cwd == web_dir" branch correctly drops --workspace and the
assertion failed. Model a real workspace checkout instead: the single
package-lock.json lives at the root, so --workspace web scopes the install.

Also add the symmetric web regression test (web/ carrying its own lockfile =>
--workspace must be dropped and the install runs plainly from web_dir via
npm ci), matching the TUI coverage already in test_tui_npm_install.py.

---------

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-11 05:01:25 +00:00
Brooklyn Nicholson
d7d281fa37 feat(desktop): strict per-thread drafts on decoupled composer
Keyed draft stash (Map + localStorage mirror) behind the live composer:
switching threads stashes the departing draft and restores the entering
one; empty threads show an empty box. Session lifecycle never clears
composer state — the scope swap is the only coupling.

Co-authored-by: mollusk <roger@roger.local>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-11 00:01:06 -05:00
Brooklyn Nicholson
292192f7d7 refactor(desktop): tidy composer draft persistence
- DRY the duplicated submit-restore blocks into dispatchSubmit()
- inline localStorage access (drop browserStorage indirection);
  clearPersistedComposerDraft delegates to write('')
- drop stale per-scope-stash comment in use-session-actions
2026-06-10 23:47:32 -05:00
Brooklyn Nicholson
c710868fbc refactor(desktop): decouple composer from session lifecycle entirely
The composer is a single global surface that sits ABOVE the thread: its
contents follow the user across session switches and are never touched
by session lifecycle. Switching threads doesn't change the render.

Replaces the per-scope draft choreography (scoped storage keys, attachment
stash map, skip-sentinel, restore-on-scope-change effect) with:
- one global localStorage key so an unsent draft survives app reloads
- a one-shot restore on mount
- nothing else — session switches simply don't touch the composer

Verified E2E via CDP with real sidebar clicks + real keystrokes:
typed draft survives A->B->A switching and a full page reload.
2026-06-10 23:39:35 -05:00
brooklyn!
3e74f75e41 feat(agent): coding-context posture across CLI/TUI/desktop/ACP (#43316)
* feat(agent): coding-context posture with per-model edit-format tuning

Hermes detects when it's running in a coding context — an interactive
surface (CLI, TUI, ACP, desktop) sitting in a code workspace (git repo or
recognised project root) — and shifts into a coding posture. Outside that
(chat platforms, non-workspaces) nothing changes.

The posture is modelled as a frozen RuntimeMode selected from a small
ContextProfile registry (coding/general). A profile is data: the toolset to
collapse to, the operating brief to inject, and seams for model routing and
memory. Every domain reads the same resolved object instead of re-probing
git/config on its own:

- System prompt — RuntimeMode.system_blocks(): an operating brief (gather
  context before editing, edit through tools not chat, verify with terminal,
  cap retry loops) plus a live git/workspace snapshot, built once and baked
  into the stable prompt tier so per-conversation caching is preserved.
- Per-model edit-format tuning — the brief nudges each model family toward
  the patch mode it handles best: OpenAI/Codex toward mode='patch' (V4A
  multi-file diffs), Anthropic toward mode='replace' (string replacement).
  The model id rides on RuntimeMode; unknown families keep neutral wording.
- Skill index — non-coding skill categories are pruned from the prompt's
  skill index (discovery-only; skills_list/skill_view still reach the full
  catalog, with a disclosure note).
- Toolset — only under the opt-in 'focus' mode does the posture collapse to
  the coding toolset + enabled MCP servers; the default posture is
  prompt-only and never overrides configured toolsets.

Activation via agent.coding_context: auto (default), focus, on, off.
Subagents inherit the posture for free via toolset inheritance + the shared
prompt builder. Detection is not memoized so a long-lived gateway/TUI
process can't pin a stale posture across working directories.

* feat(agent): cover new-file authoring in the coding edit-format nudge

The per-model edit-format guidance only addressed editing existing code
(patch mode='patch' vs 'replace'), but authoring a brand-new file —
write_file, not patch — is a large fraction of real coding work and the
nudge was silent on it. Surfaced when building a single-file artifact where
the dominant operation was write_file and the steering offered no guidance.

Both family lines now lead with "author new files with write_file; for
edits to existing code prefer ...". Tests assert write_file appears in each
family's brief; unknown families still get neutral wording.

* docs(agent): correct memoization docstring + clarify TUI config-load asymmetry

* feat(agent): sharpen the coding posture — verify-loop facts, wider edit steering, $HOME guard

Tuning pass on the coding posture from dogfooding it as a harness:

- Workspace snapshot now hands the model its verify loop up front:
  detected manifests + package manager (lockfile sniff), the exact
  verify commands (package.json scripts, Makefile targets,
  scripts/run_tests.sh, pytest config), and which context files
  (AGENTS.md / CLAUDE.md / .cursorrules) exist at the root. Marker-only
  (non-git) projects get the snapshot too instead of nothing. The
  "verify before claiming done" brief line was the highest-value piece
  in evals — this turns it from advice into an executable loop instead
  of making the model rediscover the test command every session. Still
  stat-cheap, size-guarded reads, built once at prompt time.

- Edit-format steering covers the families Hermes actually serves:
  Gemini and open-weight coding models (DeepSeek, Qwen, Kimi, GLM,
  Grok, Hermes, Llama, Mistral, Devstral, MiniMax) steer to
  mode='replace' — their RL scaffolds use str_replace-style editors.
  Previously only GPT/Codex and Claude families got steering; the
  models Hermes users disproportionately run all fell to neutral.

- Operating brief gains four behaviors elite harnesses encode: batch
  independent reads/searches in one turn; fix root causes and the bug
  class (sibling call paths), not the reported site; no drive-by
  refactors/renames/reformatting; never read, print, or commit secrets.
  Plus a patch-failure escalation ladder: after the same region fails
  twice, rewrite the enclosing function/file with write_file instead of
  a third patch attempt.

- $HOME dotfiles guard: a git repo rooted exactly at the home directory
  (or a marker sitting in it, e.g. a global ~/AGENTS.md) is user config,
  not a code workspace — without the guard, every session anywhere under
  a dotfiles-managed home silently flipped to the coding posture. Real
  projects under such a home still detect via their own markers/repos;
  'on' mode bypasses the guard.
2026-06-10 23:06:44 -05:00
Brooklyn Nicholson
fdc0d19566 fix(desktop): make draft persistence actually fire — new-chat sentinel, reload flush, session-switch clears
Manual testing of the salvaged draft persistence showed none of it worked
end-to-end. Three distinct bugs, all invisible to the store-level unit
tests:

1. New-chat drafts were never written. The skip-one-persist sentinel was
   reset to null after consuming, but null IS a real scope (the unsaved
   new-session draft) — so in a new chat every persist run matched the
   "consumed" sentinel and bailed. This silently killed the headline
   #38498 fix. Use undefined as the no-skip sentinel, which can never
   collide with a scope.

2. Cmd+R inside the debounce window dropped the trailing text. React does
   not run effect cleanups on a page reload, so the flush-on-unmount
   never fired; with the 400ms debounce that meant type-then-reload lost
   the draft every time. Flush pending writes on pagehide.

3. Session switch/new/resume/branch paths in use-session-actions cleared
   the composer stores synchronously with the session-id updates. React
   batches those, so by the time ChatBar's scope-change cleanup ran to
   stash the departing session's attachments, the store was already
   empty — the stash recorded [] and the chips were lost anyway. The
   composer's per-scope restore now owns composer contents wholesale on
   scope change, so drop the upstream clears (clearComposerDraft only
   touched the vestigial $composerDraft atom nothing reads).

Co-authored-by: mollusk <roger@roger.local>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-10 22:58:50 -05:00
Teknium
7d8d000b19 revert(cron): remove per-job profile support (PR #28124) (#43956)
Fully removes the cron per-job 'profile' arg added in #28124: the
cronjob tool schema field, CLI --profile flags on cron create/edit,
job-record storage/validation, the scheduler's _job_profile_context
wrapper, and the script-runner env override. Sequential-partition
logic reverts to workdir-only.

The context-local HERMES_HOME override in hermes_constants and the
subprocess bridging in tools/environments/local.py are kept — they
now have other consumers (dashboard multi-profile, TUI gateway).
2026-06-10 20:46:17 -07:00
teknium1
68ffedb6a9 chore(release): map Spaceman-Spiffy for #35586 salvage 2026-06-10 20:45:16 -07:00
teknium1
efcbbde48c refactor: keep anthropic_content_blocks in-memory only (no state.db column)
Drop the hermes_state.py column + persistence plumbing from the salvaged
interleaved-thinking fix. The ordered-block channel covers the failure
window in-memory (turn replayed within the live conversation loop). A
session reloaded from disk after a crash falls back to reconstruction;
if that replay 400s, the thinking-signature recovery (#43667) strips
reasoning_details and retries — one degraded call in a rare resume path
instead of a schema column. Replaces the DB-roundtrip test with a
fallback-shape test.
2026-06-10 20:45:16 -07:00
RaumfahrerSpiffy
7a1eed8268 fix(anthropic): redact replayed tool inputs and broaden thinking-replay 400 recovery
Two additive hardening changes on the interleaved-thinking replay path
introduced by this PR's anthropic_content_blocks channel. Both are scoped
to that channel's blast radius; neither changes correct behavior.

1. Replay-time tool-input re-sourcing (credential safety).
   The ordered-block channel captures each tool_use `input` from the RAW
   API response in normalize_response, which is NOT credential-redacted.
   The parallel tool_calls[].function.arguments IS redacted at storage
   time (build_assistant_message, #19798). The verbatim-replay fast path
   in _convert_assistant_message replayed the raw block input, so a secret
   a model inlined into a tool call (e.g. an Authorization header value
   passed inside a terminal command) would ride back onto the wire even
   though it is redacted everywhere else in history. Re-source tool_use
   input from the redacted tool_calls map by
   sanitized id; interleave order (the reason this channel exists) is
   unaffected. Adapted from #36071, which re-sources tool inputs the same
   way on its replay path.

2. Broaden the thinking-replay 400 classifier (defense-in-depth).
   error_classifier only matched "signature" + "thinking", so the
   frozen-block variant — "thinking ... blocks in the latest assistant
   message cannot be modified. These blocks must remain as they were in
   the original response." — carried no "signature" token and fell through
   to a non-retryable abort. The anthropic_content_blocks channel prevents
   the reorder that triggers this 400 at the source, but if any future
   mutator reintroduces it, the turn now self-heals via the existing
   strip-reasoning-and-retry recovery instead of crash-looping. A negative
   case ensures an unrelated "cannot be modified" 400 (no "thinking") is
   not swept in. Mirrors the classifier broadening in #36087 and #36071.

Tests
- tests/agent/test_anthropic_thinking_block_order.py: a replay test
  asserting an inlined secret is redacted on the wire while interleave
  order is preserved.
- tests/agent/test_error_classifier.py: three cases — frozen-block 400
  native and via OpenRouter route to thinking_signature/retryable; an
  unrelated "cannot be modified" 400 does not.
Both grafts verified RED (tests fail with the change reverted) then GREEN.
Full adapter, transport, classifier and output-field-leak suites pass.

Co-authored-by: AlexanderBFoley <92330381+AlexanderBFoley@users.noreply.github.com>
2026-06-10 20:45:16 -07:00
RaumfahrerSpiffy
529bb1c3d5 fix(anthropic): strip output-only SDK fields from replayed content blocks
HTTP 400 "messages.N.content.M.text.parsed_output: Extra inputs are not
permitted" on the native Anthropic transport. Anthropic SDK 0.87.0 response
blocks carry output-only attributes the Messages *input* schema forbids: text
blocks get `parsed_output` and `citations=None`, tool_use blocks get `caller`.
normalize_response captured blocks verbatim via _to_plain_data and replayed
them as request input on the next turn, so the forbidden fields leaked back ->
400. Like the earlier thinking-block bug, one poisoned turn wedges every
subsequent request in the session (even the diagnostic turn), recoverable only
by switching models or deleting the session.

This is a defect in the anthropic_content_blocks channel added for the
interleaved-thinking fix: it preserved block ORDER correctly but copied every
SDK attribute, including output-only ones.

Fix — whitelist input-permitted fields per block type at all three leak points:
- agent/transports/anthropic.py normalize_response: sanitize at CAPTURE so the
  poison never persists to state.db (defence-in-depth).
- agent/anthropic_adapter.py _sanitize_replay_block (new): whitelist used on the
  ordered-blocks replay path; also recovers already-poisoned stored sessions.
- agent/anthropic_adapter.py _convert_content_part_to_anthropic: a stored
  `text` part is rebuilt from whitelisted fields instead of dict(part) verbatim
  (this was the exact content.N.text.parsed_output failure locus).

Whitelist not blacklist, so future SDK output-only fields can't reintroduce it.
Block order and thinking-block signatures are preserved (the reason the channel
exists). Adds tests/agent/test_anthropic_output_field_leak.py; full adapter
suite green (163 tests). Existing poisoned state.db rows scrubbed out-of-band.
2026-06-10 20:45:16 -07:00
RaumfahrerSpiffy
aaccaada28 fix(anthropic): preserve interleaved thinking/tool_use block order on replay
Interleaved-thinking turns (adaptive thinking, Claude 4.6+/Opus 4.8) emit
content blocks like:

    thinking_1(signed) tool_use_1 thinking_2(signed) tool_use_2

Anthropic signs each thinking block against the turn content preceding it
at its position. normalize_response split the turn into two parallel lists
(reasoning_details + tool_calls), discarding cross-type order, and
_convert_assistant_message rebuilt it as [all thinking][text][all tool_use].
That moved thinking_2 ahead of tool_use_1, invalidating its signature, so
Anthropic rejected the latest assistant message with HTTP 400:

    messages.N.content.M: `thinking` or `redacted_thinking` blocks in the
    latest assistant message cannot be modified.

Observed repeatedly in agent.conversation_loop against api.anthropic.com /
claude-opus-4-8, recurring across sessions on multi-thinking-block turns.

Fix: carry a verbatim, order-preserving copy of the turn's content blocks
(anthropic_content_blocks) end-to-end - capture in normalize_response,
persist/restore through state.db, and replay unchanged for the latest
assistant message. Gated to turns that actually interleave signed thinking
with tool_use, so normal turns are unaffected.

Adds 3 regression tests including a SQLite round-trip covering the
crash-recovery reload path.
2026-06-10 20:45:16 -07:00
Brooklyn Nicholson
65ddc7c4a1 fix(desktop): retain composer attachments per session scope + guard programmatic drafts
The salvaged draft persistence scoped text per session but reset the
composer's attachments to [] on every scope change, so a staged image or
file was silently dropped when you switched sessions and never restored on
return — inconsistent with the "drafts survive session switches" promise
and a real paper-cut given remote staging cost.

Retain attachments per scope in an in-memory map (keyed by the same scope
as the text draft) since blobs / object URLs / live upload state can't be
serialized to localStorage. Entering a scope restores its stashed chips;
leaving stashes the current ones; an accepted submit clears the scope.
This survives session switches (the case users hit) without pretending to
survive a full reload, which attachments fundamentally can't.

Also guard the debounced text write so browsing sent-message history or
editing a queued prompt (both swap the composer to recalled text via
loadIntoComposer) no longer clobbers the genuine in-progress draft in
storage.

Co-authored-by: mollusk <roger@roger.local>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-10 22:41:34 -05:00
Teknium
ad9012097b fix(dashboard): dedupe useNavigate import/declaration in ProfilesPage
tsc -b (run by the Docker image build, unlike local vite-only checks)
rejected the duplicate identifier.
2026-06-10 20:34:53 -07:00
Teknium
914befa9aa feat(dashboard): profile-scoped skills & toolsets management
'Set as active' on the Profiles page only flips the sticky active_profile
file (future CLI/gateway runs) — it never retargets the running dashboard
process. The skills/toolsets endpoints called bare load_config()/
save_config(), so after 'activating' a profile in the web UI, deactivating
a skill silently wrote into the dashboard's own profile and the activated
profile was untouched.

Backend:
- _profile_scope() context manager on the skills/toolsets endpoints:
  context-local HERMES_HOME override for call-time config resolution +
  cron-style locked swap of tools.skills_tool's import-time SKILLS_DIR
- profile param on /api/skills, /api/skills/toggle, /api/tools/toolsets*
  (list/toggle/config/provider/env), hub sources/search installed-state
- hub install/uninstall/update spawn 'hermes -p <profile> skills ...' so
  the child rebinds skills_hub.SKILLS_DIR at import (the override cannot
  reach import-time globals); profile validated -> 404/400 before spawn

Frontend:
- Skills page: profile selector (deep-linkable /skills?profile=<name>),
  amber banner naming the managed profile, threaded through skill toggles,
  toolset drawer, and hub browser
- Profiles page: 'Manage skills & tools' action per card; 'Set as active'
  toast now says it applies to new CLI/gateway runs only

Omitted profile keeps legacy behavior (dashboard's own profile).
2026-06-10 20:34:53 -07:00
Teknium
3d14f01fd6 fix(desktop): debounce per-keystroke draft persistence writes
The salvaged draft-persistence effect wrote to localStorage on every
keystroke — the composer's per-keystroke path was deliberately slimmed
down previously, so debounce the write (400ms) and flush pending text on
scope change/unmount so a fast session switch can't drop trailing
keystrokes. Also add AUTHOR_MAP entry for the salvaged commit.
2026-06-10 22:34:30 -05:00
Roger
18d61bd06e fix(desktop): persist composer drafts across reloads
Save in-progress composer text to browser localStorage per chat session and restore it when the desktop composer remounts. Keep the draft when submit is rejected or throws, and clear it only after the prompt is accepted.
2026-06-10 22:34:13 -05:00
Teknium
acd7932c0f docs: cross-link write-approval gate from skills, configuration, and slash-command docs (#43801)
The memory/skill write-approval gate (#38199, #43354, #43452) was only
documented inside features/memory.md. Surface it everywhere users will
actually look:

- features/skills.md: new 'Gating agent skill writes' section under
  skill_manage, with the staging semantics, review commands, and the
  distinction from skills.guard_agent_created
- configuration.md: memory.write_approval added to the Memory
  Configuration block; new 'Write approval for skill writes' subsection
  next to the guard_agent_created scanner
- reference/slash-commands.md: /memory and /skills review subcommands in
  both the CLI and messaging tables; Notes updated since /skills
  pending/approve/reject/diff/approval now works on the gateway
- features/memory.md: cross-link to the new skills section
2026-06-10 19:54:44 -07:00
Teknium
0a5762c78d fix(web): genericize free-MCP client identity per telemetry policy
Replace the hermes-identifying clientInfo/User-Agent/session-id prefix on
the keyless Parallel Search MCP path with a neutral 'mcp-web-client'
identity. Project policy forbids third-party usage attribution without an
explicit user opt-in (see telemetry PR policy); MCP requires a clientInfo,
so a generic one satisfies the spec without attributing traffic.

Also adds the contributor AUTHOR_MAP entry and refreshes uv.lock against
current main (parallel-web 0.6.0).
2026-06-10 19:54:38 -07:00
Matt Harris
e0e2571711 feat(web): Parallel-backed web search & extract — free Search MCP when keyless, v1 REST when keyed
Make Parallel the web search/extract backend with a zero-setup free tier:

- Keyless (no PARALLEL_API_KEY): web_search/web_extract work out of the box via
  Parallel's free hosted Search MCP (search.parallel.ai/mcp), and parallel
  becomes the default backend when no other web credentials are configured
  (ahead of ddgs, which is search-only). A small hand-rolled Streamable-HTTP
  JSON-RPC client speaks the MCP's web_search/web_fetch tools; the existing
  web_search/web_extract tools are the only tools registered.
- Keyed (PARALLEL_API_KEY set): uses the Parallel v1 REST endpoints
  (client.search / client.extract with advanced_settings.full_content) — no beta.
  Bumps parallel-web 0.4.2 -> 0.6.0.
- Attribution: on the free path only, results carry provider/attribution and the
  CLI tool line reads "Parallel search" / "Parallel fetch"; the paid path is
  unbranded.
- Selection/registration: web tools register unconditionally (free MCP backstop)
  while check_web_api_key remains a real usability probe; explicit per-capability
  backends are honored (so misconfig surfaces) rather than masked by the fallback.

Tested: live web_search/web_extract against search.parallel.ai in keyless and
keyed modes; unit suites for the MCP client, backend selection, and display
labeling; full agent run shows the "Parallel search" label on the free path.
2026-06-10 19:54:38 -07:00
brooklyn!
fe54960142 desktop: un-truncate the active slash/@ row so long descriptions stay readable (#43926)
Follow-up to #42351. Slash command rows render the command label and
description with `truncate`, so skill commands and longer blurbs were
clipped with no way to read the full text. Rather than add a floating
tooltip (which overlaps the popover and only helps the mouse), the active
row — the one reached by keyboard arrows or hover, since onMouseEnter
already sets activeIndex — now drops truncation and wraps inline
(whitespace-normal break-words). Idle rows stay single-line/truncated so
the list reads compact.
2026-06-11 02:35:38 +00:00
brooklyn!
3ffbdfbcc0 desktop: registry-driven slash commands + first-class /resume & /handoff (#42351)
* desktop: surface /tools, /save, /personality and fix /help skill count

Move /tools and /save out of TERMINAL_ONLY_COMMANDS and /personality out of
ADVANCED_COMMANDS so they appear in the desktop slash palette and execute via
the existing slash.exec → command.dispatch fallback. The backend gateway already
accepts these through slash.exec (none are in _PENDING_INPUT_COMMANDS or the
skill list), so no backend change is required.

Recompute skill_count in filterDesktopCommandsCatalog from the filtered pairs.
Previously the /help footer echoed the unfiltered backend total — e.g. "60
skill commands available" while only ~29 actually appeared in the rendered
list, because the desktop hides terminal-only, picker-owned, and advanced
commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* desktop: keep slash popover live while typing args

The trigger regex `(?:^|[\s])([@/])([^\s@/]*)$` stopped matching the moment
the user typed a space after a slash command, so the popover never showed arg
completions for `/personality`, `/tools`, etc. — even though the backend's
`complete.slash` already returns them with a `replace_from` indicator.

Split the trigger detection so `/` allows args (`/cmd arg1 arg2`) while `@`
keeps the strict no-space behavior. Restrict the slash command name to
`[a-zA-Z][\w-]*` so file paths like `src/foo/bar` don't accidentally trigger
the popover.

Rewrite arg-completion items in useSlashCompletions to insert the full
`/personality alice` token instead of stranding `/alice`: when `replace_from`
is past the command base, prepend the existing prefix to each item's text so
the chip serializer produces a coherent replacement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* cli: complete toolset names after /tools enable|disable

SlashCommandCompleter previously only auto-derived the first subcommand level
from args_hint, so `/tools enable <tab>` yielded nothing — the user had to
remember every toolset key (web, file, spotify, …) and every MCP server prefix.

Add `_tools_completions` that handles both stages: subcommand (list|disable|enable)
and tool name. Filter by current enable state so `/tools enable <tab>` only
offers disabled toolsets and `/tools disable <tab>` only offers enabled ones —
no point suggesting a no-op. MCP server prefixes (server:) come from the
saved mcp_servers config; per-tool completion under a server would require
runtime MCP introspection and is left as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* desktop: registry-driven slash commands with first-class pickers

Collapse the if/else slash dispatch into one DESKTOP_COMMAND_SPECS table
that drives popover suggestions, per-type composer pills, and execution.

- /resume, /sessions, /switch: inline session completions (like /skin) plus
  a "Browse all sessions…" entry that opens a dedicated session picker overlay
- /handoff: inline platform completion + handoff.request/handoff.state
  gateway bridge so desktop reaches CLI parity
- colored per-type pills (command/skill/theme) in the composer
- strip ANSI and fix width/alignment of slash output in the chat panel

* desktop: fold repeated slash session/output boilerplate into one helper

runExec, /title, /help and the unavailable case each re-derived the same
ensure-session → bail-with-notify → build-renderSlashOutput dance.
withSlashOutput() returns {sessionId, render} or null, so each handler is
a two-line resolve instead of an eight-line preamble.

* desktop: keep backend meta on slash arg completions

Arg suggestions (/personality <name>, /tools enable <toolset>, /handoff
<platform>) were having their meta overwritten with the parent command's
registry description: desktopSlashDescription("/personality none") canonicalizes
back to /personality and returns its blurb. Skip the lookup for arg rows so the
backend's own display_meta ("clear personality overlay", etc.) survives.

* cli: list real personalities in /personality completion

_personality_completions resolved load_config().agent.personalities — but that
schema has no agent.personalities key, so completion always returned just
`none` even though the runtime (load_cli_config().agent.personalities) ships a
dozen built-ins (helpful, kawaii, pirate, …). Read from the same source the
command actually applies, so `/personality ` surfaces the real options.

* desktop: expand bare arg-commands to their options on pick

Picking a command like /personality from the slash popover committed it
immediately instead of advancing to its argument list. Mark arg-taking
commands (/skin, /resume, /handoff, /personality, /tools) in the registry
and, when one is picked bare, insert "/cmd " as plain text and re-open the
popover on its inline options — mirroring typing "/cmd " by hand. Arg picks
(serialized text already contains a space) still commit a single pill.

Also realign trigger-popover loading test with the redesigned popover (the
/help empty-state hint shows when resolved, not while the spinner is up);
the merge from main reintroduced the pre-redesign expectation.

* tui_gateway: fold session-db close into a context manager

Both handoff RPCs repeated the same `db, close_db = _session_db_handle()`
+ `finally: if close_db: db.close()` dance. Turn the helper into a
`_session_db` contextmanager that owns the close, so callers just
`with _session_db(session) as db:`.

* desktop: unblock handoff retries and exact resume ids

Clear timed-out desktop handoffs through the gateway so retries are not stuck behind a pending row, and let typed /resume session ids bypass the loaded sidebar cache.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 01:49:24 +00:00
emozilla
bfcc9f92b4 Merge commit '6110aed9b' into feat/whatsapp-cloud-api 2026-06-10 21:39:22 -04:00
xxxigm
615ad97928 fix(streaming): stop socket read timeout from preempting stale-stream detector (#43570)
* fix(streaming): stop socket read timeout from preempting stale-stream detector

The stale-stream detector is deliberately scaled to 180-300s so reasoning
models (e.g. Opus) can pause mid-stream during extended thinking. But the
httpx socket read timeout stayed at a flat 120s for cloud providers and fired
first, tearing down healthy reasoning streams before the detector (which owns
retry + diagnostics) could act. Symptom: every Copilot/Opus turn dies with
ReadTimeout at a consistent ~125s and never completes.

Floor the cloud socket read timeout at the stale-stream timeout so it can no
longer fire before the detector. Local providers and explicit
HERMES_STREAM_READ_TIMEOUT / request_timeout_seconds overrides are unchanged.

* test(streaming): pin read-timeout >= stale-stream invariant for cloud reasoning streams

Cover the contract that the httpx socket read timeout is never shorter than
the stale-stream detector for cloud providers on the default: small contexts
floor to 180s, >=50K to 240s, >=100K to 300s; explicit overrides win; local
providers and the unresolved-value fallback are unaffected.
2026-06-10 20:21:38 -05:00
Austin Pickett
9dd9ef0ec9 fix(web): profiles page modal (#43858)
* fix(web): profiles page modal

* chore: drop unrelated package-lock.json changes

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 20:43:22 -04:00
Teknium
4490c7cf8d fix: in-memory transcript blocks empty-session prune
CI caught tests/cli/test_cli_new_session.py asserting that /new keeps
the old session row when conversation history exists in memory. The
live transcript is authoritative: a session whose messages haven't
flushed to the DB yet (or whose flush failed) must not be pruned.
Guard _discard_session_if_empty on self.conversation_history and pin
the behavior with a test.
2026-06-10 17:37:34 -07:00
Teknium
e96ca1a0d3 feat(sessions): drop empty sessions on CLI exit and session rotation
Port from google-gemini/gemini-cli#27770: starting the CLI and
immediately quitting (or rotating with /new, /clear) left an empty
untitled session row behind. These ghost rows pile up in /resume,
`hermes sessions list`, and the in-chat recent-sessions browser.

- SessionDB.delete_session_if_empty(): transactional check-and-delete
  that only removes rows with no messages, no title, and no child
  sessions (delegate subagent parents are preserved). Also removes
  on-disk transcript files via the existing _remove_session_files.
- HermesCLI._discard_session_if_empty(): thin wrapper, wired into the
  cli_close shutdown path and the new_session() rotation path.
  Skipped when /exit --delete already handles removal.

Unlike the one-shot prune_empty_ghost_sessions migration (TUI-only,
24h-old rows), this prevents new ghost rows from accumulating at the
moment they would be created.
2026-06-10 17:22:27 -07:00
teknium1
d1383a6b14 fix(skills): widen HERMES_HOME-aware .env resolution to all sibling skills
Follow-up to the GitHub-skills fix: the same hardcoded ~/.hermes/.env
pattern existed across other bundled and optional skills. Under the
official Docker setup (HERMES_HOME=/opt/data, subprocess HOME=/opt/data/home)
those paths point at a nonexistent file.

- kanban-video-orchestrator setup.sh.tmpl + docs: resolve via
  ${HERMES_HOME:-$HOME/.hermes}/.env in check_key()
- telephony.py / canvas_api.py / hyperliquid_client.py: error and
  save messages now report the real resolved env path instead of a
  hardcoded literal (path resolution itself was already correct)
- godmode SKILL.md: load_dotenv snippet resolves via HERMES_HOME
- watch_github.py + ~20 SKILL.md prose mentions: document the env file
  as ${HERMES_HOME:-~/.hermes}/.env so Docker users edit the right file
2026-06-10 15:10:11 -07:00
xxxigm
0a593f132c fix(skills/github): resolve .env via HERMES_HOME, not hardcoded ~/.hermes
The GitHub skills' auth-detection fell back to reading GITHUB_TOKEN from a
hardcoded ~/.hermes/.env. In the official Docker layout HERMES_HOME=/opt/data
while tool subprocesses run with HOME=/opt/data/home, so `~/.hermes/.env`
expands to /opt/data/home/.hermes/.env — a path that does not exist — while the
real secrets file is /opt/data/.env. Result: the agent reports GITHUB_TOKEN as
"not set" even though it is present and the dashboard Keys page shows it.

Resolve the file as ${HERMES_HOME:-$HOME/.hermes}/.env (HERMES_HOME is bridged
into tool subprocess env, falling back to ~/.hermes when unset) across all six
auth-detection sites: github-auth (SKILL.md + scripts/gh-env.sh), github-issues,
github-repo-management, github-pr-workflow, github-code-review.
2026-06-10 15:10:11 -07:00
Teknium
3b4c715e1c fix(telegram): stripped-text fallbacks, re-finalize skip, and tail-only delete guard
Follow-ups on top of the two salvaged GodsBoy commits, all live-validated
against the real Telegram Bot API:

- _edit_overflow_split finalize fallbacks degrade to _strip_mdv2() clean
  text instead of putting raw **markdown** markers on screen (salvaged
  from PR #43463 minus its format-first sizing — live probes show
  Telegram's 4096 limit counts PARSED text, so MarkdownV2 escape
  inflation cannot cause MESSAGE_TOO_LONG and sizing against formatted
  wire length only causes premature splits and fragment messages).
- Skip the redundant requires-finalize edit after a got_done edit that
  split-and-delivered (salvaged from PR #43463): re-finalizing re-splits
  the full text into the adopted continuation and duplicates chunks.
- _send_fallback_final only deletes the stale partial message when the
  fallback re-sent the COMPLETE final text. When the prefix dedup sent
  only the missing tail, the partial IS the head of the answer; deleting
  it left users with only the second half of long responses (live-
  reproduced: flood-control during a long stream -> head deleted,
  ratio 0.54 of content visible). This is the third bug behind the
  'Telegram cut messages' reports and was present on main and both PRs.
2026-06-10 15:09:35 -07:00
GodsBoy
da818510ec fix(gateway): finalize best-effort delivery when stream consumer is cancelled 2026-06-10 15:09:35 -07:00
GodsBoy
590b3c0d7e fix(gateway): recover partial Telegram overflow streams 2026-06-10 15:09:35 -07:00
xxxigm
88fcf0c8c0 docs(memory): clarify that memory does not auto-compact when full
The "Persistent Memory" callout said "when memory is full, the agent
consolidates or replaces entries to make room," which reads as if the
store self-compacts automatically. It does not: the `memory` tool
returns an overflow error and the agent does the consolidation in-turn
(the design from #41755). Also note that `replace` is bound by the same
limit — swapping in a longer entry can still overflow — which is the
exact case that confused a user (replace rejected near the cap even
though the math was correct).
2026-06-10 14:39:50 -07:00
xxxigm
f7a6d6a6a1 test(cron): cover provider "custom" → providers.custom resolution
Add execution-time coverage that bare `provider="custom"` resolves a literal
providers.custom endpoint (and still falls through when none exists), plus
creation-time coverage that `_resolve_model_override` keeps a resolvable
"custom" and only pins the main provider when it is unresolvable.
2026-06-10 14:39:03 -07:00
xxxigm
acd4f34e65 fix(cron): resolve per-job provider "custom" to providers.custom instead of codex
A cron job stored with `provider: "custom"` and a matching `providers.custom`
entry in config failed at execution with `auth_unavailable: providers=codex`.
Two layers conspired:

- `_get_named_custom_provider` returned None for bare "custom" *before*
  scanning config, so a literal `providers.custom` entry was never matched and
  resolution fell through to the global default (codex). Now it scans config
  for an entry literally named "custom"; with none it still returns None,
  preserving the legacy model.base_url trust path.
- `_resolve_model_override` blindly stripped bare "custom" at job creation and
  pinned `model.provider` (e.g. codex). It now keeps "custom" when a configured
  custom endpoint resolves, pinning the main provider only when it doesn't.
2026-06-10 14:39:03 -07:00
helix4u
1e7316ced2 fix(desktop): use sudo callback without interactive env 2026-06-10 14:29:56 -07:00
Tranquil-Flow
a8f404b29f fix(gateway): probe launchd domain instead of hardcoding user/<uid> (#40831)
The previous fix for #23387 changed _launchd_domain() from gui/<uid> to
user/<uid> to support Background/SSH sessions on macOS 26+. However, this
broke Aqua sessions where gui/<uid> is the only working domain and
user/<uid> cannot bootstrap or manage the service.

Now _launchd_domain() probes which domain actually contains the loaded
service:
1. Try gui/<uid> first (Aqua sessions)
2. Fall back to user/<uid> (Background/SSH sessions)
3. Use launchctl managername as heuristic when neither has the service
4. Cache the result for the process lifetime

Regression tests cover all four paths plus caching behavior.
2026-06-10 12:39:48 -07:00
teknium1
2d75833abe chore(release): map ianculling for #36087 salvage 2026-06-10 12:39:44 -07:00
0xyg3n
9f95f72b98 fix(agent): strip api_messages in thinking-signature recovery so the retry actually omits thinking blocks
The thinking-signature recovery in agent/conversation_loop.py popped
reasoning_details from messages, then continued to retry. That had two
defects.

First, the strip never reached the wire payload. api_messages is built
once at the start of the turn by shallow-copying every entry in messages
(line 919 area). Each api_messages entry has its own reference to the
same reasoning_details list. When build_api_kwargs runs on every retry
iteration of the inner while-loop, it consumes api_messages, not
messages. Popping reasoning_details from messages left api_messages
untouched, so the retry's request still carried the same thinking
blocks Anthropic had just rejected. The classifier latched
thinking_sig_retry_attempted = True after the first attempt, and the
loop terminated with max_retries_exhausted on the same 400.

Second, the pop mutated the canonical message list. messages is the
same list _persist_session writes to state.db and the session
transcript, so a single recovery permanently wiped every signed
thinking block from the stored conversation. Subsequent turns reloaded
the stripped state, hit the same 400 ('invalid signature' or 'cannot
be modified', see #24107), and the agent stopped responding entirely.
Cascading compaction-ended sessions then chained off the corrupted
parent and the affected chat could not produce a response on any
future turn.

Move the strip onto api_messages, which is the API-call-time list
rebuilt into kwargs on every retry. messages is no longer touched, so
disk I/O stays clean and the recovery actually reaches the wire.

Observed against the native Anthropic Messages API on claude-opus-4-7
and claude-opus-4-8 with the interleaved-thinking-2025-05-14 beta on
hermes-agent 0.12.0 and 0.14.0. PR #24107 narrows the trigger; this
change makes the recovery do what it always claimed to do, and
prevents the destructive aftermath.

Tests cover the api_messages strip in isolation: pop on a shallow copy
does not affect the source, the canonical messages list survives the
strip, idempotency on a duplicate firing path, and a no-op when no
reasoning_details exist on the messages.

Related: #24107, #26959, #17861.
2026-06-10 12:39:44 -07:00
Ian Culling
86e10dd874 fix(agent): route 'thinking blocks cannot be modified' 400 to recovery
Anthropic returns a 400 when the thinking/redacted_thinking blocks in the
latest assistant message are mutated upstream: 'thinking or redacted_thinking
blocks in the latest assistant message cannot be modified. These blocks must
remain as they were in the original response.'

The classifier's thinking_signature branch only matched on the substring
'signature', so this variant fell through to a non-retryable client error
and hard-aborted the turn -- even though the existing strip-reasoning_details
-and-retry recovery would have healed it.

Broaden the 400 match to also catch 'cannot be modified' / 'must remain as
they were' (still gated on 'thinking'), routing it to the same recovery.
Adds a negative-case test so unrelated 'cannot be modified' 400s are not
swept in.

Defense-in-depth, orthogonal to the root-cause work in #35975 / #17861
(which prevent the block mutation in the first place). Only changes a
terminal-failure into a one-shot recovery.

Signed-off-by: Ian Culling <ian@culling.ca>
2026-06-10 12:39:44 -07:00
rob-maron
6110aed9be Suppress "Credit access paused" notice on free models (#43669)
* don't show credits message on free model

* PR comments
2026-06-10 23:55:06 +05:30
brooklyn!
6de3963e37 fix(desktop): keep model runtime state per session (#43702)
* fix(desktop): keep model runtime state per session

(cherry picked from commit f72ee87d99ee38cb7b5badeb9a8af869bb92073a)

* fix(desktop): keep footer model state scoped to active session

(cherry picked from commit d91942ebd4671ff857b5c8526dbf133f04782ecb)

* fix(desktop): restore stored runtime when resuming sessions

(cherry picked from commit 32b3793418257617b8da57e26151f079c2620d00)

* fix(desktop): persist live runtime changes for resume

(cherry picked from commit c58467779436dcef44a80ad55b52664752dc0837)

* fix(desktop): persist resumed endpoint runtime

* chore(attribution): map pinguarmy's commit email in AUTHOR_MAP

The salvaged commits on this branch preserve @pinguarmy's authorship
(郝鹏宇 / peterhao@Peters-MacBook-Air.local). Add the mapping so the
check-attribution CI gate resolves the email to the GitHub username.

---------

Co-authored-by: 郝鹏宇 <peterhao@Peters-MacBook-Air.local>
2026-06-10 18:16:50 +00:00
Teknium
07ac185904 fix(ci): exit-4 forensics for vanishing test files in run_tests_parallel.py (#43646)
* fix(ci): append filesystem forensics when a per-file pytest run exhausts exit-4 retries

A PR-added test file (tests/test_iron_proxy.py, PR #30179) repeatedly
failed exactly one CI shard with 'ERROR: file or directory not found'
across 4 runs (including a fresh merge SHA on fresh runners), while the
identical slice passes locally against the same merge commit and a
tree-integrity watcher confirms no sibling test mutates the repo. Three
unrelated branches showed the same one-shard signature the same day.

We currently cannot attribute these because the log only carries
pytest's exit-4 line. This adds a forensics block to the captured
output when exit-4 survives the retry loop:

- does the file exist NOW (post-retries)
- parent dir entry count + similarly-named entries
- git status --porcelain dirty-entry count + first 10 entries

Zero behavior change: rc stays 4, retries unchanged, forensics wrapped
in a broad try/except so they can never mask the failure.

Two new tests cover the exhausted-retries and genuinely-missing paths.

* chore: drop the two forensics tests — ship the runner change only
2026-06-10 10:04:17 -07:00
Shannon Sands
3acf73161f Move folder creation into dialog 2026-06-10 09:53:12 -07:00
Shannon Sands
dd60c49bb8 Add dashboard file drop upload panel 2026-06-10 09:53:12 -07:00
Shannon Sands
6fe4821926 Add dashboard file browser paths 2026-06-10 09:53:12 -07:00
Teknium
d986bb0c6d feat(dashboard): full-featured profile builder (model + skills + MCPs) (#39084)
* feat(profiles): extend create endpoint for full profile-builder (model + MCPs + skills)

Backend foundation for the dashboard profile builder. Extends POST /api/profiles
to accept, in one call, everything a profile needs beyond name/clone:

- mcp_servers[]  -> written into the new profile's config.yaml
- keep_skills[]  -> replace-semantics: disable every seeded skill not kept
- hub_skills[]   -> async install via 'hermes -p <name> skills install <id>'

All applied best-effort AFTER the profile dir exists, so a hiccup in any one
never 500s the create. Model/MCP/keep-skills writes are profile-scoped via the
HERMES_HOME context override (same mechanism as the existing _write_profile_model).
Hub installs go through a subprocess scoped with -p because skills_hub.SKILLS_DIR
is import-time-bound and the runtime override can't redirect it.

Adds two helpers (_write_profile_mcp_servers, _disable_unselected_skills) and a
TestClient test asserting all four paths land in the NEW profile's config and
the hub spawn is scoped to it. Design doc at docs/design/profile-builder.md.

* feat(dashboard): full-featured profile builder page

Adds a dedicated /profiles/new builder that composes everything a profile
needs into one stepped create flow, reusing the existing Models/Skills/MCP
data paths instead of duplicating them:

- Identity   name + description
- Model      provider+model picker (api.getModelOptions)
- Skills     keep-which-built-in/optional (replace semantics, default = full
             bundle) + skills-hub search/add (api.getSkills, searchSkillsHub)
- MCPs       add HTTP/stdio servers inline
- Review     blueprint -> single POST /api/profiles create

Nothing writes until Create; the one call commits model+MCPs+skill selection
and spawns hub-skill installs (reported in the success toast). ProfilesPage
header gets a 'Build' button (full builder) alongside 'Create' (quick modal).
Route is page-only (not in the sidebar nav). Verified with vite build (2258
modules, green).
2026-06-10 09:18:32 -07:00
ethernet
4cecb1a13a change(tooling): npm audit fix in website/ 2026-06-10 11:59:34 -04:00
ethernet
90f4b3040d change(tooling): remove react-compiler eslint, update concurrently
concurrently 9 had a critical vuln dependency,
react-compiler eslint plugin is built into react-hooks eslint plugin as
of https://react.dev/blog/2025/10/07/react-compiler-1
2026-06-10 11:59:34 -04:00
ethernet
3bfbb3f2a0 change(tooling): typecheck in CI, update ts to 6
fix(ui-tui): fix ts 6 real type errors

change(tooling): use new node everywhere
2026-06-10 11:59:34 -04:00
Davy
a72bb03757 fix(docker): optimize image size — .dockerignore, drop dev deps, split build layers (#38749)
* fix(docker): optimize image size with .dockerignore, drop dev deps, split build layers

Three changes to reduce the Docker image size and speed up rebuilds:

1. .dockerignore — exclude ~69 MB of files that are never needed inside
   the container: apps/ (desktop Tauri source), tests/, website/
   (Docusaurus), docs/, infographic/, nix/, plans/, packaging/, and
   various dotfiles (.envrc, .hadolint.yaml, .mailmap, etc.).  The
   existing .dockerignore already covered node_modules and .git; these
   additions prevent the remaining non-runtime content from inflating
   both the build context and the final image (COPY . .).

2. pyproject.toml — add a [docker] extra that mirrors [all] but omits
   [dev] (debugpy, pytest, pytest-asyncio, pytest-timeout, ty, ruff,
   setuptools).  The published image doesn't need test/debug tooling.
   Estimated savings: ~30-50 MB of Python packages.

3. Dockerfile — use --extra docker instead of --extra all in the
   uv sync layer.  Also split the COPY + npm run build so that the
   web/ and ui-tui/ frontend builds are cached independently from
   Python source changes (COPY . .).  A Python-only commit no longer
   invalidates the (slower) frontend build layer.

Note: the build-only apt packages (gcc, python3-dev, libffi-dev,
libolm-dev) are still installed in the final image.  Removing them
requires a true multi-stage build (builder → runtime), which is a
larger refactor tracked separately.

* fix(docker): remove redundant [docker] extra, revert to --extra all

The [docker] extra was identical to [all] on main — the PR had added [dev]
to [all] then created [docker] as [all] minus [dev], a no-op round-trip.
Revert [all] to its original form and drop the [docker] extra.

Keep the .dockerignore additions and frontend build layer reordering.
2026-06-10 03:08:00 -07:00
Gille
47e77ae166 fix(curator): use shared atomic state writer 2026-06-10 03:04:54 -07:00
Gille
4c797d0e23 fix(desktop): hide Windows console children launched by GUI 2026-06-10 03:04:54 -07:00
teknium1
189ffe7362 test: port voice-reply suffix assertions, fix change-detector cap test, add AUTHOR_MAP entry
- Add output_path suffix assertions (.ogg Telegram / .mp3 non-Telegram) to
  _send_voice_reply tests, covering the OGG voice-note path that landed on
  main in ae82eed2b (the PR's third commit was redundant with it).
- Convert test_gemini_default_is_32000 back to an invariant against
  PROVIDER_MAX_TEXT_LENGTH instead of a hardcoded literal.
- Map barronlroth@gmail.com -> barronlroth in scripts/release.py.
2026-06-10 02:57:39 -07:00
Barron Roth
2c19208224 feat(tts): add Gemini audio tag rewrite 2026-06-10 02:57:39 -07:00
Barron Roth
5718811de0 feat(tts): add Gemini persona prompt file 2026-06-10 02:57:39 -07:00
Teknium
af3c8b80b5 fix(tests): close pid-file read race in test_grandchild_reaped_via_pgroup (#43447)
The grandchild wrote its pid with open('w').write(...), so the polling
reader in the test could observe the file after creation but before the
write flushed, parsing '' -> ValueError: invalid literal for int().
Write to a temp file and os.replace() it into place so the pid file only
ever appears fully written.
2026-06-10 02:57:27 -07:00
Teknium
70d5d7e39b fix(memory,skills): repair write-approval inline prompt, gateway staging, and gateway /skills review (#43452)
Follow-ups to #38199/#43354 found in post-merge review:

- Inline CLI memory approval never worked: the per-thread approval callback
  was not passed to prompt_dangerous_approval, so the prompt_toolkit
  fail-closed guard (#15216) denied every gated foreground write without
  showing a prompt. Now invokes the registered callback directly; a crashed
  prompt falls back to staging instead of a silent deny.
- Gateway sessions claimed inline support but prompt_dangerous_approval has
  no gateway round-trip (that lives in the pending-approval queue), so gated
  gateway memory writes hit the input() fallback and denied. Gateway
  contexts now stage for /memory pending review.
- /skills pending|approve|reject|diff|approval now works on the gateway
  (gateway_config_gate on skills.write_approval), so skills staged from a
  messaging session can be reviewed there. Diff output truncated for chat.
- memory_tool validates required params before the gate so invalid writes
  are rejected immediately instead of staged and failing at approve time.
- Stale tri-state write_mode docstrings updated to the boolean gate; docs
  table corrected (inline prompt is interactive-CLI-only).
- 6 new tests covering the interactive approve/deny/error paths, gateway
  staging, skills never-prompt invariant, and pre-gate validation.
2026-06-10 02:57:15 -07:00
Teknium
a5c32cdf30 fix(update): self-heal a venv left half-built by an interrupted install (#42172)
* fix(update): self-heal a venv left half-built by an interrupted install

An update killed mid dependency-install (Ctrl-C, terminal close, WSL OOM)
could leave the venv with pip wiped and core deps (e.g. Pillow) missing,
with no automatic recovery — the user had to manually run ensurepip +
reinstall.

Drop an install-scoped .update-incomplete breadcrumb right before the dep
install and clear it only after core-dependency verification passes. On the
next launch (any command except 'update' itself), if the marker is present,
unconditionally bootstrap pip via ensurepip then re-run the .[all] install +
verification, then clear the marker. Failure leaves the marker for retry and
prints the manual recovery command. Never raises — recovery cannot block
launch.

* fix(update): address review — stderr-only recovery output, single-flight lock, gitignore marker

- Route all recovery output (status lines + streamed pip/uv install via
  fd-level dup2) to stderr so protocol-on-stdout launches (hermes acp)
  never get install noise on the JSON-RPC stream.
- Single-flight O_EXCL lockfile (.update-incomplete.lock) so a gateway
  start + CLI launch (or two profiles) can't run concurrent installs
  into the shared venv; stale locks (>1h) are broken for the next launch.
- gitignore .update-incomplete + lock so source-tree installs keep a
  clean git status and update's autostash skips them.
- Document why the loose 'update' argv substring match is intentional
  (over-match defers one launch; under-match would race the real update).
- 4 new tests: lock held → skip, stale lock broken, lock released,
  output lands on stderr only.
2026-06-10 02:57:05 -07:00
Ben Barclay
15813336cc fix(config): preserve original .env file mode in remove_env_value too (#43349)
#33699 fixed save_env_value so an operator-set .env mode (e.g. 0640 on a
Docker bind-mount) survives a config write instead of being re-tightened
to 0600 by the unconditional _secure_file() call. The sibling
remove_env_value() had the identical bug: it restores original_mode and
then unconditionally called _secure_file(env_path), clobbering the mode
back to 0600 on every `hermes config remove KEY`.

Apply the same fix: move _secure_file() into the else branch so it only
runs when no original mode was captured (a freshly created .env still
gets 0600 hardening; existing operator-set modes survive).

Added test_remove_env_value_preserves_existing_file_mode_on_posix, which
fails on the unfixed remove path (expected 0o640, got 0o600) and passes
with the fix.
2026-06-10 19:53:07 +10:00
Siddharth Balyan
183d86b3e0 fix(openrouter): route reasoning_effort to verbosity for adaptive Anthropic models (#43436)
* fix(openrouter): route reasoning_effort to verbosity for adaptive Anthropic models

Reasoning-mandatory Anthropic models (Claude 4.6+/fable/mythos-class) over
OpenRouter ignore reasoning.effort and use adaptive thinking. #42991 correctly
stopped Hermes from sending a reasoning field to them (it 400s), but put nothing
in its place — leaving agent.reasoning_effort a silent no-op on the OpenRouter
path: the model always ran at its adaptive default (high) regardless of config.

OpenRouter honors the requested effort on the top-level verbosity field instead
(maps to Anthropic output_config.effort). Route the existing
reasoning_config[effort] there for these models while still never emitting a
reasoning field, preserving the #42991 fix. No new config arg — the value the
user already sets via agent.reasoning_effort now flows to verbosity.

- low/medium/high/xhigh/max pass through verbatim (OpenRouter accepts the
  extended scale for Claude; verified live HTTP 200 + monotonic token spend).
- effort unset/none/disabled omits verbosity so the model keeps its default.
- native Anthropic transport already correct; unchanged.

Fixes #43432

* test(openrouter): cover real effort range (add minimal, frame max as passthrough)

Adversarial review noted the verbosity tests looped over 'max' — a value
parse_reasoning_effort can never produce — while omitting 'minimal', which it
can. Align the routing test with the real config range
(VALID_REASONING_EFFORTS = minimal/low/medium/high/xhigh) and keep a separate
value-agnostic passthrough test that documents why xhigh/max must survive
verbatim (TypedDict, no runtime literal validation; OpenRouter accepts the
extended scale for Claude).

* docs: explain reasoning_effort -> verbosity routing for adaptive Anthropic models

Document that reasoning_effort transparently maps to OpenRouter's verbosity
field for adaptive-thinking Anthropic models (Claude 4.6+/Fable/Mythos), where
reasoning.effort is ignored. Note xhigh is the configurable ceiling (max is wire-
only). Add verbosity as a top-level-kwarg example in the provider-plugin guide.
2026-06-10 15:03:01 +05:30
Teknium
cd9a9cd8e5 fix(gateway): Slack approval UX in threads — block-size overflow + typed-prefix instruction text (#43444)
Two fixes for the reported Slack thread approval UX:

1. Slack Block Kit approval/confirm sends silently overflowed the
   3000-char section-block cap (flat 2900-char truncation + header +
   reason), so long execute_code approvals failed with invalid_blocks
   and fell back to the plain-text prompt with no buttons. Budget the
   command preview against the rendered fixed parts so blocks never
   exceed the cap (send_exec_approval + send_slash_confirm).

2. The text fallbacks told users to reply /approve — which Slack blocks
   inside threads and Matrix clients reserve client-side. Add a
   typed_command_prefix capability flag on BasePlatformAdapter
   (default "/"; Slack and Matrix set "!" to match their existing
   bang-prefix rewrite) and use it in the shared fallback prompt
   builders (exec approval, update prompt, destructive slash confirm,
   expensive-model confirm) plus Matrix's reaction-prompt text.
   The slash-confirm text-intercept now also accepts bang-prefixed
   replies (!always, !cancel) since those keywords aren't registered
   commands and the adapters' rewrite doesn't touch them.
2026-06-10 02:30:01 -07:00
Evi Nova
5d8c44a393 fix(docker): pre-install matrix deps in Docker image (#30399) (#42413)
The Matrix gateway requires mautrix[encryption] which pulls in
python-olm. While python-olm was removed from [all] due to missing
Windows/macOS wheels, it has binary manylinux wheels for Linux
amd64/arm64. The Docker image only runs on Linux, so adding --extra
matrix to the uv sync line is safe.

libolm-dev is already in the apt-get install line for runtime linking.

Fixes: #30399
2026-06-10 19:23:06 +10:00
kshitij
2f19512341 fix(cli): repair non-UTF-8 stdout/stderr on all platforms, not just Windows (#43439)
`hermes setup` (and other banner-printing commands) crash with an unhandled
UnicodeEncodeError on Linux hosts whose locale selects a non-UTF-8 codec —
e.g. a fresh Raspberry Pi / minimal Debian with a latin-1 or C/POSIX locale.
The setup wizard prints box-drawing characters (┌│├└─) and the ⚕ glyph before
any stream repair runs, so the command dies before it can start.

The existing _ensure_utf8() shim already knew how to re-wrap the standard
streams as UTF-8, but it returned early on `sys.platform != "win32"`, so the
identical crash class on Linux was never covered.

- Drop the win32 gate: repair any stdout/stderr whose encoding is not UTF-8.
- Prefer TextIOWrapper.reconfigure() so the stream object is fixed in place
  (cached sys.stdout references keep working); fall back to reopening the fd
  with closefd=False (the CPython-recommended safe variant).
- Use errors="replace" — matching the sibling hermes_cli/stdio.py shim — so a
  stray un-encodable byte degrades gracefully instead of crashing.
- Only set the PYTHONUTF8/PYTHONIOENCODING child-process hints when a repair
  actually happened, so a healthy UTF-8 host sees zero footprint (no stream
  swap, no env mutation).

This is intentionally the earliest, platform-agnostic guard, running at import
time before any banner prints. hermes_cli/stdio.py::configure_windows_stdio()
still runs later from the entry points for the Windows-only extras (console
code-page flip, EDITOR default, PATH augmentation); it early-returns on
non-Windows and its stream reconfigure is an idempotent no-op once we've
already repaired the streams here.

Add regression tests covering latin-1 and ascii/POSIX streams, the reconfigure
fallback, already-UTF-8 no-op (identity preserved + no env mutation), the
repair-sets-env and respects-explicit-env contracts, and hostile/None streams.
2026-06-10 02:21:00 -07:00
brooklyn!
f222bd26e7 Merge pull request #43430 from NousResearch/bb/desktop-tool-codicons-filled
style(desktop): filled glyphs for in-thread tool icons
2026-06-10 03:52:04 -05:00
Brooklyn Nicholson
38273676ea fix(desktop): carve sticky user bubbles out of the titlebar drag region
Sticky human bubbles park at --sticky-human-top (~4px), sliding under the
titlebar's -webkit-app-region:drag strips. Electron resolves drag regions at
the compositor level — z-index and pointer-events don't apply — so clicking a
stuck bubble dragged the window instead of opening the edit composer. Add
no-drag to the shared bubble base class (read-only bubble + edit composer).

Covers the runtime side with a test: clicking a user bubble opens the inline
edit composer through both the incremental external-store runtime and the
stock one.

(cherry picked from commit db4e1f4f3e)
2026-06-10 03:46:03 -05:00
Brooklyn Nicholson
c1308ebf3f style(desktop): filled SVG glyphs for in-thread tool icons
Replace the earlier text-stroke approach (which only bolds outline
codicons — a font glyph has no fillable region) with dedicated solid
SVG glyphs for tool rows. Adds ToolIcon, keyed by the same names as
TOOL_META, with a codicon fallback for uncovered tools.
2026-06-10 03:41:55 -05:00
teknium1
fa32af886f fix: dedupe concurrent gateway restarts + surface restart outcome in onboarding UI
Follow-ups to the salvaged Telegram QR onboarding auto-restart:

- _spawn_gateway_restart() reuses a live in-flight 'hermes gateway restart'
  child instead of spawning a second racing one (stale cached frontend +
  new backend both requesting a restart, or restart-button double-click).
  Both /api/gateway/restart and the onboarding apply path go through it.
- ChannelsPage polls /api/actions/gateway-restart/status after a
  server-initiated restart and surfaces a non-zero exit (e.g. systemd
  linger missing) via the manual-restart banner, since restart_started
  only means the child spawned.
- Test for the reuse path + _ACTION_PROCS isolation in existing tests.
2026-06-10 01:35:12 -07:00
Shannon Sands
984e69ff62 Auto-restart gateway after Telegram QR onboarding 2026-06-10 01:35:12 -07:00
Brooklyn Nicholson
e80754647c style(desktop): render in-thread tool codicons as filled glyphs
Outline codicons read too thin at conversation-tool scale; a scoped
filled modifier thickens tool-row and code-card icons without changing
icon semantics elsewhere in the shell.
2026-06-10 03:30:25 -05:00
Teknium
298bb93d39 feat(skills): show live per-source progress while browsing (#43398)
do_browse waited on a frozen 'Fetching skills...' spinner while sources
resolved, so a slow source looked like a hang. parallel_search_sources
already exposes an on_source_done(sid, count) callback fired as each source
completes — wire it into the status line so it ticks off sources live
(official (12), + github (4), + clawhub (500)). The page is still rendered
once, after the full set is merged and trust-sorted, so browse's
official-first ordering and pagination contract are untouched.
2026-06-10 01:02:40 -07:00
Teknium
eee1da45f0 fix(skills): bound ClawHub catalog walk to requested page on cold start (#43395)
Browse renders one page but the cold-cache fallback walked the entire
50k+ ClawHub catalog, then sliced off the first N — pure waste behind the
12s budget band-aid. _load_catalog_index now takes max_items: browse's
empty-query path bounds the walk to its limit and stops early; the offline
index builder still passes limit=0 (unbounded) and walks to exhaustion.
A bounded walk is partial, so it is not written to the shared full-catalog
cache (same poison-guard as the budget-truncated case).
2026-06-10 01:01:53 -07:00
konsisumer
6a30cfca82 fix(gateway): stop typing before post-delivery callbacks (#37556) 2026-06-10 00:46:00 -07:00
Teknium
888bf96025 chore(release): add tomekpanek to AUTHOR_MAP 2026-06-10 00:34:38 -07:00
tomekpanek
383d44bc9a fix(web): rank explicit credentials above managed-gateway probe
Backend selection ordered firecrawl (including the Nous-managed-tool-gateway
probe) ahead of explicit-credential backends, so a user who had both a
Nous OAuth token AND a TAVILY_API_KEY (or EXA/PARALLEL key) got firecrawl
auto-selected — then the request failed at runtime because the free Nous
tier does not include web search, and there is no fallback to the next
available backend. Explicit user setup lost to a managed convenience.

Reorder so direct-credential backends (tavily > exa > parallel > firecrawl-
direct) are tried first, then the managed-gateway firecrawl probe, then
free-tier fallbacks. Behaviour for users with only Nous OAuth (no
explicit key) is unchanged — firecrawl-via-gateway is still selected.

Behaviour change to flag: a user with BOTH a Nous OAuth token AND a
TAVILY_API_KEY (or EXA/PARALLEL key) now gets the explicit backend
instead of the managed gateway. This matches the principle of least
surprise — a user does not set TAVILY_API_KEY without intent — and
sidesteps the silent runtime failure of the gateway path on free tiers.
2026-06-10 00:34:38 -07:00
Teknium
243cada157 fix(model): cover typed gateway /model path + async-safe pricing lookups
Follow-ups on top of #26016's expensive-model guard:

- gateway/slash_commands.py: typed '/model <name>' now routes through the
  expensive-model confirmation gate (slash-confirm buttons / text fallback)
  instead of bypassing the guard the pickers enforce. Cancel leaves the
  session override and --global config untouched.
- telegram/discord/web_server: run expensive_model_warning() via
  asyncio.to_thread — it can hit models.dev or a /models endpoint on a
  cache miss, which would otherwise block the event loop.
- telegram: picker callback no longer toasts 'Model switched!' when the
  switch callback raised (both mm: and mc: paths).
- tests: new tests/gateway/test_model_command_expensive_confirm.py pins
  the typed-path gate (prompt, confirm-once, cancel, cheap-model no-op).
2026-06-10 00:24:06 -07:00
Robin Fernandes
af978ecb17 fix(model): require confirmation for expensive model selections
Rebased onto current main and re-ported across the restructured
surfaces: model flows now thread confirm_provider/base_url/api_key
through hermes_cli/model_setup_flows.py, the Discord picker lives in
plugins/platforms/discord/adapter.py, and the web dashboard picker
applies chat-mode switches via config.set so the expensive-model
confirmation can ride the response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:24:06 -07:00
teknium1
4eadef18a9 fix: guard role_authorized check against MagicMock test sources
Compare source.role_authorized with 'is True' so a MagicMock source
(test fixtures that build bare runners via object.__new__) doesn't
auto-truthy through the gate. The real SessionSource field is a bool,
so production behavior is unchanged. Fixes test_signal_in_allowlist_maps.
2026-06-10 00:18:11 -07:00
teknium1
099146fedd chore: add AUTHOR_MAP entry for PR #33958 contributor 2026-06-10 00:18:11 -07:00
Joel Chan
e5580f43c2 fix(discord): propagate role_authorized flag so DISCORD_ALLOWED_ROLES works end-to-end
DISCORD_ALLOWED_ROLES was checked by the Discord adapter (_is_allowed_user)
but gateway._is_user_authorized only read DISCORD_ALLOWED_USERS, so
role-authorized users were rejected with "Unauthorized user" at the
gateway layer despite passing the adapter gate.

- Add role_authorized: bool = False to SessionSource
- Add role_authorized param to build_source (base.py)
- Compute _role_authorized in on_message when user passes via role not user ID
- Thread _role_authorized through _handle_message -> build_source
- Check source.role_authorized early in _is_user_authorized (run.py)

Fixes #33952
2026-06-10 00:18:11 -07:00
达令小新
5a4297a11a fix(model_metadata): prefer hardcoded 1M for MiniMax M3 over stale models.dev probe 2026-06-09 23:24:40 -07:00
xxxigm
aea0b7397b test(discord): cover voice timeout under voice-off mode
Assert the inactivity handler skips disconnect (and the channel spam) when the
voice-mode getter reports "off", and still disconnects on genuine inactivity
when the mode is active.
2026-06-09 23:24:26 -07:00
xxxigm
311900842e fix(discord): don't auto-disconnect voice when reply mode is off
The voice inactivity timer (VOICE_TIMEOUT) only counted the bot's OWN audio
playback as activity. Under /voice off (text-only replies, but still in the
channel — leaving is /voice leave) nothing ever reset it, so every 300s the bot
disconnected and spammed "Left voice channel (inactivity timeout)."

The adapter now learns the live voice-reply mode via a getter wired from run.py
and skips the auto-disconnect while mode is off. It also resets the timer when a
user actually speaks to the bot, so an active listener (incl. voice-on
text-only sessions that never play audio) isn't dropped mid-conversation.
2026-06-09 23:24:26 -07:00
briandevans
105625d650 fix(skills): honour overall_timeout and bound ClawHub catalog walk
parallel_search_sources accepted an overall_timeout but never honoured it.
The ThreadPoolExecutor ran inside a `with ... as pool` block, whose __exit__
calls shutdown(wait=True); even after as_completed() raised TimeoutError on
schedule, leaving the block blocked the caller until every worker finished.
A single slow source (e.g. ClawHub) therefore stalled the entire browse for
minutes. Manage the executor manually and shut it down with
wait=False, cancel_futures=True in a finally, so the timeout actually returns
and not-yet-started work is dropped.

ClawHubSource._load_catalog_index walked up to 750 sequential pages with no
wall-clock bound (each request under its own timeout=30, so nothing errored),
and wrote the result to the index cache unconditionally — so an interrupted or
slow walk poisoned the cache with a partial catalog. Add a
CATALOG_WALK_BUDGET_SECONDS deadline that breaks the walk early, and only write
the cache when the walk reaches a natural stop (cursor exhausted or page cap),
never on a budget-truncated walk.

Adds regression tests covering both bugs (timeout honoured + slow source
flagged; budget abort does not poison cache) plus their happy-path invariants.
2026-06-09 23:22:54 -07:00
teknium
2ce3ae3d16 fix(error-classifier): don't misclassify unsupported-param 400s as context overflow
A GPT-5 model rejecting max_tokens returns a 400 whose message contains the
literal substring 'max_tokens' — one of the _CONTEXT_OVERFLOW_PATTERNS. The 400
path in _classify_400 checked overflow patterns before any request-validation
check (which only existed on the 5xx path), so the parameter error was routed
into the compression loop, re-sent with the same bad param, and ended in
'Cannot compress further' on a tiny context.

Hoist a request-validation guard (unsupported/unknown parameter) above the
context-overflow check in _classify_400. Deliberately excludes the generic
invalid_request_error code, which OpenAI also stamps on real overflow 400s, so
genuine overflows still compress. Pairs with the max_completion_tokens param
fix that stops the bad request at the source.

Also adds AUTHOR_MAP entry for the salvaged PR #13902 commit.
2026-06-09 23:22:10 -07:00
Xiangji
19c07c4037 fix(params): send max_completion_tokens for newer OpenAI families on custom endpoints
Third-party OpenAI-compatible endpoints (self-hosted gateways, OpenRouter,
Azure proxies) fronting gpt-4o / gpt-4.1 / gpt-5+ / o1-o4 models silently
received max_tokens and 400'd with unsupported_parameter, because the three
kwarg-selection sites only checked base_url_hostname(...) == "api.openai.com"
and fell through to max_tokens on every other host. The constraint is
enforced server-side by the model family, not by the URL, so name-based
detection is required as a fallback.

Changes:
- utils.py: new shared helper model_forces_max_completion_tokens(model) that
  prefix-matches gpt-4o, gpt-4.1, gpt-5, o1, o3, o4 families on normalized
  (lowercased, vendor-prefix-stripped) names.
- run_agent.py: _max_tokens_param ORs the helper into the URL check.
- agent/auxiliary_client.py:
  - auxiliary_max_tokens_param gains an optional keyword-only model arg.
  - _build_call_kwargs inline branch applies the same check for both
    provider == "custom" and non-custom paths.

Tests:
- tests/test_model_forces_max_completion_tokens.py: 31 new cases covering
  positive families, negatives (classic gpt-4, claude, llama, mistral, qwen,
  deepseek), vendor prefixes, case-insensitivity, whitespace, None/empty,
  and substring-not-prefix guards.
- tests/run_agent/test_run_agent.py::TestMaxTokensParam: 5 new model-based
  cases (custom + gpt-5.4, openrouter + gpt-4o-mini, custom + o1-preview,
  classic gpt-4-turbo keeps max_tokens, llama3 keeps max_tokens).
- tests/agent/test_auxiliary_client.py::TestAuxiliaryMaxTokensParam: new
  class, 7 tests covering the URL x model matrix.
2026-06-09 23:22:10 -07:00
Teknium
ab55008631 chore: add AUTHOR_MAP entry for OndrejDrapalik
Maps the salvaged #36781 commit author email to the GitHub login so the
release attribution + CI author check resolve.
2026-06-09 23:21:24 -07:00
Ondrej Drapalik
1c055a4c58 fix(xai): accept Grok Build code during loopback wait + tiny screenshot guard
xAI's consent page renders the authorization code in-page instead of
redirecting to the loopback callback, so the listener just hangs and the
manual-paste flow demands a callback URL that never contains the token.

- auth.py: poll stdin non-blockingly while waiting for the xAI loopback
  callback; accept a pasted bare Grok Build code and substitute the locally
  generated state (PKCE code_verifier still binds the exchange). No need to
  wait for timeout or re-run with --manual-paste.
- computer_use: parse PNG/JPEG dimensions from base64 and fall back to the
  text/AX/SOM payload when the screenshot is below the provider minimum
  (8x8), which xAI rejects with HTTP 400.
- model_setup_flows.py: xAI credential reuse prompt uses the standard radio
  picker via a shared _prompt_auth_credentials_choice helper.
- main.py: thread a title through _prompt_provider_choice; re-home the helper
  import (flows live in model_setup_flows.py post-decomposition).

Salvaged from #36781 onto current main (contributor's main.py edits re-homed
to model_setup_flows.py, where the flows were extracted since the PR opened).
2026-06-09 23:21:24 -07:00
Teknium
095f526b11 refactor(memory,skills): replace tri-state write_mode with boolean write_approval (default off) (#43354)
The shipped tri-state write_mode (on|off|approve) conflated two concepts —
whether writes are enabled and whether they're gated — so 'on' (writes flow
freely, gate inactive) read like 'gating is on'. Replace it with a single
clear boolean gate that defaults off.

  memory.write_approval / skills.write_approval:
    false (default) — write freely; the approval gate is off (pre-gate behaviour)
    true            — require approval: memory foreground prompts inline, memory
                      background-review + all skill writes stage for review

The old 'off = block all writes' mode is dropped; memory_enabled: false already
disables memory entirely, so a third 'block' state was redundant.

- tools/write_approval.py: get_write_mode/MODE_* → write_approval_enabled() bool;
  evaluate_gate() loses the config-driven 'blocked' path (blocked now only comes
  from an interactive user denial).
- tools/memory_tool.py, tools/skill_manager_tool.py: comment + behaviour follow.
- hermes_cli/config.py: memory/skills write_mode → write_approval (False);
  _config_version 28→29 with a 28→29 migration that renames any persisted
  write_mode (approve→true, on/off/unset→false) and drops the old key.
- slash commands: '/memory|/skills mode <on|off|approve>' → 'approval <on|off>'
  ('mode' kept as a back-compat alias); set_mode_fn callback now takes a bool.
- write_approval_commands.py, cli_commands_mixin.py, gateway/slash_commands.py,
  commands.py: handlers + registry args/subcommands updated.
- docs + tests rewritten for the boolean model; added migration tests.
2026-06-09 23:21:14 -07:00
synapsesx
9ca9697342 fix(gateway): return tuple from voice transcription on placeholder caption (#42090)
## What does this PR do?

The voice-during-active-run feature (#41984) changed
`_enrich_message_with_transcription` so that it returns a
`(enriched_text, successful_transcripts)` tuple instead of a bare string,
which lets callers echo the raw transcript back to the user. The signature
and every other return path were updated to match, but one branch was
missed: when a successfully transcribed clip arrives with the Discord
"empty content" placeholder as its caption, the method still returned the
prefix string on its own. All four call sites unpack the result with
`text, transcripts = await self._enrich_message_with_transcription(...)`,
so that path raised `ValueError: too many values to unpack (expected 2)`
and the inbound voice message was dropped instead of reaching the agent.

This is a real user-facing path rather than a corner case: a Discord voice
note sent without a caption is delivered as exactly that placeholder, so a
captionless voice message that transcribed correctly would crash the
handler precisely when transcription had worked. The fix returns the
proper tuple from that branch so the placeholder is still stripped while
the transcripts continue to flow back to the caller for the echo.

## Related Issue

N/A

## Type of Change

- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ]  New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ]  Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)

## Changes Made

- `gateway/run.py`: in `_enrich_message_with_transcription`, return
  `(prefix, successful_transcripts)` instead of a bare `prefix` from the
  empty-content-placeholder branch, so the contract matches the signature
  and the other return paths.
- `tests/gateway/test_stt_config.py`: add
  `test_enrich_message_with_transcription_returns_tuple_for_empty_content_placeholder`,
  which drives a successful transcription with the placeholder caption and
  asserts the placeholder is stripped while the transcript is still returned.

## How to Test

1. Check out `main` and run the new test — it fails with
   `ValueError: too many values to unpack (expected 2)`, reproducing the
   crash a captionless Discord voice note would trigger.
2. Apply this change and re-run
   `pytest tests/gateway/test_stt_config.py -q` — all tests pass.
3. `ruff check gateway/run.py tests/gateway/test_stt_config.py` and
   `python scripts/check-windows-footguns.py gateway/run.py
   tests/gateway/test_stt_config.py` both pass.

## Checklist

### Code

- [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md)
- [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)

### Documentation & Housekeeping

- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
2026-06-09 23:16:23 -07:00
Ben Barclay
63a421d4c0 fix(dashboard): _require_token endpoints all 401 behind the OAuth gate (#42578)
* fix(dashboard): let _require_token endpoints work behind the OAuth gate

In gated/OAuth mode (non-loopback bind without --insecure) the dashboard
authenticates the SPA via a session cookie and deliberately does NOT inject
the legacy ephemeral _SESSION_TOKEN into index.html. gated_auth_middleware
verifies the cookie and attaches request.state.session before any non-public
/api/ route runs; the legacy auth_middleware short-circuits in this mode too.

But several handlers call _require_token() directly, which only validated the
(absent) _SESSION_TOKEN header. So every cookie-authenticated request to those
endpoints 401'd — making plugin install/enable/disable, /api/dashboard/plugins/hub,
and the other _require_token routes permanently unreachable behind the gate.
In the UI this surfaced as a 401: {"detail":"Unauthorized"} popup on plugin
install for any publicly-bound (e.g. Fly-hosted NAS) dashboard.

Fix: _require_token now defers to the active gate. When auth_required is True it
accepts the request iff the gate attached a verified session (and 401s otherwise);
loopback/--insecure behavior is unchanged (still validates the session token).

Adds two regression tests driving the full in-process stub OAuth round trip:
the install endpoint must NOT 401 a logged-in request, and must still 401 with
no cookie. Verified the accept-test fails on the pre-fix code.

* test(dashboard): cover the whole _require_token route class under the gate

The install popup was one symptom of a class-wide bug: all 14 endpoints that
call _require_token directly (API-key reveal, provider validation, the
OAuth-provider connect/disconnect flow, and plugin enable/disable/update/
delete/visibility/providers) 401'd cookie-authenticated requests in gated mode.

Add a parametrized test hitting a representative spread (plugins/hub, env/reveal,
providers/validate, an oauth provider route, agent-plugin enable) asserting a
logged-in caller is never 401'd — proving the fix covers the class, not just
agent-plugins/install.
2026-06-09 22:57:49 -07:00
Ben Barclay
e4a1b35a39 fix(config): preserve original .env file mode instead of unconditionally tightening to 0600 (#33699)
`save_env_value()` captures the original .env file mode (e.g. 0640 for Docker
volume mounts) and restores it via `os.chmod` — but then unconditionally calls
`_secure_file(env_path)` on the next line, which re-tightens the mode to 0600
and defeats the entire preservation logic. The intent (preserve when
`original_mode` is captured, secure otherwise) was already in the code but
got short-circuited.

Move `_secure_file()` into the `else` branch so it only runs when no original
mode was captured — fresh `.env` files written for the first time still get
the 0600 hardening treatment, but operator-set modes survive subsequent writes.

Salvages #31518 by @blut-agent (config.py portion only). Their PR also bundled
unrelated lowercase-lookup changes in `hermes_cli/commands.py`; this salvage
takes only the focused config fix. The commands.py changes are reasonable on
their own merits but belong in a separate PR.

Co-authored-by: blut-agent <278569635+blut-agent@users.noreply.github.com>
2026-06-10 15:42:16 +10:00
Teknium
ea7981eba7 fix(dashboard): point webhook-disabled hint at Channels page (#43324)
The webhook 'platform disabled' card told users to enable it 'in your
messaging settings' — no such page exists. The webhook platform is
enabled on the Channels page (nav label), matching how every other
dashboard page refers to it.
2026-06-09 22:41:52 -07:00
kshitij
f1b8519670 Merge pull request #43322 from kshitijk4poor/fix/langfuse-redact-base64-data-uri
fix(langfuse): redact base64 data URIs instead of truncating into invalid base64
2026-06-09 22:41:41 -07:00
mnajafian-nv
f8fd30942c fix(cli): prevent duplicate one-shot finalize on interrupted cleanup (#43320)
Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>
2026-06-09 22:41:04 -07:00
teknium
1967c590ed chore: add AUTHOR_MAP entry for xiaoxinova
Maps xiaoxingitee@gmail.com -> xiaoxinova so the contributor-attribution
CI check passes when PR #42342 (MiniMax-M3 1M context fix) is merged.
2026-06-09 22:35:38 -07:00
LeonSGP
702f4df194 Repair cron ownership on container restart (#41976) 2026-06-10 15:32:34 +10:00
kshitij
0092015496 Merge pull request #43323 from kshitijk4poor/fix/skill-view-frontmatter-name-lookup
fix(skills): resolve skill_view by frontmatter name when dir name differs
2026-06-09 22:31:19 -07:00
kshitijk4poor
9caa12f4ec fix(skills): resolve skill_view by frontmatter name when dir name differs
skills_list() surfaces each skill's frontmatter `name:`, but skill_view()
only matched on the on-disk directory name (Strategy 2). When a skill's
directory is a shorter category/alias that differs from its frontmatter
name, skill_view(name) failed to find it. Extend the recursive Strategy-2
walk to also match frontmatter `name:`, guarded by a try/except so an
unreadable/malformed SKILL.md can't break discovery.

Adds a regression test that creates a skill whose directory name differs
from its frontmatter name and asserts skill_view resolves it (fails on
current main, passes with this change).

Salvaged the skill_view fix from #39682 onto current main as a standalone,
single-concern change with the test the original PR lacked.

Co-authored-by: foras910521-lab <foras910521-lab@users.noreply.github.com>
2026-06-10 10:51:45 +05:30
kshitijk4poor
4642762289 fix(langfuse): redact base64 data URIs instead of truncating into invalid base64
The Langfuse SDK treats `data:*;base64,...` strings as media and tries to
decode them. `_truncate_text` was slicing those strings mid-payload, producing
invalid base64 and noisy "Error parsing base64 data URI" logs. Observability
only needs the metadata, not raw image/audio bytes, so redact the whole data
URI (type, media_type, length) before it reaches the SDK.

Salvaged the Langfuse fix from #39682 onto current main as a standalone,
single-concern change (the dashboard `dist/**` and plugin-discovery parts of
that PR already landed separately on main).

Co-authored-by: foras910521-lab <foras910521-lab@users.noreply.github.com>
2026-06-10 10:49:36 +05:30
brooklyn!
bf7abc2f73 Merge pull request #43292 from NousResearch/bb/vscode-marketplace-themes
feat(desktop): install any VS Code theme from the Marketplace
2026-06-09 23:53:59 -05:00
mnajafian-nv
d03cdd63eb fix(cli): run one-shot query cleanup before lease release (#43036)
* fix(cli): run one-shot query cleanup before lease release

Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>

* test(cli): cover quiet one-shot cleanup finalization

Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>

---------

Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>
2026-06-09 21:52:13 -07:00
Teknium
96af61b6ef feat(memory,skills): approve/deny gate for memory + skill writes (#38199)
Adds memory.write_mode and skills.write_mode (on|off|approve), applied to
both foreground turns and the background self-improvement review fork — the
source of the unprompted 'wrong assumption' saves users reported.

- on (default): write freely, unchanged behaviour
- off: never write; the tool returns a clean disabled result
- approve: don't commit. Memory foreground writes prompt inline (small,
  reviewable in a chat bubble); background memory writes and ALL skill writes
  stage to a pending store instead (a SKILL.md is too large to review inline,
  and a daemon thread can't block on a prompt)

Review staged writes from CLI or any messaging platform:
  /memory pending|approve|reject|mode
  /skills pending|approve|reject|diff|mode

Skill review respects the size asymmetry: inline you see a one-line gist;
the full unified diff stays out-of-band (/skills diff, dashboard, or the
staged JSON file).

New: tools/write_approval.py (gate + pending store), hermes_cli/
write_approval_commands.py (shared CLI+gateway handlers). Gates wired at the
single entry points memory_tool() and skill_manage(), using the existing
write-origin ContextVar to distinguish foreground from background_review.
2026-06-09 21:51:43 -07:00
Brooklyn Nicholson
7803cbfbb9 style(desktop): use the nous overlay surface (--stroke-nous + --shadow-nous) for the HUDs
Drop the ad-hoc border + shadow-xl for the design-system borderless-overlay
pair already used by the dialog, keybind panel, and notification stack.
2026-06-09 23:49:02 -05:00
Brooklyn Nicholson
45e1689c03 fix(desktop): apply the shared HUD tokens to the marketplace submenu
The 'Install theme…' page is the one palette page rendered as a bespoke
component rather than through the shared CommandItem loop, so it missed the
compact HUD sizing. Route it through HUD_ITEM/HUD_TEXT and top-align the row
icon + status with the title line.
2026-06-09 23:43:29 -05:00
Teknium
fdc90346ea chore(skills): move red-team skills (godmode, obliteratus) to optional-skills — Anthropic classifier (#43221)
* chore(skills): remove red-team skills (godmode, obliteratus) from bundled catalog

Anthropic's output classifier on claude-fable-5 (and likely other Claude
models served through it) intermittently returns empty content for sessions
whose system prompt advertises these skills. The bundled skills-catalog block
is injected into every session's system prompt, so the descriptions

  - red-teaming/godmode      'Jailbreak LLMs: Parseltongue, GODMODE, ULTRAPLINIAN'
  - mlops/inference/obliteratus 'OBLITERATUS: abliterate LLM refusals (diff-in-means)'

trip the classifier on EVERY session regardless of which skill is actually
loaded, killing unrelated legitimate work (PR review, codebase audits, etc.).

Measured impact (controlled, interleaved A/B, claude-fable-5 via OpenRouter,
prompts differing only by the ~204 chars of these catalog lines, N=20 each):
  catalog lines present -> 19/20 (95%) blocked
  catalog lines absent  -> 5/20  (25%) blocked

Removing them ~quartered the block rate. Rewording the descriptions was not
enough; the skills must leave the bundled catalog.

- Delete skills/red-teaming/godmode and skills/mlops/inference/obliteratus
- Drop their generated doc pages + catalog/sidebar entries (EN + zh-Hans)
- Drop the godmode hand-written-page exception in generate-skill-docs.py

* chore(skills): relocate godmode + obliteratus to optional-skills

Rather than deleting outright, move both into optional-skills/ so they remain
installable via `hermes skills install` while leaving the always-injected
bundled catalog (which is what tripped Anthropic's classifier).

- optional-skills/security/godmode  (was skills/red-teaming/godmode)
- optional-skills/mlops/obliteratus  (was skills/mlops/inference/obliteratus)
- regenerate optional-skills catalog + sidebar entries
2026-06-09 21:41:00 -07:00
Teknium
f082b4ec5c fix(ci): make parallel runner's exit-4 retry robust for newly-added test files (#42994)
The per-file test runner re-runs a file once when pytest exits 4 ("file or
directory not found") while the file exists on disk — a transient seen on
loaded shared CI runners where the planner collects a file (--collect-only
counts its tests) but the per-file subprocess fails to stat it moments later.

A single immediate retry could land in the same brief high-load window and
fail again, and the retry was gated on one Path.exists() check that can itself
be a flaky stat under that load — so a freshly-added test file that LPT pins to
one shard would deterministically red that shard on every run (no actual test
failure; the file just never executes).

- Extract the subprocess spawn/communicate/process-tree-kill logic into a
  shared _spawn_pytest_once() helper (removes ~90 lines of duplication between
  the primary run and the retry).
- Replace the single-shot retry with a bounded backoff loop
  (_EXIT4_RETRY_ATTEMPTS, escalating sleep) that re-runs while the file is
  present on disk.
- Add _file_present() which re-checks existence across a few spaced stats, so a
  single flaky negative stat doesn't wrongly conclude the file is missing. A
  genuinely-missing file (typo/deleted) still fails fast — exit 4 is not
  swallowed when the file truly does not exist.
- Tests: transient-then-pass recovery, genuinely-missing fails fast with no
  retry, give-up after max attempts, and _file_present transient/missing cases.
2026-06-09 21:39:09 -07:00
Brooklyn Nicholson
833410e02b feat(desktop): theme the terminal ANSI palette + restyle the Cmd-K / Ctrl-Tab HUDs
Imported VS Code themes now carry their integrated-terminal ANSI palette
(`terminal.ansi*`), keyed to the painted variant (terminal / darkTerminal).
The terminal adopts it when the full base-8 set is present and keeps its VS
Code defaults otherwise; withSurface still owns the background, so the pane
stays translucent.

Pull the command palette and session switcher into a shared top-center HUD
(`floating-hud.ts`): no dim/blur backdrop, one compact text + item-padding
size, sidebar-label-style section headers (brand-tinted, uppercase), and the
themed portal scrollbar.
2026-06-09 23:37:50 -05:00
Teknium
6b330522e1 docs(agents): add Design Philosophy + Contribution Rubric to AGENTS.md (#42641)
AGENTS.md was almost entirely how-to/mechanics with the want/don't-want
guidance implicit and scattered. Adds a single authoritative intent layer
near the top, calibrated against what actually merges and what actually
gets rejected.

- 'What Hermes Is': framing + the two properties that drive design
  (prompt-cache integrity, narrow-waist core).
- 'Contribution Rubric': dual-purpose intent doc — (1) for humans/own work:
  what gets merged vs rejected; (2) for the triage sweeper: when a PR is safe
  to close on the three allowed reasons AND when NOT to close one. Taste-based
  'won't implement / out of scope' closes stay human-only by design.
  - 'What we want' calibrated against the last ~55 merges: fix real bugs well,
    expand reach at the edges (platforms/channels/providers/models/desktop —
    large features land routinely), refactor god-files into clean modules,
    keep the CORE narrow. 'Expansive at the edges, conservative at the waist.'
  - 'What we don't want': speculative hooks, .env-for-non-secrets, needless
    core tools, lazy-read escape hatches, feature-destroying fixes, ungated
    telemetry, change-detector tests, core-touching plugins.
  - 'Before you call it a bug — verify the premise (and when NOT to close)':
    distilled from real closes (#41741 intentional-design-not-a-gap, #41610
    wrong-premise, #42327 fix-never-executes, #42393 deliberate-omission,
    #41999 overreach). Doubles as sweeper guidance to avoid wrongly closing
    legitimate PRs.
- 'The Footprint Ladder' (core-tool decision): extend > CLI+skill > gated tool
  > plugin > MCP server in the catalog > new core tool (last resort).

Trim: 'Adding New Tools' intro points at the ladder. Detailed mechanics stay
where readers need them.
2026-06-09 21:31:07 -07:00
Austin Pickett
1770263ccc fix(desktop): honor default project directory for new sessions (#43234)
* fix(desktop): honor default project directory for new sessions

The Settings picker persisted project-dir.json but the renderer kept
seeding new chats from sticky localStorage home. Prefer the configured
default on boot and session.create, pin TERMINAL_CWD at backend spawn,
and reject packaged install-dir paths that regressed after #37536.

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

* fix(desktop): address review on default project dir PR

Add workspace cwd precedence tests, extract isPackagedInstallPath for
platform test coverage, and stop rewriting live $currentCwd when a
session is already active (cache-only until the next new chat).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 23:28:59 -05:00
Brooklyn Nicholson
33a5bfa3c4 Merge remote-tracking branch 'origin/main' into bb/vscode-marketplace-themes
# Conflicts:
#	apps/desktop/electron/main.cjs
#	apps/desktop/src/app/command-palette/index.tsx
#	apps/desktop/src/themes/context.tsx
2026-06-09 23:22:36 -05:00
brooklyn!
8f73d0d945 feat(desktop): resizable VS Code-themed terminal pane + palette polish (#42521)
* refactor(desktop): dock terminal under chat and simplify file rail

Keep the right rail focused on file browsing while moving the persistent terminal into the chat column bottom slot, and make terminal colors follow the active light/dark mode instead of a fixed Solarized palette.

* fix(desktop): make the terminal a resizable, themed side pane

- Move the terminal into a resizable pane (viewport-% widths) that shares
  <main>'s stacking context, so its drag handle no longer sits under the
  fixed terminal overlay; works on either rail side.
- Restore +x on node-pty's spawn-helper before the first spawn to fix
  "posix_spawnp failed" on macOS prebuilds (real cause; drop the redundant
  shell-candidate retry loop).
- Gate terminal open/fit/start on document.fonts.ready and strip leading
  blank rows (re-armed before the resize Ctrl-L redraw) so the prompt sits
  flush at the top with no starship add_newline gap.
- Inherit the app editor-surface color as the terminal background.
- Bind Ctrl+` (⌃` on macOS) to toggle the terminal; add a palette entry.

* feat(desktop): show platform hotkey hints in the command palette

- Render each palette item's live binding as a <KbdGroup> hint via a new
  comboTokens() helper (mac shows ⌘/⌃/⌥/⇧, every other platform shows
  Ctrl/Alt/Shift — never a ⌘ on PC).
- Default the terminal toggle to ⌘` / Ctrl+` (the ~ key) on both platforms.
- Drop the hardcoded (⌘⏎) baked into the composer steer tooltip; render it
  platform-aware with formatCombo instead.

* fix(desktop): drop the active check on the command-palette terminal item

* fix(desktop): remove active/check states from the command palette

* fix(desktop): allow ⌥/Shift-drag selection over mouse-mode TUIs

Full-screen apps (hermes --tui, vim) enable mouse reporting, so a plain
drag can't select text and ⌘/Ctrl+L (add-selection-to-chat) had nothing
to send. Enable macOptionClickForcesSelection so ⌥-drag on macOS (Shift
elsewhere) forces a native selection over mouse-mode apps.

* feat(desktop): tell the in-pane agent it's embedded in the GUI

Set HERMES_DESKTOP_TERMINAL=1 on the terminal pane's shell env and surface
it in build_environment_hints, so a hermes/--tui launched inside the pane
knows it's next to the GUI chat and that ⌥/Shift-drag + ⌘/Ctrl+L sends a
selection to the composer. Distinct from HERMES_DESKTOP (agent backend).

* refactor(desktop): drop the redundant Ctrl+` terminal-toggle fallback

The toggle now ships as mod+` on both platforms, so the standard combo
index handles it — the bespoke fallback (and its stale 'old default'
comment) is dead weight.

* fix(desktop): read live terminal selection for ⌘/Ctrl+L

A redraw-heavy TUI (spinners/clocks) outruns onSelectionChange, leaving the
React selection state empty so the state-gated shortcut listener never
attached and ⌘L no-op'd. Always listen and read xterm's live selection (with
a native fallback) at press time; only swallow the key when there's text to
send. Drops the now-redundant custom key handler.

* feat(desktop): make any agent aware it's in the Hermes desktop GUI

Generalize the runtime-surface hint: fire for HERMES_DESKTOP (the backend
powering the GUI chat) as well as HERMES_DESKTOP_TERMINAL (a hermes in the
embedded terminal pane), so it's about being inside the desktop GUI, not
about being a TUI. The terminal-pane selection note stays pane-specific.

* feat(desktop): give the GUI agent a read_terminal tool

The in-app terminal buffer lives in the renderer (xterm), so expose it to the
chat agent over the same blocking bridge clarify uses: read_terminal emits
terminal.read.request, the renderer serializes the buffer (visible screen by
default, or a start_line/count range against total_lines) and answers
terminal.read.respond. Gated to the GUI via HERMES_DESKTOP.

Also restores the flipped-layout titlebar inset fix (app-shell +
desktop-controller) for terminal/preview rails at the window's left edge.

* chore(desktop): trim read_terminal comments

* feat(desktop): add a terminal toggle to the statusbar

The file rail lost its terminal icon, leaving ⌘` and the command palette
as the only ways in. Add a one-click toggle to the statusbar's left
cluster, mirroring the command-center item: it reads $terminalTakeover so
it lights up while the pane is open and stays in sync with the hotkey, and
is gated to chat view (the only place the pane can show).

* fix(desktop): relabel the terminal header button to what it does

The in-pane button claimed a focus/split fullscreen toggle ("Focus
terminal view" / "Return to split view", screen-full/normal icons), but
the terminal is just a resizable side pane — there's no fullscreen. The
button only mounts while the pane is open, so the focus branch was dead
and clicking it merely closed the terminal. Relabel to "Hide terminal"
with a close icon, drop the dead conditional and the now-unused takeover
read.

* fix(desktop): move the terminal toggle next to the version item

Relocate it from the left cluster to the right of the statusbar, just
left of the client version item.

* feat(desktop): default the terminal to PowerShell on Windows

Prefer pwsh (7+) then Windows PowerShell 5.1 over cmd.exe, falling back to
comspec only when neither is present. -NoLogo drops the startup banner so
the prompt sits flush like the POSIX shells.

* feat(desktop): show a persistent divider on the terminal pane

The resize sash only painted on hover, so the terminal/chat boundary was
invisible at rest. Add an opt-in `divider` prop to Pane that paints a thin
resting hairline on the resize edge (side-aware, so it tracks the rail when
the layout flips) and enable it on the terminal pane.

* refactor(desktop): resolve the terminal shell instead of hardcoding it

Make shell selection a real resolver: an explicit override wins
(HERMES_DESKTOP_SHELL on both platforms, $SHELL on POSIX), otherwise
auto-detect the best installed shell — pwsh > Windows PowerShell 5.1 > cmd
on Windows, zsh > bash > sh on POSIX. A shared shellSpecFor() picks the
interactive flags by family, so an overridden bash/pwsh/cmd all launch
correctly.

* fix(desktop): repaint the terminal on light/dark switch

Setting term.options.theme updated colors for the DOM renderer but not the
WebGL one, which caches glyph colors in a texture atlas — so already-drawn
cells kept their old palette after a mode switch. Hold the WebglAddon in a
ref and clear its atlas when the theme changes.

* fix(desktop): match the terminal palette to VS Code Light+/Dark+

Adopt VS Code's exact default ANSI palette (the terminalColorRegistry
defaults), enable minimumContrastRatio: 4.5 so foregrounds are clamped
against the background the way the integrated terminal does, and key the
light/dark choice off renderedMode (the painted surface) instead of
resolvedMode so it can't invert. The canvas + inset paint the live skin
surface (--ui-editor-surface-background) so the terminal blends with the
app and follows light/dark, while the contrast clamp keeps colors crisp.

* fix(desktop): tighten command palette search to substring matching

cmdk's default fuzzy scorer matched anything with the query letters
scattered across an item, so e.g. "color" never narrowed to color
entries. Add a substring filter: every typed word must literally appear
in an item's value/keywords, keeping results tight and predictable.

* fix(desktop): blend the terminal header into the skin surface

The persistent-terminal overlay painted the static palette background
(#1e1e1e/#ffffff), so the transparent header strip revealed a near-black
slab above the surface-colored body. Paint the overlay with the live
--ui-editor-surface-background so header and body read as one pane.

* fix(desktop): re-resolve the terminal surface on skin switch

The canvas surface only re-resolved on light/dark change, so switching
skins at the same mode left the WebGL canvas painted with the old tint
until reload. Key the resolve off themeName too. Also trim the palette
comments.

* chore(desktop): drop redundant terminal theming header comment
2026-06-09 23:15:20 -05:00
Brooklyn Nicholson
27a3211579 feat(desktop): install any VS Code theme from the Marketplace
Browse + install color themes from the VS Code Marketplace straight from
Cmd-K and Settings → Appearance. The Electron main process resolves the
extension, unzips the .vsix with a hand-rolled zip reader (zlib only, no
new deps), and hands back the raw theme JSON; the renderer converts it to
a DesktopTheme with a small seed → color-mix mapping.

- Folds an extension's light + dark variants into one theme family, so the
  light/dark toggle switches Solarized/GitHub variants and installing in
  dark mode stays dark.
- Guarantees accent contrast (WCAG AA) so imported sidebar labels read
  instead of vanishing into the surface.
- Filters icon/product-icon packs out of the Themes-category search.
- "Install theme…" lives atop the Cmd-K theme picker; imports fold into
  the Light/Dark groups by the modes they support.
2026-06-09 23:06:44 -05:00
Ben Barclay
5cf6e28a2f fix(gateway): auto-start after container restart via planned-stop marker (#42675) (#43236)
* fix(gateway): auto-start after container restart via planned-stop marker

On Docker (s6-overlay), the gateway runs as a dynamically-registered s6
service. When the container stops/restarts/upgrades, s6 sends the gateway
a plain SIGTERM. The shutdown path (_stop_impl) ended with an
unconditional _update_runtime_status("stopped"), persisting
gateway_state=stopped to the volume. container_boot.py reads that on the
next boot and only auto-starts gateways whose last state was "running"
(_AUTOSTART_STATES) — so after a routine `docker compose up
--force-recreate` the gateway stays down and messaging channels silently
go dark, with no error surfaced (issue #42675).

The codebase already distinguishes intentional stops from unexpected
signals via the planned-stop marker (write_planned_stop_marker /
consume_planned_stop_marker_for_self): `hermes gateway stop`,
systemd/launchd ExecStop, and Ctrl+C write a marker before signalling,
so the handler classifies them as planned. An unmarked SIGTERM
(container/s6 restart, OOM, bare kill) is signal-initiated.

This wires that existing classification through to the state persist,
rather than adding unreliable signal-source inference:

- run.py: GatewayRunner._signal_initiated_shutdown, set in
  shutdown_signal_handler's unmarked-signal branch. In _stop_impl, a
  signal-initiated (non-restart) teardown now persists "running" instead
  of "stopped" — preserving the operator's run-intent and overwriting the
  mid-shutdown "draining" marker so _AUTOSTART_STATES matches on reboot.
  Operator stops and restarts persist "stopped" as before.

- service_manager.py: S6ServiceManager.stop() now writes the planned-stop
  marker for the supervised PID (read from s6-svstat) before `s6-svc -d`,
  so an in-container `hermes gateway stop` is correctly classified as
  intentional (parity with the systemd/launchd/host stop paths, which
  already mark). Best-effort: a marker-write failure falls back to the
  safe signal-initiated path.

Tests: shutdown persist-decision table (signal→running, operator→stopped,
restart→stopped), s6 stop marker write + svstat PID parse + failure
tolerance. The signal→running and s6-marker tests fail without the
respective source change. Verified end-to-end against a container built
from this branch: an unmarked SIGTERM to the live gateway leaves
gateway_state=running (shutdown-context log confirms signal path);
existing real container-restart suite still green.

* docs(docker): clarify gateway autostart distinguishes operator-stop from container-kill

The per-profile-supervision section described the autostart-across-restart
contract as "running gateways come back, stopped stay stopped" without
spelling out what records 'stopped'. That contract was the source of
#42675 confusion: users expected a restart to bring the gateway back and
it didn't. With the write-side fix, only an explicit `hermes gateway stop`
records 'stopped'; container/s6 restart SIGTERMs (incl. image upgrades and
unexpected exits) leave the state 'running' so the gateway auto-starts.
Make that distinction explicit in both the multi-profile and
per-profile-supervision sections.

* test(docker): real-restart autostart E2E for #42675

Adds test_live_gateway_autostarts_after_real_restart_without_manual_state_stamp:
a live s6-supervised gateway is killed by an actual `docker restart`
SIGTERM (no manual gateway_state stamp, no planned-stop marker) and must
auto-start on the next boot. Exercises the WRITE side of the fix that the
existing stamp-based tests bypass.

Verified to FAIL against an origin/main image (reconciler logs
prior_state=stopped action=registered — the #42675 bug) and PASS against
the fixed image (prior_state=running action=started).
2026-06-10 14:01:34 +10:00
Siddharth Balyan
b4170f3ac2 fix(cron): don't strict-scan script-injected output in no-skills jobs (#43223)
The runtime assembled-prompt scan (#3968 lineage) selected its pattern
tier on has_skills alone. A script-driven, no-skills job injects its
script's stdout into the prompt, and that blob was scanned with the
STRICT user-prompt pattern set — so any command-shape string in the
data feed (e.g. a triage bot ingesting a bug report that quotes
`rm -rf /`) hard-blocked the job on every tick.

Script output and context_from output are runtime DATA produced by
operator-authored code — the same trust class as install-vetted skill
markdown, not a user-authored directive prompt. Select the scan tier by
what the assembled prompt CONTAINS: when it includes skill content OR
injected data, use the looser _scan_cron_skill_assembled set (keeps
unambiguous injection directives, drops command-shape patterns,
sanitizes invisible unicode instead of blocking).

Defense-in-depth is preserved:
- The raw user prompt is still strict-scanned at create/update
  (api_server paths untouched) AND re-scanned strict at runtime even
  when the looser tier was selected for the data blob.
- Plain no-script/no-skills jobs keep the strict scan on the whole
  assembled prompt.
- Injection directives arriving via script stdout still block.

Rejected alternative: removing destructive_root_rm from the strict set
or a per-job skip_injection_scan flag — both weaken the guard globally.
2026-06-10 08:27:24 +05:30
Ben Barclay
7df3aa34b1 fix(dashboard-auth): warn when public_url override is silently rejected (#43214)
A non-empty HERMES_DASHBOARD_PUBLIC_URL / dashboard.public_url value that
fails URL validation (overwhelmingly: a missing http(s):// scheme, e.g.
"hermes.domain.com") was silently discarded by resolve_public_url(),
falling back to reconstructing the OAuth redirect_uri from request
headers. Behind a reverse proxy that doesn't forward X-Forwarded-Proto
reliably, that yields an http:// callback even though the operator
explicitly set the public URL — with no signal as to why (#42780).

Emit a deduplicated operator-facing WARNING (once per distinct value,
since resolve_public_url runs per request) naming the offending value
and the required scheme. Turns a silent footgun into a self-diagnosing
one; behaviour is otherwise unchanged.

Tests assert the warning fires for a scheme-less value, is deduplicated
across repeated calls, and stays silent for a valid value — all three
fail without the fix.
2026-06-10 12:14:57 +10:00
brooklyn!
b96bd4808d feat(desktop): open any chat in its own window (#43219)
Pops a session into a standalone, focused window for side-by-side work.
A secondary window loads the renderer at the session route with a
?win=secondary flag (ahead of the HashRouter '#'); it drops the global
sidebar plus the install/onboarding overlays and renders a single chat,
sharing the one local gateway over WS (no backend duplication). The main
process keys windows by sessionId so re-opening focuses the existing one
and self-cleans on close.

Open it via:
- ⌘-click (mac) / ⌃-click (win/linux) a sidebar session — the universal
  "open in new window" gesture. Archive moves to the ⋯ / right-click menus
  only, off the easy-to-misfire modifier-click.
- "New window" in the session ⋯ and context menus (link-external icon,
  i18n'd across en/ja/zh/zh-hant).

A standalone window has no left rail, so AppShell treats its edge as
uncovered and applies the titlebar inset — the chat title clears the
macOS traffic lights instead of hiding behind them.

Co-authored-by: tim404x <tim404x@users.noreply.github.com>
2026-06-09 21:09:45 -05:00
Ben Barclay
d33965396e feat(tui): include session name in the terminal titlebar (#43188)
The terminal/console titlebar was composed from status marker + model +
cwd only; the session's (auto-)title never appeared, even though the TUI
already knows it.

Change the format to `<marker> <session name> · <model> · <cwd>`, with the
session name and cwd each omitted when absent so single-segment titles stay
clean. The current session's live title is pulled from the existing
session.active_list poll (which already carries each session's current flag
and title), so there's no extra round-trip; UiState gains a sessionTitle
field updated only when it actually changes, preserving the existing
idle-flicker guard.

Extract the join logic into a pure composeTabTitle() helper in domain/paths
and cover its edge cases (name omitted, cwd omitted, whitespace-only name,
marker-only fallback, truncation, boundary length) in paths.test.ts.
2026-06-10 11:24:01 +10:00
Gille
258d24039f fix(desktop): scope thinking disclosure pending state (#43197) 2026-06-09 20:16:20 -05:00
brooklyn!
ab5f1a1f11 feat(desktop): Mac-style session switcher (^Tab / ^⇧Tab / ^1-9) (#43111)
Bind session.next/prev to Control+Tab / Control+Shift+Tab with a distinct
`ctrl` modifier token (literal Control on macOS — not Cmd, which the OS
reserves). Add ^1…^9 positional jumps mirroring profile ⌘1…⌘9.

Mac-style interaction:
- Quick ^Tab tap jumps on keydown with no HUD (even if Ctrl stays down)
- Hold Tab ~220ms, or tap Tab again while Ctrl is held → compact HUD
- Ctrl↑ commits the highlight; Esc cancels; rows clickable (^+click safe)
- Recency-ordered list snapshotted on open; cycles by stored session id

Includes combo.test.ts + session-switcher.test.ts.
2026-06-09 20:12:46 -05:00
brooklyn!
8bb6529553 fix(desktop): sidebar sections never overlap — two-mode CSS scroll + collapse/cap groups (#43147)
* fix(desktop): prevent sidebar section overlap

Use a shared sidebar section scroller only on short windows so sections do not overlap, while preserving per-section scrolling on taller layouts.

* fix(desktop): measure section stack for compact sidebar mode

Window-height media query kept big windows in compact mode whenever the OS chrome ate into 830px; observe the section stack element instead so compact only engages when the stack is actually short.

* refactor(desktop): drive sidebar compact mode with CSS, not JS

Replace the matchMedia hook with a `short` (max-height: 830px) Tailwind
variant so the per-section scrollers flatten into one shared scroll stack on
short windows purely in CSS. Taller windows keep their per-group scrollers and
recents virtualization unchanged.

* refactor(desktop): pure-CSS two-mode sidebar scroll + collapse/cap groups

Drop the JS-measured compaction in favour of a single `compact` height
variant (max-height: 768px):
- tall: every section is its own capped, independent scroller; Sessions
  is the lone flex-1 scroller.
- short: sections flatten and the stack scrolls as one.

Every section is now `shrink-0`, so nothing is squeezed below its
content and bled onto a sibling — the root cause of the header overlap
(flexbox implied min-size). Sessions keeps its virtualized scroller in
short mode only when it's the long list.

Non-session groups (messaging, cron) collapse by default — expanded ids
persist per platform — and render 3 rows, revealing 10 more on demand.
Extract the shared SidebarLoadMoreRow. Stress harness seeds 50 recents
to mirror the real first page.

* chore(desktop): trim sidebar comments, unify "compact" naming

Self-review polish: condense the over-long mode comments, use "compact"
consistently (matching the variant) instead of mixing "short", and drop a
no-op useCallback around revealMoreMessaging.

* chore(desktop): drop dev sidebar stress harness from the PR

Remove stress-probe.ts and its main.tsx import — it was a throwaway
testing aid, not something to ship.
2026-06-10 01:11:45 +00:00
BROCCOLO1D
29036155ce fix(terminal): lazy-parse docker env config (#42733)
Co-authored-by: BROCCOLO1D <279959838+BROCCOLO1D@users.noreply.github.com>
2026-06-10 11:04:27 +10:00
xxxigm
8b84d82227 fix(desktop): send on Enter from live editor text, not stale composer state (#39639)
* fix(desktop): send on Enter from live editor text, not stale composer state

Pressing Enter often did nothing (~90% with IME / fast typing); adding a
trailing space "fixed" it. The composer's submit path read the draft from the
AUI composer state (`useAuiState(s => s.composer.text)`) and the derived
`hasComposerPayload`, both of which lag the contentEditable DOM by a render. On
fast typing or IME composition the final keystroke(s) weren't in state yet, so
`submitDraft()` saw an empty draft and dropped the message. A trailing space
only worked around it by forcing an extra input event that flushed the state.

submitDraft() now refreshes draftRef from the editor node and submits/queues
based on the live DOM text, and the Enter handler decides the queue-drain vs
submit branch from the DOM too. draftRef is already synced on every input
event, so this just closes the in-flight-keystroke gap.

Fixes #39630. Also addresses the "typing + Enter does nothing" reports in

#39623.

* test(desktop): cover Enter-submit from live editor text (#39630)

Pin the contract that the composer's Enter path reads the live DOM editor
text, not the render-lagged composer state: a just-typed message sends even
when state hasn't synced; while busy it queues (never drains the queue or
cancels); an empty Enter while busy is a no-op; and an empty idle Enter
drains the next queued prompt. Faithful DOM-event repro mirroring
handleEditorKeyDown + submitDraft.
2026-06-10 00:51:23 +00:00
xxxigm
93340fa3c1 fix(tui_gateway): honor target profile's terminal.cwd on desktop profile switch (#40892)
* fix(tui_gateway): honor target profile's terminal.cwd on desktop profile switch

The desktop's app-global remote mode serves every profile from one
tui_gateway backend, so the process-global TERMINAL_CWD only reflects the
launch profile. After switching profiles, a new session resolved its
workspace from that stale env var and inherited the previous profile's
directory.

Add _profile_configured_cwd() to read a non-launch profile's own
terminal.cwd from its config.yaml (skipping placeholder/empty/missing and
non-existent paths so callers fall back cleanly), and wire it into
_completion_cwd() with precedence: explicit client cwd -> existing session
cwd -> bound profile's configured cwd -> TERMINAL_CWD -> os.getcwd().

Fixes #40334

* test(tui_gateway): cover per-profile cwd resolution (#40334)

Pin the new contract: _profile_configured_cwd reads a profile's own
terminal.cwd and rejects placeholders/missing paths, and _completion_cwd
prefers a bound profile's cwd over a stale launch-profile TERMINAL_CWD
while still letting an explicit client cwd win.
2026-06-09 19:45:29 -05:00
xxxigm
59ea2f98e6 fix(desktop): always show the Manage-profiles overflow (#42871)
The "..." overflow that opens the profile manager (the only UI to edit a
profile's SOUL.md) was gated behind profiles.length > 1, so a user with
only the default profile couldn't edit its persona without first creating
a throwaway second profile. Render it unconditionally.
2026-06-09 19:32:25 -05:00
brooklyn!
aecdacb11b Merge pull request #43109 from NousResearch/fix/desktop-remote-attach-drops
fix(desktop): stage dropped files into the remote session workspace
2026-06-09 19:22:11 -05:00
Brooklyn Nicholson
7ffc216bc0 fix(agent): make a binary @file: reference actionable instead of a dead end
A binary @file: ref (PDF, docx, spreadsheet, …) expanded to a bare
"binary files are not supported" warning with no content. The model saw a
failure and gave up — e.g. a dropped PDF came back as a text note claiming the
type was unsupported, even though the file was staged on disk right next to it.

Inject an actionable content block instead: the path, mime type, size, and a
nudge to use its tools to read/convert/view the file (and explicitly not to tell
the user the type is unsupported). General across every binary type — not
PDF-specific. The file already resolves where the agent's tools run (local cwd
or the staged copy in a remote session workspace), so it can act on it directly.
2026-06-09 19:16:46 -05:00
brooklyn!
218452b050 fix(state.db): recover from malformed sqlite_master so hidden sessions reappear (#43149)
* fix(state.db): recover from malformed sqlite_master so hidden sessions reappear

The corruption class behind "Desktop/Dashboard show no sessions while
hundreds of session files sit on disk" is a malformed sqlite_master — most
often a duplicate object row, e.g. two CREATE VIRTUAL TABLE messages_fts
entries — surfacing as:

    sqlite3.DatabaseError: malformed database schema (messages_fts) -
    table messages_fts already exists

SQLite parses the whole schema while preparing the FIRST statement on a
connection, so on this class every statement fails before it runs: PRAGMA
journal_mode (which is where SessionDB.__init__ actually trips, in
apply_wal_with_fallback, BEFORE _init_schema), PRAGMA integrity_check, and
even DROP TABLE. The only operations that still work are
PRAGMA writable_schema=ON plus direct sqlite_master surgery. A plain
FTS-index rebuild at the _init_schema layer therefore cannot reach or fix
this; the canonical sessions/messages rows are intact — only the derived
schema is broken.

Add a dedicated recovery that operates where the failure actually happens:

- hermes_state.repair_state_db_schema(): backs up the raw file first, then a
  least-destructive ladder — (1) de-duplicate sqlite_master keeping the
  lowest rowid per object (preserves the existing FTS index), escalating to
  (2) drop every messages_fts* schema object + VACUUM and let the next open
  rebuild the FTS index from messages. sessions/messages are never modified.
  Plus is_malformed_db_error() to discriminate this class.
- SessionDB.__init__ auto-heals: on a malformed-schema open error it repairs
  once (process-guarded against loops / concurrent web_server opens) and
  reopens, so Desktop/Dashboard recover on their own instead of silently
  showing "no sessions".
- hermes doctor --fix detects the malformed class and repairs it (reporting
  the recovered session count + backup name).
- hermes sessions repair [--check-only] [--no-backup] runs on the raw file
  path, since SessionDB() itself cannot open a malformed DB.

Supersedes #32589 and #33869: both targeted FTS corruption but gated their
repair behind statements (integrity_check / SELECT / DROP TABLE) that
themselves fail on this class, and neither addressed the apply_wal_with_fallback
open-time failure. Credit preserved via Co-authored-by.

Closes #33865.

Co-authored-by: João Vitor Cunha <145560011+plcunha@users.noreply.github.com>
Co-authored-by: Tuna Dev <273476039+tuancookiez-hub@users.noreply.github.com>

* test(state.db): cover strat-B escalation + unrepairable safe-fail paths

---------

Co-authored-by: João Vitor Cunha <145560011+plcunha@users.noreply.github.com>
Co-authored-by: Tuna Dev <273476039+tuancookiez-hub@users.noreply.github.com>
2026-06-09 18:49:08 -05:00
Brooklyn Nicholson
29147afd63 fix(desktop): friendlier toast when a remote attachment exceeds the 16MB cap
Remote attachments read their bytes through the readFileDataUrl IPC, which is
hard-capped at 16MB and rejects with a raw "file is too large (N bytes; limit M
bytes)" string straight into the failure toast (helix4u review note on #43109).

Translate that into "<file> is too large to upload to the remote gateway (max
16 MB)", parsing the limit out of the message so it tracks the real cap. Applies
to both the image and non-image remote read paths; non-cap errors pass through
unchanged. Adds unit coverage for both.
2026-06-09 18:31:09 -05:00
Brooklyn Nicholson
b021497bc8 fix(desktop): show a staging spinner in the edit composer while OS drops upload
The message-edit composer staged dropped OS files asynchronously with no
visible state, so confirming the edit before the upload resolved could send
the message without the gateway-side ref (helix4u review note on #43109).

Add a staging flag: while uploadOsDropRefs is in flight, show a small spinner
pill in the bubble and block submit (disabled send button + submitEdit guard)
so the edit can't outrace the ref insertion. New `attachingFile` i18n string
across en/zh/zh-hant/ja.
2026-06-09 18:26:54 -05:00
Brooklyn Nicholson
891c9a6823 fix(desktop): close eager-upload races flagged in review
Two races in the drop-time eager upload:

- Resurrected chip: the success path used addComposerAttachment, which
  re-appends when the id is gone, so a file removed mid-upload reappeared once
  the upload resolved. Add updateComposerAttachment (update-only; no-op when the
  chip was removed) and use it on both the eager success path and submit-time
  sync.
- Duplicate upload: submit-time sync didn't join an eager upload still in
  flight, so drop-then-Enter could fire file.attach twice and leave a duplicate
  under .hermes/desktop-attachments/. Track in-flight eager uploads by id and
  await the pending one before deciding to re-upload, reusing its gateway ref.

Tests: composer-store no-resurrect unit tests + a join-on-submit integration
test asserting a single file.attach.

Addresses @helix4u review on #43109.
2026-06-09 18:21:10 -05:00
kshitijk4poor
72154ad879 perf(ci): cache uv + use uv sync in tests workflow
Both jobs in tests.yml (`test` matrix and `e2e`) start from a cold uv
cache on every run and install deps with `uv pip install -e ".[all,dev]"`,
which re-resolves pyproject.toml ranges and rebuilds the editable install
each time.

Two changes:

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

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

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

Co-authored-by: Wesley Simplicio <wesleysimplicio@live.com>
2026-06-09 18:30:44 -04:00
Brooklyn Nicholson
153060e206 fix(desktop): render optimistic image thumbnails from in-hand base64
The in-flight user bubble seeded image attachment refs as `@image:<localpath>`.
In remote-gateway mode that path lives on the desktop, not the gateway, so the
inline thumbnail fetch hit /api/media and 403'd ("Path outside media roots"),
flashing a fallback chip until submit uploaded the bytes.

Seed (and keep) image refs as the raw base64 preview data URL instead. It
renders inline via extractEmbeddedImages with zero network, and survives the
post-sync rewrite (the agent gets the bytes through the attached-image pipeline,
not this display ref) so the thumbnail no longer remounts/flashes. Non-image
refs are unchanged.

Adds optimisticAttachmentRef + unit coverage.
2026-06-09 17:03:42 -05:00
Brooklyn Nicholson
4906dcfc25 fix(desktop): stage dropped files into the remote session workspace
Finder/OS drops became `@file:/Users/...` refs that only resolve when the
gateway shares the local disk, so on a remote gateway non-image files
(PDF/CSV/Markdown/...) never reached the agent. Route OS drops through the
file.attach / image.attach_bytes upload pipeline — in-app project-tree and
gutter drags stay inline workspace-relative refs — across every drop surface:
the conversation area, the composer form, the contenteditable input, and the
message-edit composer (which still reproduced the bug).

Also:
- upload dropped files eagerly when a session exists, so the card shows a
  spinner instead of stalling the send (images stay submit-time to avoid
  racing their thumbnail write);
- round the attachment card and drop the monospace detail;
- render image previews from the bytes we already hold, so a pasted/dropped
  screenshot shows its thumbnail and previews even when its only on-disk copy
  is a transient path (the data URL is not persisted to localStorage).

Supersedes #38615, #41203.

Co-authored-by: LeonSGP <154585401+LeonSGP43@users.noreply.github.com>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-09 16:50:08 -05:00
Teknium
57c6714995 fix(models): keep curated Anthropic aliases in /model picker (#43103)
The Anthropic picker returned the live /v1/models dump verbatim whenever
credentials were configured. Anthropic's API lags newly-routed curated
aliases (e.g. claude-fable-5, reachable on Anthropic before the models
endpoint enumerates it), so the curated entry vanished from the picker.

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

* test(install): cover the Electron mirror fallback

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

* docs(desktop): troubleshoot a stuck Electron download

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

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

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

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

---------

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

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

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

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

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

Add a PluginsHub overlay modeled on the existing SkillsHub:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #40176

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

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

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

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

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

* chore: add AUTHOR_MAP entry for PhilipAD

---------

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

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

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

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

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

Fixes #40503

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

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

---------

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

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

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

Two renderer regressions in the desktop chat app:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Salvage changes on top of the original PR:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

failing 8 of 9 tests on main for every PR.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Adds TestCustomPortalPersistence covering all four cases.

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Salvaged from #34467 by @yanxue06.
2026-06-08 15:52:33 -07:00
emozilla
984e6cb5b8 feat(whatsapp): add WhatsApp Business Cloud API adapter
Add an official, production-grade WhatsApp integration via Meta's
Business Cloud API as a complement to the existing Baileys bridge.
No bridge subprocess, no QR codes, no account-ban risk — at the cost
of a Meta Business account and a public HTTPS webhook URL.

Setup is fully wizard-driven: 'hermes whatsapp-cloud' walks through
every credential with paste-time validation (catches the #1 trap of
pasting a phone number into the Phone Number ID field), generates a
verify token, and ends with copy-paste instructions for the
cloudflared / Meta-dashboard / Business Manager pieces that can't be
automated. The wizard also points users at Meta's Business Manager
for setting the bot's display name and profile picture.

Feature set:

- Inbound: text, images (with native-vision routing), voice notes
  (STT), documents (small text inlined, larger cached), reply context.
- Outbound: text with WhatsApp-flavored markdown conversion, images,
  videos, documents, opus voice notes via ffmpeg with MP3 fallback.
- Native interactive buttons for clarify, dangerous-command approval,
  and slash-command confirmation flows — matches the Telegram /
  Discord UX, graceful degrades to plain text.
- Read receipts (blue double-checkmarks) and typing indicator,
  using Meta's combined endpoint so they fire in a single API call.
- Webhook security: X-Hub-Signature-256 HMAC verification (raw body,
  constant-time), wamid deduplication, group-shaped-message refusal
  (groups deferred to v2 — Baileys still covers them).
- Full integration with the gateway's session, cron, display-tier,
  prompt-hint, and auth-allowlist systems. Cloud and Baileys can run
  side-by-side against different phone numbers.

Also wires STT (speech-to-text) through Nous's managed audio gateway
for Nous subscribers — previously the default stt.provider=local
required a separate faster-whisper install. New subscribers now get
voice-note transcription out of the box.

Docs: 418-line user guide at website/docs/user-guide/messaging/
whatsapp-cloud.md, sidebar entry, environment-variables reference,
ADDING_A_PLATFORM.md updated with the optional interactive-UX
contract for future adapter authors.

Tests: 100 dedicated tests for the adapter, 32 for the setup wizard,
20 for the Nous subscription STT wiring, plus regression coverage
across display_config, prompt_builder, and the cron scheduler.

Known limitations (deferred until clear demand signal):
- Group chats — use the Baileys bridge if you need them.
- Message templates for 24-hour-window outside-conversation sends —
  reactive chat is unaffected; cron / delegate_task with gaps > 24h
  will fail with a clear error. The agent's system prompt warns the
  model about this so it knows to mention it when scheduling delayed
  messages.
2026-05-23 01:07:01 -04:00
716 changed files with 70555 additions and 9346 deletions

View File

@@ -63,3 +63,45 @@ data/
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
hermes-config/
runtime/
# ---------- Not needed inside the Docker image ----------
# Desktop app source (Tauri/Electron); never installed in the container
apps/
# Test suite — not shipped in production images
tests/
# Documentation site (Docusaurus) and supplementary docs
website/
docs/
# Assets only used by the GitHub README
assets/
infographic/
# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them)
plugins/hermes-achievements/docs/
# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker
nix/
flake.nix
flake.lock
packaging/
# Design and planning documents
plans/
.plans/
# ACP registry manifest (icon + agent.json) — not consumed at runtime
acp_registry/
# Repo-level dotfiles that are git-only or dev-tooling config
.env.example
.envrc
.gitattributes
.hadolint.yaml
.mailmap
# Top-level LICENSE (not matched by *.md); not needed inside the container
LICENSE

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

View File

@@ -44,7 +44,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
node-version: 22
cache: npm
cache-dependency-path: website/package-lock.json

View File

@@ -18,7 +18,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
node-version: 22
cache: npm
cache-dependency-path: website/package-lock.json

View File

@@ -55,15 +55,31 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
# pyproject.toml or uv.lock changes. `uv sync` still runs every
# time, but resolves from the warm cache instead of re-downloading
# and re-building wheels.
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
# `uv sync --locked` installs the exact pinned set from uv.lock (and
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
- name: Run tests (slice ${{ matrix.slice }}/6)
# Per-file isolation via scripts/run_tests_parallel.py: discovers
@@ -161,15 +177,31 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
# pyproject.toml or uv.lock changes. `uv sync` still runs every
# time, but resolves from the warm cache instead of re-downloading
# and re-building wheels.
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
# `uv sync --locked` installs the exact pinned set from uv.lock (and
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
- name: Packaged-wheel i18n smoke test
run: |

25
.github/workflows/typecheck.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
# .github/workflows/typecheck.yml
name: Typecheck
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
typecheck:
runs-on: ubuntu-latest
strategy:
matrix:
package:
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
fail-fast: false # report all failures, not just the first one
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 ${{ matrix.package }} typecheck

9
.gitignore vendored
View File

@@ -89,6 +89,9 @@ website/static/api/skills-index.json
# every build).
website/static/api/skills.json
website/static/api/skills-meta.json
# automation-blueprints-index.json is a build artifact emitted by
# website/scripts/extract-automation-blueprints.py during prebuild.
website/static/api/automation-blueprints-index.json
models-dev-upstream/
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
@@ -114,6 +117,12 @@ docs/superpowers/*
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
# — ignore so `git status` stays clean and update's autostash skips them.
.update-incomplete
.update-incomplete.lock
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/

205
AGENTS.md
View File

@@ -4,6 +4,201 @@ Instructions for AI coding assistants and developers working on the hermes-agent
**Never give up on the right solution.**
## What Hermes Is
Hermes is a personal AI agent that runs the same agent core across a CLI, a
messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI,
and an Electron desktop app. It learns across sessions (memory + skills),
delegates to subagents, runs scheduled jobs, and drives a real terminal and
browser. It is extended primarily through **plugins and skills**, not by
growing the core.
Two properties shape almost every design decision and are the lens for
reviewing any change:
- **Per-conversation prompt caching is sacred.** A long-lived conversation
reuses a cached prefix every turn. Anything that mutates past context,
swaps toolsets, or rebuilds the system prompt mid-conversation invalidates
that cache and multiplies the user's cost. We do not do it (the one
exception is context compression).
- **The core is a narrow waist; capability lives at the edges.** Every model
tool we add is sent on every API call, so the bar for a new *core* tool is
high. Most new capability should arrive as a CLI command + skill, a
service-gated tool, or a plugin — not as core surface.
## Contribution Rubric — What We Want / What We Don't
This is the project's intent layer. Use it two ways:
1. **For humans and for your own work** — what gets merged and what gets
rejected, so a contribution aims at the target.
2. **For automated review (the triage sweeper)** — guidance on when a PR is
safe to close on the three allowed reasons (`implemented_on_main`,
`cannot_reproduce`, `incoherent`) and, just as important, **when NOT to
close** one. Taste-based "we don't want this / out of scope" closes are NOT
an automated decision — those stay with a human maintainer. The sweeper's
job here is to recognize design intent and *avoid wrongly closing a
legitimate contribution*, not to make the won't-implement call itself.
Read the balance right: Hermes ships a **lot** — most merges are bug fixes to
real reported behavior, and the product surface (platforms, channels,
providers, models, desktop/TUI features) expands aggressively and on purpose.
The restraint below is aimed squarely at the **core agent + the model tool
schema**, the one place where every addition is paid for on every API call.
"Smallest footprint" governs *how a capability is wired into the core*, NOT
whether the product is allowed to grow. We are expansive at the edges and
conservative at the waist.
### What we want
- **Fix real bugs, well.** The bulk of what lands is `fix(...)` against an
actual reported symptom. A good fix reproduces the symptom on current
`main`, points to the exact line where it manifests, and fixes the whole bug
class — sibling call paths included — not just the one site the reporter hit.
- **Expand reach at the edges.** New platform adapters, channels, providers,
models, and desktop/TUI/dashboard features are welcome and land routinely,
including large ones (a new messaging channel, a session-cap feature, a
Windows PTY bridge). Breadth in the product is a goal, not a footprint
concern — as long as it integrates with the existing setup/config UX
(`hermes tools`, `hermes setup`, auto-install) rather than bolting on a raw
env var.
- **Refactor god-files into clean modules.** Extracting a multi-thousand-line
cluster out of `cli.py` / `run_agent.py` / `gateway/run.py` into a focused
mixin or module is wanted work, even when the diff is huge and mechanical
(large `+N/-N` refactors merge regularly). The "every line traces to the
request" test applies to *feature* PRs; a declared refactor's request IS the
extraction.
- **Keep the core narrow.** New *model tools* are the expensive exception —
every tool ships on every API call. Prefer, in order: extend existing code →
CLI command + skill → service-gated tool (`check_fn`) → plugin → MCP server
in the catalog → new core tool (last resort). See "The Footprint Ladder."
- **Extend, don't duplicate.** Before adding a module/manager/hook, check
whether existing infrastructure already covers the use case. When several PRs
integrate the same *category*, design one shared interface instead of merging
them one at a time (see the ABC + orchestrator note under the Footprint
Ladder).
- **Behavior contracts over snapshots.** Tests should assert how two pieces of
data must relate (invariants), not freeze a current value (model lists,
config version literals, enumeration counts). See "Don't write
change-detector tests."
- **E2E validation, not just green unit mocks.** For anything touching
resolution chains, config propagation, security boundaries, remote
backends, or file/network I/O, exercise the real path with real imports
against a temp `HERMES_HOME`. Mocks hide integration bugs.
- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict
message role alternation (never two same-role messages in a row; never a
synthetic user message injected mid-loop), and a system prompt that is
byte-stable for the life of a conversation.
- **Contributor credit preserved.** Salvage external work by cherry-picking
(rebase-merge) so authorship survives in git history; don't reimplement from
scratch when you can build on top.
### What we don't want (rejected even when well-built)
- **Speculative infrastructure.** Hooks, callbacks, or extension points with no
concrete consumer. Adding a hook is easy; removing one after plugins depend
on it is hard. A hook is NOT speculative if a contributor has a real, stated
use case — even if the consumer ships separately.
- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets
only (API keys, tokens, passwords). All behavioral settings — timeouts,
thresholds, feature flags, display prefs — go in `config.yaml`. Bridge to an
internal env var if the mechanism needs one, but user-facing docs point to
`config.yaml`. Reject PRs that tell users to "set X in your .env" unless X
is a credential.
- **A new core tool when terminal + file already do the job, or when a skill
would.** If the only barrier is file visibility on a remote backend, fix the
mount, not the toolset.
- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit`
pagination on tools that load content the agent must read fully (skills,
prompts, playbooks). Models will read page 1 and skip the rest.
- **"Fixes" that destroy the feature they secure.** A mitigation that kills the
feature's purpose is the wrong mitigation. Read the original commit's intent
(`git log -p -S`) before restricting behavior; find a fix that preserves the
feature.
- **Outbound telemetry / usage attribution without opt-in gating.** No new
analytics, third-party identifier tagging, or attribution tags until a
generic user-facing opt-in (config gate + setup prompt + `hermes tools`
toggle) exists. Park behind a label, do not merge.
- **Change-detector tests, cache-breaking mid-conversation, dead code wired in
without E2E proof, and plugins that touch core files.** Plugins live in their
own directory and work within the ABCs/hooks we provide; if a plugin needs
more, widen the generic plugin surface, don't special-case it in core.
### Before you call it a bug — verify the premise (and when NOT to close)
The most common reason a well-written PR gets closed is not code quality — it
is that the change is built on a **wrong premise**, or it treats an
**intentional design as a gap**. These patterns cut both ways: they tell a
human reviewer what to scrutinize, and they tell the automated sweeper when a
PR is NOT safe to close as `implemented_on_main` / `cannot_reproduce` (when in
doubt, leave it open for a human). They are distilled from real closes.
- **"Intentional design, not a gap."** A limitation that looks like an
oversight is often deliberate. Before "fixing" a missing link or a
restriction, ask whether the isolation IS the design. Example: profiles are
independent islands on purpose — a PR adding live config inheritance from the
default profile was closed because coupling profiles together is exactly what
the design prevents (the copy-at-creation `--clone` path already covers the
legitimate "start from my default" case). Read the original commit's intent
(`git log -p -S "<symbol>"`) before assuming something is unfinished.
- **"The premise doesn't hold against how X actually works."** A PR's
justification frequently rests on a wrong mental model of an existing
mechanism. Trace the real code/runtime before accepting the rationale. Two
real closes: a rate-limit "re-probe during cooldown" PR (the breaker only
trips on a *confirmed-empty* account bucket, so re-probing just hammers a
bucket we've already proven empty); a usage-accumulation fix whose new branch
**never executes at runtime** because an earlier guard already popped the
state it depended on. If you can't point to the exact line where the bug
manifests AND show the fix changes that line's behavior, you haven't verified
the premise.
- **"This fix was wrong — the absence/omission was deliberate."** Adding the
obvious-looking missing piece can break things the omission was protecting.
Example: restoring "missing" `__init__.py` files made a test tree importable
as a dotted package that shadowed the real plugin, deleting its `register()`
at import time. The absence was load-bearing.
- **"Overreached / resurrected an approach we'd moved past."** Scope creep that
supersedes an agreed-on base, or revives a direction the maintainers
deliberately closed, gets rejected even when the code works. Keep the change
to the narrow piece that was actually agreed; offer the rest as a focused
follow-up.
The throughline: **verify the claim AND the intent against the codebase before
writing or merging a fix.** A confirmed reproduction on current `main` plus a
line-level account of where the fix acts beats a plausible-sounding rationale
every time. When in doubt about intent, it is cheaper to ask than to ship a
fix that fights the design.
### The Footprint Ladder (new capability decision)
Each rung adds more permanent surface than the one above. Choose the highest
(least-footprint) rung that correctly solves the problem:
1. **Extend existing code** — the capability is a variation of something that
already exists. Zero new surface.
2. **CLI command + skill** — manages config/state/infra expressible as shell
commands. The agent runs `hermes <subcommand>` guided by a skill. Zero
model-tool footprint. Default choice for subscriptions, scheduled tasks,
service setup. Examples: `hermes webhook`, `hermes cron`, `hermes tools`.
3. **Service-gated tool (`check_fn`)** — needs structured params/returns AND
only appears when a prerequisite is configured. Zero footprint otherwise.
Examples: Home Assistant tools (gated on token), memory-provider tools.
4. **Plugin** — third-party/niche/user-specific capability that doesn't ship in
core. Lives in `~/.hermes/plugins/` or a pip package, discovered at runtime.
5. **MCP server (in the catalog)** — if the capability genuinely needs to be a
tool (structured I/O the agent invokes) but isn't core-fundamental, prefer
building it as an MCP server and adding it to the MCP catalog over growing
the core toolset. The agent connects to it through the built-in MCP client;
zero permanent core-schema footprint, and it's reusable by any MCP host.
6. **New core tool** — only when the capability is fundamental, broadly useful
to nearly every user, and unreachable via terminal + file (or an MCP server).
Examples of correct core tools: terminal, read_file, web_search,
browser_navigate.
When 3+ open PRs try to integrate the same *category* of thing (memory
backends, providers, notifiers), don't merge them one at a time — design an
ABC + orchestrator, wrap the existing built-in as the first provider, and turn
the competing PRs into plugins against that interface.
## Development Environment
```bash
@@ -264,7 +459,7 @@ npm install # first time
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
npm start # production
npm run build # full build (hermes-ink + tsc)
npm run type-check # typecheck only (tsc --noEmit)
npm run typecheck # typecheck only (tsc --noEmit)
npm run lint # eslint
npm run fmt # prettier
npm test # vitest
@@ -302,9 +497,11 @@ A **separate** chat surface from both the classic CLI and the dashboard's embedd
## Adding New Tools
For most custom or local-only tools, do **not** edit Hermes core. Use the plugin
route instead: create `~/.hermes/plugins/<name>/plugin.yaml` and
`~/.hermes/plugins/<name>/__init__.py`, then register tools with
Before adding any tool, settle the footprint question first (see "The
Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
be core tools. For custom or local-only tools, do **not** edit Hermes core.
Use the plugin route instead: create `~/.hermes/plugins/<name>/plugin.yaml`
and `~/.hermes/plugins/<name>/__init__.py`, then register tools with
`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
enabled or disabled without touching `tools/` or `toolsets.py`.

View File

@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
@@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
#
# `uv sync --frozen --no-install-project --extra all --extra messaging`
# installs the deps reachable through the composite `[all]` extra
# (handpicked set intended for the production image), plus gateway
# messaging adapters that should work in the published image without a
# first-boot lazy install. We do NOT use `--all-extras`:
# (handpicked set intended for the production image — excludes `[dev]`),
# plus gateway messaging adapters that should work in the published image
# without a first-boot lazy install. We do NOT use `--all-extras`:
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
# redundancy), none of which belong in the published container.
@@ -164,19 +164,30 @@ RUN npm install --prefer-offline --no-audit && \
# image update and recall/retain then fails with
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
#
# The Matrix gateway's deps ([matrix] extra) are baked in because
# python-olm (transitive via mautrix[encryption]) builds from source on
# Python/image combinations without usable wheels. The Docker image is
# Linux-only, so keeping the native libolm/build-toolchain packages here
# avoids the cross-platform failures that kept [matrix] out of [all]
# while still making Matrix work in the published container. Fixes #30399.
#
# The editable link is created after the source copy below.
COPY pyproject.toml uv.lock ./
RUN touch ./README.md
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
# ---------- Frontend build (cached independently from Python source) ----------
# Copy only the frontend source trees first so that Python-only changes don't
# invalidate the (relatively slow) web + ui-tui build layer.
COPY web/ web/
COPY ui-tui/ ui-tui/
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . .
# Build browser dashboard and terminal UI assets.
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
# ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime.
# The venv needs to be traversable too.

View File

@@ -1,5 +1,6 @@
graft skills
graft optional-skills
graft optional-mcps
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs

View File

@@ -3,7 +3,9 @@
</p>
# Hermes Agent ☤
<p align="center">
<a href="https://hermes-agent.nousresearch.com/">Hermes Agent</a> | <a href="https://hermes-agent.nousresearch.com/">Hermes Desktop</a>
</p>
<p align="center">
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>

View File

@@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
account info to show (fail-open: caller just shows nothing).
"""
try:
from hermes_cli.nous_account import nous_portal_billing_url
from hermes_cli.nous_account import nous_portal_topup_url
if account_info is None or not getattr(account_info, "logged_in", False):
return None
@@ -213,7 +213,8 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
if not windows and not details:
return None
details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
details.append("(or run /credits)")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@@ -337,6 +338,93 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
return None
@dataclass(frozen=True)
class CreditsView:
"""Surface-agnostic data for the ``/credits`` command.
One portal fetch, one parse — consumed identically by the CLI panel, the
gateway button, and any other money surface. Fail-open: when not logged in
or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None
and callers degrade gracefully.
"""
logged_in: bool
balance_lines: tuple[str, ...] = ()
identity_line: Optional[str] = None
topup_url: Optional[str] = None
depleted: bool = False
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
"""Build the /credits view: balance block + identity line + top-up URL.
Reuses the same account fetch + snapshot + URL builder as the /usage credits
block, so the numbers always match. The balance block is the rendered
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
"""
not_logged_in = CreditsView(logged_in=False)
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return not_logged_in
except Exception:
return not_logged_in
try:
import concurrent.futures
from hermes_cli.nous_account import (
get_nous_portal_account_info,
nous_portal_topup_url,
)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(
timeout=timeout
)
except Exception:
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
return not_logged_in
if account is None or not getattr(account, "logged_in", False):
return not_logged_in
snapshot = build_nous_credits_snapshot(account)
# Balance lines = the snapshot block minus the two trailing affordance lines
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
# appends for the /usage surface. /credits renders its own button/panel.
balance_lines: list[str] = []
if snapshot is not None:
rendered = render_account_usage_lines(snapshot, markdown=markdown)
balance_lines = [
line
for line in rendered
if not line.lstrip().startswith("Top up:")
and not line.lstrip().startswith("(or run")
]
# Identity line — shown before any open (roadmap §4.4).
email = getattr(account, "email", None)
org_name = getattr(account, "org_name", None)
who: list[str] = []
if email:
who.append(str(email))
if org_name:
who.append(f"org {org_name}")
identity_line = ("Topping up as " + " / ".join(who)) if who else None
return CreditsView(
logged_in=True,
balance_lines=tuple(balance_lines),
identity_line=identity_line,
topup_url=nous_portal_topup_url(account),
depleted=getattr(account, "paid_service_access", None) is False,
)
def _resolve_codex_usage_url(base_url: str) -> str:
normalized = (base_url or "").strip().rstrip("/")
if not normalized:

View File

@@ -187,6 +187,7 @@ def init_agent(
thinking_callback: callable = None,
reasoning_callback: callable = None,
clarify_callback: callable = None,
read_terminal_callback: callable = None,
step_callback: callable = None,
stream_delta_callback: callable = None,
interim_assistant_callback: callable = None,
@@ -417,6 +418,7 @@ def init_agent(
agent.thinking_callback = thinking_callback
agent.reasoning_callback = reasoning_callback
agent.clarify_callback = clarify_callback
agent.read_terminal_callback = read_terminal_callback
agent.step_callback = step_callback
agent.stream_delta_callback = stream_delta_callback
agent.interim_assistant_callback = interim_assistant_callback

View File

@@ -49,7 +49,7 @@ def _ra():
AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset(
{"todo", "session_search", "memory", "clarify", "delegate_task"}
{"todo", "session_search", "memory", "clarify", "read_terminal", "delegate_task"}
)
@@ -679,15 +679,28 @@ def recover_with_credential_pool(
# long-running TUI sessions stuck on stale tokens until the user
# exited and reopened.
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
_auth_haystack = " ".join(
str(error_context.get(k) or "").lower()
for k in ("message", "reason", "code", "error")
if isinstance(error_context, dict)
)
if (
not is_entitlement
and status_code == 403
and "oauth authentication is currently not allowed for this organization" in _auth_haystack
):
is_entitlement = True
if (
not is_entitlement
and status_code == 403
and (agent.provider or "") == "anthropic"
and getattr(agent, "api_mode", "") == "anthropic_messages"
):
is_entitlement = True
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
_disambiguator_haystack = " ".join(
str(error_context.get(k) or "").lower()
for k in ("message", "reason", "code", "error")
if isinstance(error_context, dict)
)
_is_xai_auth_failure = (
"[wke=unauthenticated:" in _disambiguator_haystack
or "oauth2 access token could not be validated" in _disambiguator_haystack
"[wke=unauthenticated:" in _auth_haystack
or "oauth2 access token could not be validated" in _auth_haystack
)
if not _is_xai_auth_failure:
is_entitlement = True
@@ -1784,6 +1797,17 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
),
next_args,
)
elif function_name == "read_terminal":
def _execute(next_args: dict) -> Any:
from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool
return _finish_agent_tool(
_read_terminal_tool(
start_line=next_args.get("start_line"),
count=next_args.get("count"),
callback=getattr(agent, "read_terminal_callback", None),
),
next_args,
)
elif function_name == "delegate_task":
def _execute(next_args: dict) -> Any:
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)

View File

@@ -73,20 +73,50 @@ ADAPTIVE_EFFORT_MAP = {
"minimal": "low",
}
# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added
# xhigh as a distinct level between high and max; older adaptive-thinking
# models (4.6) reject it with a 400. Keep this substring list in sync with
# the Anthropic migration guide as new model families ship.
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
# ── Anthropic thinking-mode classification ────────────────────────────
# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking,
# and 4.7 additionally forbids the manual ``thinking`` block entirely and drops
# temperature/top_p/top_k. Newer Claude releases (4.8, and named models like
# claude-fable-5) follow the same modern contract — but they share no common
# version substring, so an allowlist of version numbers ("4.6", "4.7", …) goes
# stale the moment a model ships without a recognized number and silently
# routes it down the legacy manual-thinking path.
#
# Instead we DEFAULT unknown Claude models to the modern contract and keep an
# explicit *legacy* list of the older Claude families that still require manual
# thinking. This mirrors _get_anthropic_max_output's "default to newest" design
# (future models are unlikely to regress to the older contract), so each new
# Claude release works without a code change.
#
# Non-Claude Anthropic-Messages models (minimax, qwen3, GLM, …) are NOT Claude,
# so they fall through to the legacy path automatically — exactly what those
# manual-thinking endpoints need.
# Older Claude families that DON'T support adaptive thinking (manual thinking
# with budget_tokens only). Substring-matched against the model name.
_LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = (
"claude-3", # 3, 3.5, 3.7
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
"claude-sonnet-4-0", "claude-sonnet-4.0",
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
"claude-opus-4-5", "claude-opus-4.5",
"claude-sonnet-4-5", "claude-sonnet-4.5",
"claude-haiku-4-5", "claude-haiku-4.5",
)
# Older Claude families that DON'T accept the "xhigh" effort level (4.6 only
# supports low/medium/high/max). xhigh arrived with Opus 4.7. Adaptive models
# not in this list (4.7, 4.8, fable, future) accept xhigh.
_NO_XHIGH_CLAUDE_SUBSTRINGS = (
"claude-opus-4-6", "claude-opus-4.6",
"claude-sonnet-4-6", "claude-sonnet-4.6",
)
def _is_claude_model(model: str | None) -> bool:
return "claude" in (model or "").lower()
# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
# is the only supported mode; 4.7 additionally forbids manual thinking entirely
# and drops temperature/top_p/top_k).
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8")
# Models where temperature/top_p/top_k return 400 if set to non-default values.
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# ── Max output token limits per Anthropic model ───────────────────────
@@ -94,6 +124,8 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
# starves thinking-enabled models (thinking tokens count toward the limit).
_ANTHROPIC_OUTPUT_LIMITS = {
# Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
"claude-fable": 128_000,
# Claude 4.8
"claude-opus-4-8": 128_000,
# Claude 4.7
@@ -208,8 +240,17 @@ def _resolve_anthropic_messages_max_tokens(
def _supports_adaptive_thinking(model: str) -> bool:
"""Return True for Claude 4.6+ models that support adaptive thinking."""
return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
"""Return True for Claude models that use adaptive thinking (4.6+).
Defaults *unknown* Claude models to adaptive (the modern contract) and
only returns False for the explicit legacy list of older Claude families
that require manual budget-based thinking. Non-Claude Anthropic-Messages
models (minimax, qwen3, …) return False so they keep the manual path.
"""
if not _is_claude_model(model):
return False
m = model.lower()
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
def _supports_xhigh_effort(model: str) -> bool:
@@ -219,18 +260,33 @@ def _supports_xhigh_effort(model: str) -> bool:
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max
when this returns False.
Defaults unknown adaptive Claude models to accepting xhigh (4.7+ contract);
only the 4.6 family and legacy manual-thinking models are excluded.
"""
return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS)
if not _supports_adaptive_thinking(model):
return False
m = model.lower()
return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS)
def _forbids_sampling_params(model: str) -> bool:
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
Opus 4.7 explicitly rejects sampling parameters; later Claude releases are
expected to follow suit. Callers should omit these fields entirely rather
than passing zero/default values (the API rejects anything non-null).
Opus 4.7 introduced this restriction; later Claude releases follow it.
Defaults unknown Claude models to forbidding sampling params (the modern
contract). The 4.6 family still accepts them, and the legacy manual-thinking
families (4.5 and older) accept them too, so both are excluded. Non-Claude
models are unaffected. Callers should omit these fields entirely rather than
passing zero/default values (the API rejects anything non-null).
"""
return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
if not _is_claude_model(model):
return False
m = model.lower()
# 4.6 family is adaptive but still accepts sampling params.
if any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS):
return False
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
def _supports_fast_mode(model: str) -> bool:
@@ -821,6 +877,7 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
capture_output=True,
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
except (OSError, subprocess.TimeoutExpired):
logger.debug("Keychain: security command not available or timed out")
@@ -1163,7 +1220,10 @@ def run_oauth_setup_token() -> Optional[str]:
"Install it with: npm install -g @anthropic-ai/claude-code"
)
# Run interactively — stdin/stdout/stderr inherited so user can interact
# Run interactively — stdin/stdout/stderr inherited so the user can
# complete the OAuth login prompt. Must keep inherited stdin; the TUI-EOF
# concern does not apply to an interactive login the user explicitly
# invokes. noqa: subprocess-stdin
try:
subprocess.run([claude_path, "setup-token"])
except (KeyboardInterrupt, EOFError):
@@ -1511,6 +1571,15 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
if ptype == "input_text":
block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
elif ptype == "text":
# A stored Anthropic text block. Rebuild from whitelisted fields only —
# SDK response text blocks carry output-only siblings (parsed_output,
# citations=None) that the Messages INPUT schema rejects with HTTP 400
# "Extra inputs are not permitted". Do NOT dict(part) it verbatim.
block = {"type": "text", "text": part.get("text", "")}
cits = part.get("citations")
if isinstance(cits, list) and cits:
block["citations"] = cits
elif ptype in {"image_url", "input_image"}:
image_value = part.get("image_url", {})
url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
@@ -1625,6 +1694,58 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
return out
def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Strip output-only fields from a stored Anthropic content block so it is
valid as REQUEST input on replay.
The SDK response objects carry output-only attributes that the Messages
*input* schema forbids ("Extra inputs are not permitted"): text blocks get
``parsed_output``/``citations`` (when null), tool_use blocks get ``caller``,
etc. ``normalize_response`` captured blocks verbatim via ``_to_plain_data``,
so these leak back as input on the next turn → HTTP 400.
Whitelist per type (NOT a blacklist) so future SDK output-only fields can't
reintroduce the bug. Returns a clean block, or None to drop it.
"""
if not isinstance(b, dict):
return None
btype = b.get("type")
if btype == "text":
out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")}
# citations is input-valid ONLY when it's a non-empty list; the SDK
# emits citations=None on responses, which the input schema rejects.
cits = b.get("citations")
if isinstance(cits, list) and cits:
out["citations"] = cits
if isinstance(b.get("cache_control"), dict):
out["cache_control"] = b["cache_control"]
return out
if btype == "thinking":
out = {"type": "thinking", "thinking": b.get("thinking", "")}
if b.get("signature"):
out["signature"] = b["signature"]
return out
if btype == "redacted_thinking":
# Only valid with its data payload; drop if missing.
return {"type": "redacted_thinking", "data": b["data"]} if b.get("data") else None
if btype == "tool_use":
out = {
"type": "tool_use",
"id": _sanitize_tool_id(b.get("id", "")),
"name": b.get("name", ""),
"input": b.get("input", {}),
}
if isinstance(b.get("cache_control"), dict):
out["cache_control"] = b["cache_control"]
return out
if btype == "image":
src = b.get("source")
return {"type": "image", "source": src} if isinstance(src, dict) else None
# Unknown/unsupported block type on the input path — drop rather than risk
# another "Extra inputs are not permitted".
return None
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
"""Convert an assistant message to Anthropic content blocks.
@@ -1632,6 +1753,55 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
reasoning_content injection for Kimi/DeepSeek endpoints.
"""
content = m.get("content", "")
# Anthropic interleaved-thinking fast path: when this turn carries a
# verbatim, order-preserving block list (set by normalize_response only
# for turns that interleave SIGNED thinking with tool_use), replay it.
# Each block is run through _sanitize_replay_block to strip output-only
# SDK fields (parsed_output, caller, citations=None, …) that the Messages
# INPUT schema forbids — replaying them verbatim caused HTTP 400 "Extra
# inputs are not permitted" (text.parsed_output). Block ORDER is preserved
# (the reason this channel exists); only forbidden sibling fields are
# dropped, leaving thinking signatures and tool_use id/name/input intact.
ordered_blocks = m.get("anthropic_content_blocks")
if isinstance(ordered_blocks, list) and ordered_blocks:
# Re-source each tool_use input from the stored tool_calls map rather
# than the captured block. The ordered-blocks list captures tool_use
# input from the RAW API response (normalize_response), which is NOT
# credential-redacted; tool_calls[].function.arguments IS redacted at
# storage time (build_assistant_message, #19798). Replaying the raw
# block input would resurrect a secret the model inlined into a tool
# call (e.g. terminal(command="curl -H 'Authorization: Bearer sk-...'")
# onto the wire, even though the same value is redacted everywhere else
# in history. Keying by sanitized tool id preserves interleave order
# (the reason this channel exists) while swapping in the redacted
# input. Adapted from #36071 (replay-time tool-input re-sourcing).
redacted_input_by_id: Dict[str, Any] = {}
for tc in m.get("tool_calls", []) or []:
if not isinstance(tc, dict):
continue
fn = tc.get("function", {}) or {}
raw_args = fn.get("arguments", "{}")
try:
parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
except (json.JSONDecodeError, ValueError):
parsed_args = {}
redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args
replayed: List[Dict[str, Any]] = []
for b in ordered_blocks:
clean = _sanitize_replay_block(b)
if clean is None:
continue
if clean.get("type") == "tool_use":
# Override raw (un-redacted) input with the redacted copy when
# we have one for this id; fall back to the sanitized block
# input only if the tool_call is missing (shape mismatch).
redacted = redacted_input_by_id.get(clean.get("id", ""))
if redacted is not None:
clean["input"] = redacted
replayed.append(clean)
if replayed:
return {"role": "assistant", "content": replayed}
blocks = _extract_preserved_thinking_blocks(m)
if content:
if isinstance(content, list):

View File

@@ -102,7 +102,7 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
from agent.credential_pool import load_pool
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars
logger = logging.getLogger(__name__)
@@ -4300,13 +4300,15 @@ def get_auxiliary_extra_body() -> dict:
return _nous_extra_body() if auxiliary_is_nous else {}
def auxiliary_max_tokens_param(value: int) -> dict:
def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> dict:
"""Return the correct max tokens kwarg for the auxiliary client's provider.
OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'.
models (gpt-4o, gpt-4.1, gpt-5+, o-series) requires 'max_completion_tokens'.
The Codex adapter translates max_tokens internally, so we use max_tokens
for it as well.
for it as well. Pass ``model`` so third-party OpenAI-compatible endpoints
fronting the newer families are also recognised — URL-only detection
misses the case where a custom base URL serves e.g. ``gpt-5.4``.
"""
custom_base = _current_custom_base_url()
or_key = os.getenv("OPENROUTER_API_KEY")
@@ -4316,6 +4318,9 @@ def auxiliary_max_tokens_param(value: int) -> dict:
and _read_nous_auth() is None
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
return {"max_completion_tokens": value}
# ...and for any caller serving a newer OpenAI-family model by name.
if model_forces_max_completion_tokens(model):
return {"max_completion_tokens": value}
return {"max_tokens": value}

View File

@@ -208,6 +208,41 @@ def is_stale_connection_error(exc: BaseException) -> bool:
return False
def is_streaming_access_denied_error(exc: BaseException) -> bool:
"""Return True when AWS denied the ``bedrock:InvokeModelWithResponseStream`` action.
IAM policies scoped to ``bedrock:InvokeModel`` only (a common least-privilege
setup) reject ``converse_stream()`` with an ``AccessDeniedException`` whose
message names the streaming action, e.g.::
User: arn:aws:iam::123456789012:user/x is not authorized to perform:
bedrock:InvokeModelWithResponseStream on resource: ...
This is permanent for the session — retrying the stream can never succeed —
so callers should flip to the non-streaming ``converse()`` path (which maps
to ``bedrock:InvokeModel``) instead of burning retries.
Detection is deliberately message-based: boto3 surfaces this as a
``ClientError`` with ``Error.Code == "AccessDeniedException"``, and the
AnthropicBedrock SDK wraps the same AWS response in its own exception
types, but both preserve the action name in the message.
"""
msg = str(exc).lower()
if "invokemodelwithresponsestream" not in msg:
return False
# ClientError with an explicit access-denied code is the canonical form.
try:
from botocore.exceptions import ClientError
except ImportError: # pragma: no cover — botocore always present with boto3
ClientError = None # type: ignore[assignment]
if ClientError is not None and isinstance(exc, ClientError):
code = (getattr(exc, "response", None) or {}).get("Error", {}).get("Code", "")
return code in ("AccessDeniedException", "UnauthorizedException")
# Wrapped forms (e.g. AnthropicBedrock SDK PermissionDeniedError) — match
# on the authorization-failure phrasing AWS uses.
return "not authorized" in msg or "accessdenied" in msg
# ---------------------------------------------------------------------------
# AWS credential detection
# ---------------------------------------------------------------------------
@@ -1003,6 +1038,16 @@ def call_converse_stream(
try:
response = client.converse_stream(**kwargs)
except Exception as exc:
if is_streaming_access_denied_error(exc):
# IAM allows bedrock:InvokeModel but not
# InvokeModelWithResponseStream — permanent for this session.
# Fall back to the non-streaming converse() path.
logger.info(
"bedrock: converse_stream denied by IAM on (region=%s, model=%s) — "
"falling back to non-streaming converse().",
region, model,
)
return normalize_converse_response(client.converse(**kwargs))
if is_stale_connection_error(exc):
logger.warning(
"bedrock: stale-connection error on converse_stream(region=%s, "

View File

@@ -952,6 +952,18 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
if preserved:
msg["reasoning_details"] = preserved
# Anthropic interleaved-thinking replay: when a turn interleaves signed
# thinking blocks with tool_use, the parallel reasoning_details +
# tool_calls fields lose the cross-type ordering, and reconstruction
# front-loads thinking — reordering signed blocks and triggering HTTP 400
# ("thinking ... blocks in the latest assistant message cannot be
# modified"). Carry the verbatim ordered block list so the adapter can
# replay the latest assistant message unchanged. See
# agent/transports/anthropic.py and agent/anthropic_adapter.py.
ordered_blocks = getattr(assistant_message, "anthropic_content_blocks", None)
if ordered_blocks:
msg["anthropic_content_blocks"] = ordered_blocks
# Codex Responses API: preserve encrypted reasoning items for
# multi-turn continuity. These get replayed as input on the next turn.
codex_items = getattr(assistant_message, "codex_reasoning_items", None)
@@ -1603,6 +1615,8 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
is_streaming_access_denied_error,
normalize_converse_response,
stream_converse_with_callbacks,
)
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
@@ -1611,6 +1625,29 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
try:
raw_response = client.converse_stream(**api_kwargs)
except Exception as _bedrock_exc:
# IAM policies scoped to bedrock:InvokeModel only (no
# InvokeModelWithResponseStream) reject converse_stream()
# with AccessDeniedException. That denial is permanent for
# the session — fall back to the non-streaming converse()
# inline (it maps to bedrock:InvokeModel) and disable
# streaming for subsequent calls so we don't re-fail every
# turn.
if is_streaming_access_denied_error(_bedrock_exc):
agent._disable_streaming = True
agent._safe_print(
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
"falling back to non-streaming InvokeModel.\n"
" Grant that action to restore streaming output.\n"
)
logger.info(
"bedrock: converse_stream denied by IAM (%s) — "
"using non-streaming converse() for this session.",
type(_bedrock_exc).__name__,
)
result["response"] = normalize_converse_response(
client.converse(**api_kwargs)
)
return
# Evict the cached client on stale-connection failures
# so the outer retry loop builds a fresh client/pool.
if is_stale_connection_error(_bedrock_exc):
@@ -1698,6 +1735,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# poll loop uses this to detect stale connections that keep receiving
# SSE keep-alive pings but no actual data.
last_chunk_time = {"t": time.time()}
# Stale-stream patience, shared between the httpx socket read timeout
# (built in ``_call_chat_completions`` below) and the stale-stream detector
# (computed further down, before the worker thread starts). Initialized
# here so the read-timeout builder can floor itself at the stale value and
# never fire before the detector. ``None`` until the detector value is
# resolved, so the builder degrades to its plain default if it ever runs
# first.
_stream_stale_timeout = None
def _fire_first_delta():
if not first_delta_fired["done"] and on_first_delta:
@@ -1734,6 +1779,26 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"Local provider detected (%s) — stream read timeout raised to %.0fs",
agent.base_url, _stream_read_timeout,
)
elif (
_stream_read_timeout == 120.0
and _stream_stale_timeout is not None
and _stream_stale_timeout != float("inf")
and _stream_stale_timeout > _stream_read_timeout
):
# Cloud reasoning models (e.g. Opus) routinely pause mid-stream
# for minutes during extended thinking. The stale-stream
# detector is deliberately scaled up to tolerate this (180300s,
# see the stale-timeout block below), but the raw httpx socket
# read timeout defaulted to a flat 120s and fired *first* —
# tearing down a healthy reasoning stream before the stale
# detector (which owns retry + diagnostics) could act. Keep the
# socket read timeout in step with the detector so it no longer
# preempts it.
_stream_read_timeout = _stream_stale_timeout
logger.debug(
"Cloud reasoning stream — read timeout raised to %.0fs to "
"match stale-stream detector", _stream_read_timeout,
)
# Cap connect/pool at 60s even when provider timeout is higher.
# connect/pool cover TCP handshake, not model inference.
_conn_cap = min(_base_timeout, 60.0) if _provider_timeout_cfg is not None else 30.0
@@ -2384,9 +2449,34 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"stream" in _err_lower
and "not supported" in _err_lower
)
if _is_stream_unsupported:
# AWS Bedrock (AnthropicBedrock SDK path): IAM policies
# with bedrock:InvokeModel but not
# InvokeModelWithResponseStream reject messages.stream()
# with a permission error naming the streaming action.
# Permanent for the session — flip to non-streaming
# (messages.create() maps to bedrock:InvokeModel).
_is_bedrock_stream_denied = False
if (
not _is_stream_unsupported
and "invokemodelwithresponsestream" in _err_lower
):
# Cheap message pre-check before importing the
# adapter — bedrock_adapter triggers a lazy boto3
# install at import time, which must not run for
# unrelated providers' stream errors.
from agent.bedrock_adapter import (
is_streaming_access_denied_error,
)
_is_bedrock_stream_denied = (
is_streaming_access_denied_error(e)
)
if _is_stream_unsupported or _is_bedrock_stream_denied:
agent._disable_streaming = True
agent._safe_print(
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream. "
"Switching to non-streaming.\n"
" Grant that action to restore streaming output.\n"
if _is_bedrock_stream_denied else
"\n⚠ Streaming is not supported for this "
"model/provider. Switching to non-streaming.\n"
" To avoid this delay, set display.streaming: false "

View File

@@ -127,14 +127,21 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis
return converted
def _summarize_user_message_for_log(content: Any) -> str:
"""Return a short text summary of a user message for logging/trajectory.
def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
"""Flatten message content to a plain-text summary.
Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}``
parts from the API server. Logging, spinner previews, and trajectory
files all want a plain string — this helper extracts the first chunk of
text and notes any attached images. Returns an empty string for empty
lists and ``str(content)`` for unexpected scalar types.
parts from the API server. Several consumers want a plain string:
- Logging, spinner previews, and trajectory files (the default ``sep=" "``).
- External memory providers, which feed the text to regexes
(``sanitize_context``) and text APIs — a raw list crashes the sync with
``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``).
Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker
so the turn isn't recorded as if the attachment never existed. Returns an
empty string for empty lists and ``str(content)`` for unexpected scalar
types.
"""
if content is None:
return ""
@@ -157,7 +164,7 @@ def _summarize_user_message_for_log(content: Any) -> str:
text_bits.append(text)
elif ptype in {"image_url", "input_image"}:
image_count += 1
summary = " ".join(text_bits).strip()
summary = sep.join(text_bits).strip()
if image_count:
note = f"[{image_count} image{'s' if image_count != 1 else ''}]"
summary = f"{note} {summary}" if summary else note

View File

@@ -25,6 +25,154 @@ from typing import Any, Dict, List
logger = logging.getLogger(__name__)
def _coerce_usage_int(value: Any) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(value, 0)
if isinstance(value, float):
return max(int(value), 0)
if isinstance(value, str):
try:
return max(int(value), 0)
except ValueError:
return 0
return 0
def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
"""Translate Codex app-server token usage into Hermes accounting.
Codex app-server reports usage via thread/tokenUsage/updated as:
inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens,
totalTokens.
Hermes' canonical prompt bucket includes uncached input + cached input.
The Codex app-server protocol does not currently expose cache-write tokens,
so that bucket remains zero on this runtime.
Even when Codex omits usage for a turn, Hermes should still count that turn
as one API call for session/status accounting.
"""
agent.session_api_calls += 1
usage = getattr(turn, "token_usage_last", None)
if not isinstance(usage, dict) or not usage:
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
agent.session_id,
model=agent.model,
api_call_count=1,
)
except Exception as exc:
logger.debug(
"Codex app-server api-call persistence failed (session=%s): %s",
agent.session_id, exc,
)
return {}
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost
input_tokens = _coerce_usage_int(usage.get("inputTokens"))
cache_read_tokens = _coerce_usage_int(usage.get("cachedInputTokens"))
output_tokens = _coerce_usage_int(usage.get("outputTokens"))
reasoning_tokens = _coerce_usage_int(usage.get("reasoningOutputTokens"))
reported_total = _coerce_usage_int(usage.get("totalTokens"))
canonical_usage = CanonicalUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=0,
reasoning_tokens=reasoning_tokens,
raw_usage=usage,
)
prompt_tokens = canonical_usage.prompt_tokens
completion_tokens = canonical_usage.output_tokens
total_tokens = reported_total or canonical_usage.total_tokens
usage_dict = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
"input_tokens": canonical_usage.input_tokens,
"output_tokens": canonical_usage.output_tokens,
"cache_read_tokens": canonical_usage.cache_read_tokens,
"cache_write_tokens": canonical_usage.cache_write_tokens,
"reasoning_tokens": canonical_usage.reasoning_tokens,
}
compressor = getattr(agent, "context_compressor", None)
if compressor is not None:
try:
compressor.update_from_response(usage_dict)
context_window = getattr(turn, "model_context_window", None)
if isinstance(context_window, int) and context_window > 0:
compressor.context_length = context_window
except Exception:
logger.debug("codex app-server usage update failed", exc_info=True)
agent.session_prompt_tokens += prompt_tokens
agent.session_completion_tokens += completion_tokens
agent.session_total_tokens += total_tokens
agent.session_input_tokens += canonical_usage.input_tokens
agent.session_output_tokens += canonical_usage.output_tokens
agent.session_cache_read_tokens += canonical_usage.cache_read_tokens
agent.session_cache_write_tokens += canonical_usage.cache_write_tokens
agent.session_reasoning_tokens += canonical_usage.reasoning_tokens
cost_result = estimate_usage_cost(
agent.model,
canonical_usage,
provider=agent.provider,
base_url=agent.base_url,
api_key=getattr(agent, "api_key", ""),
)
if cost_result.amount_usd is not None:
agent.session_estimated_cost_usd += float(cost_result.amount_usd)
agent.session_cost_status = cost_result.status
agent.session_cost_source = cost_result.source
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
output_tokens=canonical_usage.output_tokens,
cache_read_tokens=canonical_usage.cache_read_tokens,
cache_write_tokens=canonical_usage.cache_write_tokens,
reasoning_tokens=canonical_usage.reasoning_tokens,
estimated_cost_usd=float(cost_result.amount_usd)
if cost_result.amount_usd is not None else None,
cost_status=cost_result.status,
cost_source=cost_result.source,
billing_provider=agent.provider,
billing_base_url=agent.base_url,
billing_mode="subscription_included"
if cost_result.status == "included" else None,
model=agent.model,
api_call_count=1,
)
except Exception as exc:
logger.debug(
"Codex app-server token persistence failed (session=%s, tokens=%d): %s",
agent.session_id, total_tokens, exc,
)
return {
**usage_dict,
"last_prompt_tokens": prompt_tokens,
"estimated_cost_usd": float(cost_result.amount_usd)
if cost_result.amount_usd is not None else None,
"cost_status": cost_result.status,
"cost_source": cost_result.source,
}
def run_codex_app_server_turn(
agent,
*,
@@ -120,6 +268,8 @@ def run_codex_app_server_turn(
agent._iters_since_skill = (
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
)
usage_result = _record_codex_app_server_usage(agent, turn)
api_calls = 1
# Now check the skill nudge AFTER iters were incremented — same
# pattern the chat_completions path uses (line ~15432).
@@ -164,12 +314,13 @@ def run_codex_app_server_turn(
return {
"final_response": turn.final_text,
"messages": messages,
"api_calls": 1, # one app-server "turn" maps to one logical API call
"api_calls": api_calls,
"completed": not turn.interrupted and turn.error is None,
"partial": turn.interrupted or turn.error is not None,
"error": turn.error,
"codex_thread_id": turn.thread_id,
"codex_turn_id": turn.turn_id,
**usage_result,
}

738
agent/coding_context.py Normal file
View File

@@ -0,0 +1,738 @@
"""Coding-context awareness — base Hermes, every interactive surface.
When the user runs Hermes inside a code workspace (CLI, TUI, desktop app, or an
editor over ACP), Hermes shifts into a **coding posture**. This module is the
single place that decides whether we're in that posture and what it implies,
so the rest of the codebase never re-derives "are we coding?" on its own.
Architecture — one seam, many consumers
----------------------------------------
The posture is modelled as a frozen :class:`RuntimeMode` selected from a small
:class:`ContextProfile` registry (today: ``coding`` and ``general``). A profile
is *data* — it declares the toolset to collapse to, the operating brief to
inject, and hints for other domains (model routing, memory, subagents). Every
domain reads the same resolved object instead of probing git/config itself:
* **System prompt** — ``RuntimeMode.system_blocks()`` → the operating brief +
a live git/workspace snapshot (``agent/system_prompt.py``).
* **Toolset** — ``RuntimeMode.toolset_selection()`` → the ``coding`` toolset
plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Only
under the opt-in ``focus`` mode: the default posture is prompt-only and
never touches the user's configured toolsets (toolsets like messaging /
smart-home / music are off-by-default anyway, and someone who explicitly
enabled image-gen or Spotify shouldn't lose it for being in a git repo).
* **Delegation** — subagents inherit the parent's toolset and run through the
same prompt builder, so the coding posture propagates to children for free.
* **Model / memory / compression** — declared on the profile
(``model_hint``, ``memory_policy``) as the extension seam; consumers read
``mode.profile`` rather than re-deciding.
Cache safety
------------
The mode is resolved **once** and is immutable. The workspace snapshot is built
once at prompt-build time and baked into the *stable* system-prompt tier — never
re-probed per turn (that would shatter the prompt cache). Branch and dirty state
drift mid-session, so the brief tells the model to re-check with ``git`` before
acting on the snapshot. A ``/coding`` flip therefore only takes effect next
session (deferred), the same contract as ``/skills install`` vs ``--now``.
Activation (config ``agent.coding_context``):
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
surface sitting in a code workspace (git repo or recognised project root).
Prompt-only; toolsets and the skill index untouched.
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
``coding`` set + enabled MCP servers and demotes non-coding skill
categories to names-only in the prompt's skill index (no skill is ever
hidden). Explicit opt-in for a lean schema.
* ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
* ``off`` — disable entirely.
"""
from __future__ import annotations
import json
import logging
import os
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger("hermes.coding_context")
CODING_TOOLSET = "coding"
# Surfaces where a coding posture makes sense under ``auto``. Messaging
# platforms (telegram, discord, slack, …) are intentionally absent — a chat bot
# in a group is not pair-programming.
INTERACTIVE_CODING_PLATFORMS = {"cli", "tui", "acp", "desktop", ""}
# Project-root signals that mark a directory as a code workspace even when it
# isn't (yet) a git repo. Cheap filename checks — no parsing.
_PROJECT_MARKERS = (
"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt",
"package.json", "tsconfig.json", "deno.json",
"Cargo.toml", "go.mod", "pom.xml", "build.gradle", "build.gradle.kts",
"Gemfile", "composer.json", "mix.exs", "pubspec.yaml",
"CMakeLists.txt", "Makefile", "Dockerfile",
"AGENTS.md", "CLAUDE.md", ".cursorrules",
)
# Agent-instruction files surfaced separately from manifests in the snapshot.
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
# Lockfile → package manager, checked in priority order.
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
_JS_LOCKFILES = (
("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"),
("yarn.lock", "yarn"), ("package-lock.json", "npm"),
)
# package.json scripts / Makefile targets worth surfacing as verify commands.
_VERIFY_TARGETS = ("test", "tests", "lint", "typecheck", "check", "build", "fmt", "format")
_MAX_VERIFY_COMMANDS = 8
_MAX_FACT_FILE_BYTES = 256 * 1024
_GIT_TIMEOUT = 2.5
# Per-model edit-format steering. Matching the edit tool format to how a model
# was trained reduces mistakes and wasted reasoning (OpenAI/Codex handle
# patch-style diffs best; Anthropic models — and most open-weight coding
# models, whose RL scaffolds use str_replace-style editors — do best with
# string-replacement). Our `patch` tool exposes both: mode="patch" (V4A
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
# its native format. Unknown families get nothing (the brief's neutral wording
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
#
# GPT/Codex get V4A for ALL edits, single-file included: in codex-rs,
# apply_patch (V4A — apply_patch.lark) is the ONLY file editor, no
# str_replace-style tool exists, and the shipped model prompts say to use
# apply_patch even "for single file edits" — so a replace-mode nudge would
# steer those models toward a format their first-party harness never taught
# them.
_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = {
"patch": (
("gpt", "codex"),
"- Edit format: author new files with `write_file`; for edits to "
"existing code use `patch` with `mode='patch'` (V4A diff) — including "
"single-file edits. It's the edit format you handle most reliably.",
),
"replace": (
("claude", "sonnet", "opus", "haiku",
"gemini", "gemma", "deepseek", "qwen", "kimi", "glm", "grok",
"hermes", "llama", "mistral", "devstral", "minimax"),
"- Edit format: author new files with `write_file`; for edits to "
"existing code prefer `patch` in `mode='replace'` — match a unique "
"snippet and swap it. Reach for `mode='patch'` (V4A) only when an edit "
"genuinely spans several files at once.",
),
}
def _model_family(model: Optional[str]) -> Optional[str]:
"""Classify a model id into an edit-format family key, or ``None``.
Used to steer the coding posture toward the edit tool format a model was
trained on. Family-agnostic by design: an unrecognised model gets ``None``
and the operating brief's neutral edit wording applies.
"""
if not model:
return None
lowered = model.lower()
for family, (needles, _line) in _EDIT_FORMAT_GUIDANCE.items():
if any(n in lowered for n in needles):
return family
return None
def _edit_format_line(model: Optional[str]) -> str:
"""The edit-format guidance line for this model's family (``""`` if none)."""
family = _model_family(model)
if family is None:
return ""
return _EDIT_FORMAT_GUIDANCE[family][1]
# Operating brief for the coding posture. Tool names referenced here (read_file,
# search_files, patch, write_file, terminal, todo) are in the coding toolset and
# in _HERMES_CORE_TOOLS, so they're present on every surface this fires on.
CODING_AGENT_GUIDANCE = (
"You are a coding agent pairing with the user inside their codebase. "
"Operate like a careful senior engineer.\n"
"\n"
"Gather context first:\n"
"- Read the relevant files with `read_file` and locate code with "
"`search_files` before changing anything. Trace a symbol to its definition "
"and usages rather than guessing its shape.\n"
"- Batch independent lookups: when several reads/searches don't depend on "
"each other, issue them together in one turn instead of one at a time.\n"
"- Never invent files, symbols, APIs, or imports. If you haven't seen it in "
"the repo, go look. Don't assume a library is available — check the project "
"manifest (pyproject.toml / package.json / Cargo.toml / go.mod) and how "
"neighbouring files import it.\n"
"\n"
"Make changes through the tools, not the chat:\n"
"- Edit with `patch`/`write_file`. Do NOT print code blocks to the user as "
"a substitute for editing — apply the change, then summarise it. Only show "
"code when the user explicitly asks to see it.\n"
"- Match the project's existing style and conventions; AGENTS.md / "
"CLAUDE.md / .cursorrules already in context win over your defaults. Touch "
"only what the task needs — no drive-by refactors, renames, or reformatting "
"— and add any imports/dependencies your code requires.\n"
"- If an edit fails to apply, re-read the file to get the current exact "
"contents before retrying — don't repeat a stale patch. If the same region "
"fails twice, rewrite the enclosing function or file with `write_file` "
"instead of attempting a third patch.\n"
"\n"
"Verify, and know when to stop:\n"
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
"tests/linter/build and confirm they pass before claiming the work is done.\n"
"- Terminal state persists across calls: current directory and exported "
"environment variables carry forward. Activate a virtualenv or export setup "
"vars once, then reuse that state instead of re-sourcing it before every "
"test command.\n"
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
"paths for the same flaw and fix the class, not just the reported site.\n"
"- When fixing linter/type errors on a file, stop after about three "
"attempts on the same file and ask the user rather than looping.\n"
"- Track multi-step work with `todo`. Reference code as `path:line` instead "
"of pasting whole files.\n"
"\n"
"Respect the user's repo: don't commit, push, or rewrite history unless "
"asked, and never read, print, or commit secrets — leave `.env` and "
"credential files alone unless the user explicitly asks. The Workspace "
"block below is a snapshot from session start — re-run `git status`/"
"`git branch` before relying on it. Be concise: lead with the change or "
"answer, not a preamble."
)
# ── Context profiles (declarative posture definitions) ──────────────────────
@dataclass(frozen=True)
class ContextProfile:
"""A named operating posture. Pure data — consumers read these fields.
``toolset`` — collapse to this toolset (+ enabled MCP) when no explicit
selection is pinned; ``None`` keeps the platform default.
``guidance`` — operating brief injected into the stable system prompt;
``""`` injects nothing.
``model_hint`` — routing preference key for smart model routing
(extension seam; not yet consumed by the router).
``memory_policy``— memory namespace/weighting hint (extension seam).
``compact_skill_categories`` — skill categories DEMOTED to names-only in
the system-prompt skill index under the opt-in ``focus``
mode. Never hidden: every skill name stays visible
(so memory-anchored recall keeps working) — only the
descriptions are dropped to cut index noise. Deny-list
semantics so unknown/custom categories keep full
entries.
"""
name: str
toolset: Optional[str] = None
guidance: str = ""
model_hint: Optional[str] = None
memory_policy: str = "default"
compact_skill_categories: tuple[str, ...] = ()
# Skill categories that are clearly not part of a coding workflow. Demoted to
# names-only in the prompt's skill index under the opt-in ``focus`` mode only
# (deny-list — anything not listed here, incl. custom user categories, keeps
# full entries). Coding-adjacent categories (devops, github, mcp,
# data-science, diagramming, research, security, …) are intentionally absent.
_NON_CODING_SKILL_CATEGORIES = (
"apple", "communication", "cooking", "creative", "email", "finance",
"gaming", "gifs", "health", "media", "music", "note-taking",
"productivity", "shopping", "smart-home", "social-media", "travel",
"yuanbao",
)
GENERAL_PROFILE = ContextProfile(name="general")
CODING_PROFILE = ContextProfile(
name="coding",
toolset=CODING_TOOLSET,
guidance=CODING_AGENT_GUIDANCE,
model_hint="coding",
memory_policy="project",
compact_skill_categories=_NON_CODING_SKILL_CATEGORIES,
)
_PROFILES: dict[str, ContextProfile] = {
GENERAL_PROFILE.name: GENERAL_PROFILE,
CODING_PROFILE.name: CODING_PROFILE,
}
def get_profile(name: str) -> ContextProfile:
"""Return a registered profile, falling back to ``general``."""
return _PROFILES.get(name, GENERAL_PROFILE)
# ── Helpers ─────────────────────────────────────────────────────────────────
def _coding_mode(config: Optional[dict[str, Any]]) -> str:
"""Return the normalized ``agent.coding_context`` mode (auto/focus/on/off)."""
if config is None:
try:
from hermes_cli.config import load_config
config = load_config()
except Exception:
config = {}
raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto")
mode = str(raw).strip().lower()
if mode in {"focus", "strict", "lean"}:
return "focus"
if mode in {"on", "true", "yes", "1", "always"}:
return "on"
if mode in {"off", "false", "no", "0", "never"}:
return "off"
return "auto"
def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
if cwd:
return Path(cwd).expanduser()
try:
from agent.runtime_cwd import resolve_agent_cwd
return resolve_agent_cwd()
except Exception:
return Path(os.getcwd())
def _git_root(cwd: Path) -> Optional[Path]:
current = cwd.resolve()
for parent in [current, *current.parents]:
if (parent / ".git").exists():
return parent
return None
def _home() -> Optional[Path]:
try:
return Path.home().resolve()
except (OSError, RuntimeError):
return None
def _marker_root(cwd: Path) -> Optional[Path]:
"""Nearest ancestor that looks like a project root, or ``None``.
Walks up at most a few levels so a manifest in the workspace root counts
even when the user is in a subdirectory. ``$HOME`` itself is skipped — a
Makefile or AGENTS.md sitting in the home directory is global user config,
not a project-root signal.
"""
current = cwd.resolve()
home = _home()
for depth, parent in enumerate([current, *current.parents]):
if depth > 6:
break
if parent == home:
continue
for marker in _PROJECT_MARKERS:
if (parent / marker).exists():
return parent
return None
def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
"""Resolve which profile applies.
``auto``/``focus``: coding when the surface is interactive AND the cwd is a
code workspace (a git repo or a recognised project root). ``on``: always
coding. ``off``: always general.
A git repo rooted at ``$HOME`` (the dotfiles pattern) is NOT a workspace
signal — without the guard, every session anywhere under a dotfiles-managed
home directory would silently flip to the coding posture.
Detection is intentionally not memoized: it's a handful of ``stat`` calls,
and callers resolve the mode once per session anyway. Caching here would
risk a stale posture if a long-lived process (gateway/TUI) serves sessions
from different working directories.
"""
if mode == "off":
return GENERAL_PROFILE.name
if mode == "on":
return CODING_PROFILE.name
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
return GENERAL_PROFILE.name
cwd = Path(cwd_str)
git_root = _git_root(cwd)
if git_root is not None and git_root == _home():
git_root = None # dotfiles repo at $HOME — not a code workspace
if git_root is not None or _marker_root(cwd) is not None:
return CODING_PROFILE.name
return GENERAL_PROFILE.name
# ── RuntimeMode (the seam) ──────────────────────────────────────────────────
@dataclass(frozen=True)
class RuntimeMode:
"""The resolved operating posture for a session. Immutable by construction.
Built once via :func:`resolve_runtime_mode` and consumed by every domain
that cares about the coding/general distinction. Never mutate or re-resolve
mid-session — that would break the prompt cache.
"""
profile: ContextProfile
surface: str
cwd: Path
# The normalized ``agent.coding_context`` mode this posture was resolved
# under (auto/focus/on/off). Toolset collapse is gated on ``focus``.
config_mode: str = "auto"
# The model id this session runs (e.g. "anthropic/claude-opus-4.8"). Used
# only to steer edit-format guidance toward the model's family — see
# ``_edit_format_line``. Fixed for the session, so cache-safe.
model: Optional[str] = None
@property
def kind(self) -> str:
return self.profile.name
@property
def is_coding(self) -> bool:
return self.profile.name == CODING_PROFILE.name
def toolset_selection(self, config: Optional[dict[str, Any]] = None) -> Optional[list[str]]:
"""Toolset list for this posture, or ``None`` to keep the platform default.
Non-``None`` only under the opt-in ``focus`` mode. The default posture
is prompt-only: most strippable toolsets are off-by-default anyway, and
a user who explicitly enabled one (image-gen for frontend/game assets,
messaging for build notifications, …) keeps it while coding.
Callers apply this only when the user hasn't pinned an explicit
selection (``--toolsets``, ``HERMES_TUI_TOOLSETS``, …); they never
override a pin. Returns the profile's toolset plus enabled MCP servers.
"""
if self.config_mode != "focus":
return None
if self.profile.toolset is None:
return None
return [self.profile.toolset, *_enabled_mcp_servers(config)]
def system_blocks(self) -> list[str]:
"""Stable system-prompt blocks for this posture (brief + workspace).
The operating brief carries a model-family edit-format nudge appended
to it (one cached string, not a separate block) so the model is steered
toward the `patch` mode it handles best — see ``_edit_format_line``.
"""
if not self.is_coding:
return []
blocks: list[str] = []
if self.profile.guidance:
brief = self.profile.guidance
edit_line = _edit_format_line(self.model)
if edit_line:
brief = f"{brief}\n{edit_line}"
blocks.append(brief)
workspace = build_coding_workspace_block(self.cwd)
if workspace:
blocks.append(workspace)
return blocks
def compact_skill_categories(self) -> frozenset[str]:
"""Skill categories to demote to names-only in the prompt's skill index.
Gated on the opt-in ``focus`` mode, like the toolset collapse: the
default posture leaves the skill index untouched. Users who didn't ask
for a lean prompt keep full entries for every category — index changes
under ``auto`` proved too surprising in practice, even names-only ones
(a demoted description is information the model no longer weighs when
deciding what to load).
Demoted — never hidden — even under ``focus``. An earlier revision
fully pruned these categories from the index, which caused silent
capability loss in a real workflow: agent-created skills are the
model's accumulated project memory (server-ops runbooks, learned
pitfalls, …), and models do not reliably reach for ``skills_list`` to
rediscover what the index stopped showing them. Names-only keeps every
skill loadable on recall while still cutting the description noise.
"""
if not self.is_coding or self.config_mode != "focus":
return frozenset()
return frozenset(self.profile.compact_skill_categories)
def resolve_runtime_mode(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
model: Optional[str] = None,
) -> RuntimeMode:
"""Resolve the operating posture once. Cheap — a handful of ``stat`` calls.
This is the single entry point every domain should call. The returned
object is immutable and safe to cache for the session. Detection itself is
intentionally *not* memoized (see ``_detect_profile_name``) so a long-lived
process can't pin a stale posture; callers resolve once per session and
hold the result. ``model`` is recorded only to steer edit-format guidance;
it never affects detection.
"""
resolved_cwd = _resolve_cwd(cwd)
mode = _coding_mode(config)
name = _detect_profile_name(
mode, (platform or "").strip().lower(), str(resolved_cwd)
)
return RuntimeMode(
profile=get_profile(name),
surface=platform or "",
cwd=resolved_cwd,
config_mode=mode,
model=model,
)
# ── Back-compat surface (thin wrappers over RuntimeMode) ────────────────────
def is_coding_context(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
) -> bool:
"""Whether Hermes should operate in its coding posture right now."""
return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).is_coding
def coding_selection(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
) -> Optional[list[str]]:
"""Toolset selection for the coding posture.
``None`` unless the user opted into ``focus`` mode AND the posture is
active — the default coding posture never overrides configured toolsets.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config
).toolset_selection(config)
def coding_system_blocks(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
model: Optional[str] = None,
) -> list[str]:
"""Stable system-prompt blocks for the current posture (empty when general).
``model`` steers the brief's edit-format nudge toward the model's family.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config, model=model
).system_blocks()
def coding_compact_skill_categories(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
) -> frozenset[str]:
"""Skill categories the active posture demotes to names-only in the index.
Empty outside the coding posture and outside the opt-in ``focus`` mode —
the default posture never touches the skill index. Under ``focus``,
demoted — never hidden: every skill name stays in the index and remains
loadable via ``skill_view`` / ``skills_list``; only descriptions are
dropped.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config
).compact_skill_categories()
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
"""Names of MCP servers the user has enabled — kept in the coding posture.
MCP servers (figma, browser, tophat, …) are explicitly configured and part
of the coding workflow, not noise to strip.
"""
try:
from hermes_cli.config import read_raw_config
from hermes_cli.tools_config import _parse_enabled_flag
servers = read_raw_config().get("mcp_servers") or {}
return [
str(name)
for name, cfg in servers.items()
if isinstance(cfg, dict)
and _parse_enabled_flag(cfg.get("enabled", True), default=True)
]
except Exception:
return []
# ── git/workspace probe ─────────────────────────────────────────────────────
def _git(cwd: Path, *args: str) -> str:
try:
out = subprocess.run(
["git", "-C", str(cwd), *args],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT,
)
except (OSError, subprocess.SubprocessError):
return ""
return out.stdout.strip() if out.returncode == 0 else ""
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
"""Parse ``git status --porcelain=2 --branch`` into branch + counts."""
branch: dict[str, str] = {}
counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicts": 0}
for line in porcelain.splitlines():
if line.startswith("# branch.head"):
branch["head"] = line.split(maxsplit=2)[-1]
elif line.startswith("# branch.upstream"):
branch["upstream"] = line.split(maxsplit=2)[-1]
elif line.startswith("# branch.ab"):
parts = line.split()
branch["ahead"], branch["behind"] = parts[2].lstrip("+"), parts[3].lstrip("-")
elif line.startswith(("1 ", "2 ")):
xy = line.split(maxsplit=2)[1]
if xy[0] != ".":
counts["staged"] += 1
if xy[1] != ".":
counts["modified"] += 1
elif line.startswith("u "):
counts["conflicts"] += 1
elif line.startswith("? "):
counts["untracked"] += 1
return branch, counts
def _read_small(path: Path) -> str:
"""Read a small text file, or ``""`` — never raises, never reads huge files."""
try:
if not path.is_file() or path.stat().st_size > _MAX_FACT_FILE_BYTES:
return ""
return path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
def _project_facts(root: Path) -> list[str]:
"""Detected project facts for the workspace snapshot.
The point is to hand the model its *verify loop* up front — which manifest,
which package manager, and the exact test/lint/build commands — instead of
making it rediscover them every session. Cheap: stat calls plus reads of a
couple of small files; built once at prompt-build time (cache-safe).
"""
facts: list[str] = []
manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
package_managers = [
pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
]
if manifests:
line = f"- Project: {', '.join(manifests[:6])}"
if package_managers:
line += f" ({'/'.join(dict.fromkeys(package_managers))})"
facts.append(line)
verify: list[str] = []
if (root / "scripts" / "run_tests.sh").is_file():
verify.append("scripts/run_tests.sh")
if (root / "package.json").is_file():
try:
scripts = json.loads(_read_small(root / "package.json") or "{}").get("scripts") or {}
except (json.JSONDecodeError, AttributeError):
scripts = {}
js_pm = next((pm for lock, pm in _JS_LOCKFILES if (root / lock).is_file()), "npm")
verify.extend(f"{js_pm} run {name}" for name in _VERIFY_TARGETS if name in scripts)
if (root / "pytest.ini").is_file() or "[tool.pytest" in _read_small(root / "pyproject.toml"):
verify.append("pytest")
makefile = _read_small(root / "Makefile")
if makefile:
verify.extend(
f"make {name}" for name in _VERIFY_TARGETS
if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
)
if verify:
deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
facts.append(f"- Verify: {'; '.join(deduped)}")
context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
if context_files:
facts.append(f"- Context files: {', '.join(context_files)}")
return facts
def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
"""Workspace snapshot for the system prompt (empty outside a workspace).
Git state (branch/status/commits) when the cwd is in a repo, plus detected
project facts (manifest, package manager, verify commands, context files)
— so marker-only (non-git) projects still get a snapshot.
"""
resolved = _resolve_cwd(cwd)
git_root = _git_root(resolved)
root = git_root or _marker_root(resolved)
if root is None:
return ""
lines = ["Workspace (snapshot at session start — re-check with `git` before acting on it):"]
lines.append(f"- Root: {root}")
if git_root is not None:
branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch"))
head = branch.get("head", "")
if head and head != "(detached)":
line = f"- Branch: {head}"
if branch.get("upstream"):
line += f" \u2192 {branch['upstream']}"
ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0")
if ahead != "0" or behind != "0":
line += f" (ahead {ahead}, behind {behind})"
lines.append(line)
elif head == "(detached)":
lines.append("- Branch: (detached HEAD)")
# Linked worktree: the per-worktree git dir differs from the shared common dir.
# We surface the fact that it's a worktree (so the model knows branches/stashes
# are shared state) but deliberately do NOT expose the primary tree path —
# giving the model a second absolute path causes it to sometimes run commands
# in the wrong directory.
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
lines.append("- Worktree: linked (git state shared with primary tree)")
dirty = [f"{n} {label}" for label, n in (
("staged", counts["staged"]), ("modified", counts["modified"]),
("untracked", counts["untracked"]), ("conflicts", counts["conflicts"]),
) if n]
lines.append(f"- Status: {', '.join(dirty) if dirty else 'clean'}")
recent = _git(root, "log", "-3", "--pretty=%h %s")
if recent:
lines.append("- Recent commits:")
lines.extend(f" {c}" for c in recent.splitlines())
lines.extend(_project_facts(root))
return "\n".join(lines)

View File

@@ -7,7 +7,7 @@ protecting head and tail context.
Improvements over v2:
- Structured summary template with Resolved/Pending question tracking
- Filter-safe summarizer preamble that treats prior turns as source material
- "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
- Historical (reference-only) section headings replace "Next Steps"/"Remaining Work" to avoid reading as active instructions
- Clear separator when summary merges into tail message
- Iterative summary updates (preserves info across multiple compactions)
- Token-budget tail protection instead of fixed message count
@@ -34,7 +34,50 @@ from agent.redact import redact_sensitive_text
logger = logging.getLogger(__name__)
HISTORICAL_TASK_HEADING = "## Historical Task Snapshot"
HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State"
HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks"
HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work"
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
"Topic overlap with the summary does NOT mean you should resume its "
"task: even on similar topics, the latest user message WINS. Treat ONLY "
"the latest message as the active task and discard stale items from "
f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / "
f"'{HISTORICAL_PENDING_ASKS_HEADING}' / "
f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or "
"'finish' work described there unless the latest message explicitly "
"asks for it. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
"topic) must immediately end any in-flight work described in the "
"summary; do not re-surface it in later turns. "
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
# Carveout era (#41607/#38364/#42812): "consistent → use as background"
# licensed stale-task resumption on topic overlap.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
@@ -57,17 +100,7 @@ SUMMARY_PREFIX = (
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
"described here — avoid repeating it:",
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
@@ -110,10 +143,18 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
# become another unbounded transcript copy after the LLM summarizer failed.
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
_FALLBACK_TURN_MAX_CHARS = 700
_AUTO_FOCUS_MAX_TURNS = 3
_AUTO_FOCUS_TURN_MAX_CHARS = 260
_AUTO_FOCUS_MAX_CHARS = 700
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
# MEDIA delivery directives must not reach the summarizer — if one leaks into
# the summary, the downstream model may re-emit it as an active directive on
# the next turn, triggering bogus attachment sends (#14665).
_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+")
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
value = value.strip()
@@ -974,6 +1015,7 @@ class ContextCompressor(ContextEngine):
for msg in turns:
role = msg.get("role", "unknown")
content = redact_sensitive_text(msg.get("content") or "")
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
# Tool results: keep enough content for the summarizer
if role == "tool":
@@ -1155,7 +1197,7 @@ class ContextCompressor(ContextEngine):
)
reason_text = f" Summary failure reason: {reason}." if reason else ""
body = f"""## Active Task
body = f"""{HISTORICAL_TASK_HEADING}
{active_task}
## Goal
@@ -1172,7 +1214,7 @@ Recovered from a deterministic fallback because the LLM context summarizer was u
## Active State
Unknown from deterministic fallback. Inspect current repository/session state if needed.
## In Progress
{HISTORICAL_IN_PROGRESS_HEADING}
{active_task}
## Blocked
@@ -1184,13 +1226,13 @@ None recoverable from deterministic fallback.
## Resolved Questions
None recoverable from deterministic fallback.
## Pending User Asks
{HISTORICAL_PENDING_ASKS_HEADING}
{active_task}
## Relevant Files
{_bullets(relevant_files, limit=12)}
## Remaining Work
{HISTORICAL_REMAINING_WORK_HEADING}
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
## Last Dropped Turns
@@ -1312,7 +1354,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
_temporal_anchoring_rule = ""
# Shared structured template (used by both paths).
_template_sections = f"""## Active Task
_template_sections = f"""{HISTORICAL_TASK_HEADING}
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
input verbatim — the exact words they used. This includes:
- Explicit task assignments ("refactor the auth module")
@@ -1359,7 +1401,7 @@ Be specific with file paths, commands, line numbers, and results.]
- Any running processes or servers
- Environment details that matter]
## In Progress
{HISTORICAL_IN_PROGRESS_HEADING}
[Work currently underway — what was being done when compaction fired]
## Blocked
@@ -1371,14 +1413,14 @@ Be specific with file paths, commands, line numbers, and results.]
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
{HISTORICAL_PENDING_ASKS_HEADING}
[Questions or requests from the user that have NOT yet been answered or fulfilled. These are STALE — they were from the compacted turns. Write them here for reference only. The agent must NOT act on them unless the latest user message explicitly requests it. If none, write "None."]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Remaining Work
[What remains to be done — framed as context, not instructions]
{HISTORICAL_REMAINING_WORK_HEADING}
[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
@@ -1421,7 +1463,7 @@ Use this exact structure:
prompt += f"""
FOCUS TOPIC: "{focus_topic}"
The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
This compaction should PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
try:
call_kwargs = {
@@ -1590,6 +1632,39 @@ The user has requested that this compaction PRIORITISE preserving all informatio
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
@classmethod
def _derive_auto_focus_topic(
cls,
messages: List[Dict[str, Any]],
) -> Optional[str]:
"""Infer a compact focus hint from the most recent real user turns."""
candidates: list[str] = []
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if msg.get("role") != "user":
continue
content = msg.get("content")
if cls._is_context_summary_content(content):
continue
text = redact_sensitive_text(_content_text_for_contains(content).strip())
if not text:
continue
text = " ".join(text.split())
if len(text) > _AUTO_FOCUS_TURN_MAX_CHARS:
text = text[: _AUTO_FOCUS_TURN_MAX_CHARS - 1].rstrip() + ""
candidates.append(text)
if len(candidates) >= _AUTO_FOCUS_MAX_TURNS:
break
if not candidates:
return None
candidates.reverse()
focus = "Recent user focus:\n" + "\n".join(f"- {item}" for item in candidates)
if len(focus) > _AUTO_FOCUS_MAX_CHARS:
focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + ""
return focus
@classmethod
def _find_latest_context_summary(
cls,
@@ -1753,7 +1828,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
Context compressor bug (#10896): ``_align_boundary_backward`` can pull
``cut_idx`` past a user message when it tries to keep tool_call/result
groups together. If the last user message ends up in the *compressed*
middle region the LLM summariser writes it into "Pending User Asks",
middle region the LLM summariser writes it into "Historical Pending User Asks",
but ``SUMMARY_PREFIX`` tells the next model to respond only to user
messages *after* the summary — so the task effectively disappears from
the active context, causing the agent to stall, repeat completed work,
@@ -2037,7 +2112,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio
)
# Phase 3: Generate structured summary
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages)
summary = self._generate_summary(turns_to_summarize, focus_topic=summary_focus_topic)
# If summary generation failed, behavior splits on
# ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure):

View File

@@ -246,7 +246,14 @@ def _expand_file_reference(
if not path.is_file():
return f"{ref.raw}: path is not a file", None
if _is_binary_file(path):
return f"{ref.raw}: binary files are not supported", None
# A binary file can't be inlined as text, but it IS on disk (the agent's
# tools run where this resolves — the local cwd, or the staged copy in a
# remote session workspace). Returning a bare "not supported" warning
# with no content was a dead end: the model saw a failure and gave up
# (told the user the file type wasn't supported). Instead, hand it an
# actionable block — the path, type, size, and a nudge to use its tools —
# so it can read/convert/view the file itself.
return None, _binary_reference_block(ref, path)
text = path.read_text(encoding="utf-8")
if ref.line_start is not None:
@@ -290,6 +297,7 @@ def _expand_git_reference(
capture_output=True,
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"{ref.raw}: git command timed out (30s)", None
@@ -482,6 +490,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
@@ -491,6 +500,30 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
return files[:limit]
def _human_bytes(n: int) -> str:
size = float(n)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} GB"
def _binary_reference_block(ref: ContextReference, path: Path) -> str:
mime, _ = mimetypes.guess_type(path.name)
mime = mime or "application/octet-stream"
try:
size = _human_bytes(path.stat().st_size)
except OSError:
size = "unknown size"
return (
f"📎 {ref.raw} ({mime}, {size}) — binary file, not inlined as text. "
f"It is available on disk at `{path}`. Use your tools to work with it "
f"(read or convert it, extract its text, or view/render it as needed); "
f"do not tell the user the file type is unsupported."
)
def _file_metadata(path: Path) -> str:
if _is_binary_file(path):
return f"{path.stat().st_size} bytes"

View File

@@ -2221,30 +2221,54 @@ def run_conversation(
print(f"{agent.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"")
print(f"{agent.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"")
# ── Thinking block signature recovery ─────────────────
# Thinking block signature recovery.
#
# Anthropic signs thinking blocks against the full turn
# content. Any upstream mutation (context compression,
# content. Any upstream mutation (context compression,
# session truncation, message merging) invalidates the
# signature → HTTP 400. Recovery: strip reasoning_details
# from all messages so the next retry sends no thinking
# blocks at all. One-shot — don't retry infinitely.
# signature and the API replies HTTP 400 ("invalid
# signature" or "cannot be modified"). Recovery strips
# ``reasoning_details`` so the retry sends no thinking
# blocks at all. One-shot per outer loop.
#
# The strip targets ``api_messages``, which is the
# API-call-time list that ``_build_api_kwargs`` consumes
# on every retry. ``api_messages`` was populated once at
# the start of the turn from shallow copies of
# ``messages``, so mutating it does not touch the
# canonical store. The previous implementation popped
# ``reasoning_details`` from ``messages`` instead, which
# had two problems: ``api_messages`` carried its own
# reference to the field through the shallow copy, so the
# retry's wire payload still included thinking blocks and
# the recovery never reached the API; and the mutation
# persisted into ``state.db`` through any subsequent
# ``_persist_session`` call, permanently corrupting the
# conversation. Future turns would replay the stripped
# state, hit the same 400, and the agent would terminate
# with ``max_retries_exhausted``, often spawning
# cascading compaction-ended sessions chained off the
# corrupted parent.
if (
classified.reason == FailoverReason.thinking_signature
and not _retry.thinking_sig_retry_attempted
):
_retry.thinking_sig_retry_attempted = True
for _m in messages:
if isinstance(_m, dict):
_api_stripped = 0
for _m in api_messages:
if isinstance(_m, dict) and "reasoning_details" in _m:
_m.pop("reasoning_details", None)
_api_stripped += 1
agent._vprint(
f"{agent.log_prefix}⚠️ Thinking block signature invalid "
f"stripped all thinking blocks, retrying...",
f"{agent.log_prefix}⚠️ Thinking block signature invalid, "
f"stripped reasoning_details from api_messages for retry...",
force=True,
)
logger.warning(
"%sThinking block signature recovery: stripped "
"reasoning_details from %d messages",
agent.log_prefix, len(messages),
"reasoning_details from %d api_messages "
"(canonical messages unchanged)",
agent.log_prefix, _api_stripped,
)
continue

View File

@@ -194,17 +194,71 @@ class AgentNotice:
id: Optional[str] = None
# ── is_free_tier_model (local-data-only free-model check) ────────────────────
def is_free_tier_model(model: str, base_url: str = "") -> bool:
"""Return True when *model* is a Nous free-tier model, using ONLY local data.
Two signals, both zero-network:
1. The ``:free`` suffix — the canonical Nous free SKU marker (e.g.
``nvidia/nemotron-3-ultra:free``). Free by construction on the API side
(spend is forced to 0 for ``:free`` ids).
2. A peek into the in-process pricing cache in ``hermes_cli.models``
(populated when the model picker fetched ``/v1/models`` pricing for
*base_url*). PEEK ONLY — a cache miss never triggers a fetch. This is
CLI/TUI-session best-effort: gateway sessions never run the picker's
pricing fetch, so suppression there rests entirely on the ``:free``
suffix (which all Nous free SKUs carry).
Fail-open to False (the depleted notice still shows) on any error: wrongly
showing the warning is recoverable noise; wrongly hiding it on a paid model
would mask a real billing block.
"""
if not model:
return False
if model.endswith(":free"):
return True
if not base_url:
return False
try:
from hermes_cli.models import _is_model_free, _pricing_cache
# Mirror get_pricing_for_provider's key normalization: the agent's
# Nous base_url is /v1-suffixed (https://inference-api.nousresearch.com/v1)
# but the picker keys _pricing_cache on the pre-/v1 root.
key = base_url.rstrip("/")
if key.endswith("/v1"):
key = key[:-3].rstrip("/")
pricing = _pricing_cache.get(key)
if not pricing:
return False
return _is_model_free(model, pricing)
except Exception:
return False
# ── evaluate_credits_notices (pure reconciliation function) ──────────────────
def evaluate_credits_notices(
state: CreditsState,
latch: dict,
*,
model_is_free: bool = False,
) -> tuple[list[AgentNotice], list[str]]:
"""Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE.
latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}.
``model_is_free``: True when the session's active model is a Nous free-tier
model (see :func:`is_free_tier_model`). Suppresses the ``credits.depleted``
notice — a depleted account on a free model can keep inferencing, so the
error banner is noise (and confuses free-tier users who never had credits).
Suppression does NOT emit the "restored" success notice; that fires only on
a genuine ``paid_access`` flip back to True.
Returns ``(to_show: list[AgentNotice], to_clear: list[str])``.
Caller emits to_clear FIRST, then to_show.
@@ -232,6 +286,16 @@ def evaluate_credits_notices(
for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest
if uf >= band[0]:
current_band = band
# Top-up suppression: when the account holds purchased (top-up) credits,
# the subscription-cap gauge is the wrong denominator — warning "90% used"
# at a user sitting on $50 of top-up is noise (and it previously stuck
# PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band
# entirely; the cap-reached case is covered by the grant_spent info notice
# below, which already names the remaining top-up balance. A top-up landing
# mid-session flips current_band → None and the clear path below removes
# any showing band line.
if state.purchased_micros > 0:
current_band = None
grant_cond = (
state.denominator_kind == "subscription_cap"
and uf is not None
@@ -284,10 +348,14 @@ def evaluate_credits_notices(
active.discard("credits.grant_spent")
# ── depleted ─────────────────────────────────────────────────────────────
if depleted_cond and "credits.depleted" not in active:
# Suppressed while the active model is free: inference still works there,
# so the error banner would just alarm users (free-tier users especially,
# who never had paid credits to "lose").
show_depleted = depleted_cond and not model_is_free
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /usage for balance",
text="✕ Credit access paused · run /credits to top up",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",
@@ -295,20 +363,23 @@ def evaluate_credits_notices(
)
)
active.add("credits.depleted")
elif "credits.depleted" in active and not depleted_cond:
elif "credits.depleted" in active and not show_depleted:
to_clear.append("credits.depleted")
active.discard("credits.depleted")
# Recovery: also emit the success notice
to_show.append(
AgentNotice(
text="✓ Credit access restored",
level="success",
kind="ttl",
ttl_ms=CREDITS_RESTORED_TTL_MS,
key="credits.restored",
id="credits.restored",
if not depleted_cond:
# Genuine recovery (paid_access flipped back True): also emit the
# success notice. A clear caused by switching to a free model while
# still depleted must NOT claim access was restored.
to_show.append(
AgentNotice(
text="✓ Credit access restored",
level="success",
kind="ttl",
ttl_ms=CREDITS_RESTORED_TTL_MS,
key="credits.restored",
id="credits.restored",
)
)
)
return (to_show, to_clear)

View File

@@ -25,7 +25,6 @@ import json
import logging
import os
import re
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -33,6 +32,7 @@ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
from hermes_constants import get_hermes_home
from tools import skill_usage
from utils import atomic_json_write
logger = logging.getLogger(__name__)
@@ -97,20 +97,7 @@ def load_state() -> Dict[str, Any]:
def save_state(data: Dict[str, Any]) -> None:
path = _state_file()
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
atomic_json_write(path, data, indent=2, sort_keys=True)
except Exception as e:
logger.debug("Failed to save curator state: %s", e, exc_info=True)

View File

@@ -858,6 +858,20 @@ 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:
@@ -895,15 +909,17 @@ def get_cute_tool_message(
return f"{line}{failure_suffix}"
if tool_name == "web_search":
return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}")
verb = "Parallel search" if _used_free_parallel(result) else "search"
return _wrap(f"┊ 🔍 {verb:<9} {_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"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 fetch pages {dur}")
return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 {verb:<9} pages {dur}")
if tool_name == "terminal":
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
if tool_name == "process":

View File

@@ -549,14 +549,32 @@ def classify_api_error(
should_fallback=True,
)
# Anthropic thinking block signature invalid (400).
# Anthropic thinking block recovery (400). Two distinct failure modes,
# same recovery (strip all reasoning_details and retry without thinking
# blocks — see the thinking_signature handler in conversation_loop.py):
# 1. Signature mismatch: a thinking block is signed against the full
# turn content; any upstream mutation (context compression, session
# truncation, message merging) invalidates the signature.
# Pattern: "signature" + "thinking".
# 2. Frozen-block mutation: Anthropic rejects any change to the
# thinking/redacted_thinking blocks in the *latest* assistant
# message — "`thinking` or `redacted_thinking` blocks in the latest
# assistant message cannot be modified. These blocks must remain as
# they were in the original response." This carries no "signature"
# token, so the original pattern missed it and the turn hard-aborted
# as a non-retryable client error instead of self-healing.
# Pattern: "thinking" + ("cannot be modified" | "must remain as they were").
# Don't gate on provider — OpenRouter proxies Anthropic errors, so the
# provider may be "openrouter" even though the error is Anthropic-specific.
# The message pattern ("signature" + "thinking") is unique enough.
# The combined patterns are unique enough.
if (
status_code == 400
and "signature" in error_msg
and "thinking" in error_msg
and (
"signature" in error_msg
or "cannot be modified" in error_msg
or "must remain as they were" in error_msg
)
):
return _result(
FailoverReason.thinking_signature,
@@ -966,6 +984,34 @@ def _classify_400(
should_fallback=False,
)
# Request-validation errors (unsupported / unknown parameter) MUST be
# checked BEFORE context_overflow. A GPT-5 model rejecting max_tokens
# returns:
# "Unsupported parameter: 'max_tokens' is not supported with this model.
# Use 'max_completion_tokens' instead."
# That string contains the literal substring "max_tokens", which is one of
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# misclassified as context_overflow, routed into the compression loop,
# re-sent with the same bad parameter, and ends in "Cannot compress
# further". These errors are deterministic (every retry gets the identical
# rejection), so classify as a non-retryable format_error and fall back.
#
# NOTE: we deliberately do NOT key off the generic ``invalid_request_error``
# code here — OpenAI stamps that same code on genuine context-overflow 400s,
# so matching it would mis-route real overflows away from compression. The
# unambiguous signals are the explicit "unsupported/unknown parameter"
# message text and the specific parameter-level error codes.
if (
any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS
if p != "invalid_request_error")
or error_code_lower in {"unknown_parameter", "unsupported_parameter"}
):
return result_fn(
FailoverReason.format_error,
retryable=False,
should_fallback=True,
)
# Context overflow from 400
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(

View File

@@ -262,6 +262,7 @@ def _install_npm(
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -310,6 +311,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
text=True,
timeout=600,
env=env,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -347,6 +349,7 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(

View File

@@ -141,6 +141,8 @@ DEFAULT_CONTEXT_LENGTHS = {
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
# substring of "anthropic/claude-sonnet-4.6").
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
"claude-fable-5": 1000000,
"claude-fable": 1000000,
"claude-opus-4-8": 1000000,
"claude-opus-4.8": 1000000,
"claude-opus-4-7": 1000000,
@@ -968,6 +970,16 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
# OpenRouter/Nous phrasing of the same condition.
"in the output" in error_lower
and "maximum context length" in error_lower
) or (
# LM Studio / llama.cpp / some OpenAI-compatible servers:
# "This model's maximum context length is 65536 tokens. However, you
# requested 65536 output tokens and your prompt contains 77409
# characters ..."
# The "requested N output tokens" phrasing means the OUTPUT cap is the
# problem (the input itself fits) — reduce max_tokens, don't compress.
"maximum context length" in error_lower
and "requested" in error_lower
and "output tokens" in error_lower
)
if not is_output_cap_error:
return None
@@ -999,6 +1011,22 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
if _available >= 1:
return _available
# LM Studio / llama.cpp style: context window is reported in tokens but the
# prompt size is reported in CHARACTERS, e.g.
# "maximum context length is 65536 tokens ... your prompt contains 77409
# characters ...".
# Estimate the input tokens conservatively (~3 chars/token, which
# over-reserves the input so the retried output cap stays safely inside the
# window) and leave the remainder of the window for output.
_m_ctx_tok = re.search(r'maximum context length is (\d+)\s*token', error_lower)
_m_chars = re.search(r'prompt contains (\d+)\s*character', error_lower)
if _m_ctx_tok and _m_chars:
_ctx = int(_m_ctx_tok.group(1))
_est_input = (int(_m_chars.group(1)) + 2) // 3
_available = _ctx - _est_input
if _available >= 1:
return _available
return None
@@ -1784,10 +1812,43 @@ def get_model_context_length(
if ctx is not None:
save_context_length(model, base_url, ctx)
return ctx
# 5f. OpenRouter live /models metadata — authoritative for OpenRouter-routed
# models. OpenRouter's catalog carries per-model context_length (e.g.
# anthropic/claude-fable-5 -> 1M) and refreshes as new slugs ship, so it
# must win over both models.dev (step 5g) and the hardcoded family catch-all
# (step 8). Before this branch, an OpenRouter selection set
# effective_provider="openrouter", which (a) made the models.dev lookup miss
# brand-new slugs and (b) skipped the step-6 OR fallback (gated on `not
# effective_provider`), so a fresh slug like claude-fable-5 fell through to
# the generic "claude": 200K entry and under-reported a 1M window. Mirrors
# the dedicated Nous/Copilot/GMI branches above.
if effective_provider == "openrouter":
metadata = fetch_model_metadata()
entry = metadata.get(model)
if entry:
or_ctx = entry.get("context_length")
# Guard against the known OpenRouter Kimi-family 32k underreport
# (same class the hardcoded overrides exist to mitigate).
if isinstance(or_ctx, int) and or_ctx > 0 and not (
or_ctx == 32768 and _model_name_suggests_kimi(model)
):
return or_ctx
if effective_provider:
from agent.models_dev import lookup_models_dev_context
ctx = lookup_models_dev_context(effective_provider, model)
if ctx:
# MiniMax M3: models.dev reports 512K but actual context is 1M.
# Prefer hardcoded catalog over stale probe value.
if _model_name_suggests_minimax_m3(model):
catalog = DEFAULT_CONTEXT_LENGTHS.get("minimax-m3")
if catalog and ctx < catalog:
logger.info(
"Rejecting models.dev context=%s for %r "
"(MiniMax-M3 underreport); using hardcoded default %s",
ctx, model, f"{catalog:,}",
)
ctx = catalog
return ctx
# 6. OpenRouter live API metadata — provider-unaware fallback.

View File

@@ -489,6 +489,23 @@ PLATFORM_HINTS = {
"files arrive as downloadable documents. You can also include image "
"URLs in markdown format ![alt](url) and they will be sent as photos."
),
"whatsapp_cloud": (
"You are on a text messaging communication platform, WhatsApp "
"(via Meta's official Business Cloud API). Standard markdown "
"(**bold**, ~~strike~~, # headers, [links](url)) is auto-converted "
"to WhatsApp's native syntax (*bold*, ~strike~, etc.) — feel free "
"to write in markdown. Tables are NOT supported — prefer bullet "
"lists or labeled key:value pairs. "
"You can send media files natively: include MEDIA:/absolute/path/to/file "
"in your response. Images (.jpg, .png) become photo attachments, "
"videos (.mp4) play inline, audio (.mp3, .ogg) sends as voice/audio "
"messages, other files arrive as documents. Image URLs in markdown "
"format ![alt](url) also work. "
"IMPORTANT: this platform has a 24-hour conversation window — if the "
"user hasn't messaged in 24h, free-form replies are refused by Meta "
"(error 131047). This rarely matters for live chat, but is worth "
"knowing if you're scheduling a delayed message."
),
"telegram": (
"You are on a text messaging communication platform, Telegram. "
"Standard markdown is automatically converted to Telegram format. "
@@ -885,6 +902,22 @@ def build_environment_hints() -> str:
f"`uname -a && whoami && pwd`."
)
# Hermes desktop GUI — any agent running under the desktop app should know
# it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
# marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
_truthy = ("1", "true", "yes")
_in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
_in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
if _in_desktop or _in_desktop_term:
_desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
if _in_desktop_term:
_desktop_hint += (
" You're in its embedded terminal pane, beside the GUI chat — the user can "
"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
"⌘/Ctrl+L to send it to the chat composer."
)
hints.append(_desktop_hint)
if is_wsl():
hints.append(WSL_ENVIRONMENT_HINT)
@@ -1085,11 +1118,12 @@ def _skill_should_show(
def build_skills_system_prompt(
available_tools: "set[str] | None" = None,
available_toolsets: "set[str] | None" = None,
compact_categories: "frozenset[str] | None" = None,
) -> str:
"""Build a compact skill index for the system prompt.
Two-layer cache:
1. In-process LRU dict keyed by (skills_dir, tools, toolsets)
1. In-process LRU dict keyed by (skills_dir, tools, toolsets, hidden)
2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by
mtime/size manifest — survives process restarts
@@ -1099,6 +1133,12 @@ def build_skills_system_prompt(
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
are read-only — they appear in the index but new skills are always created
in the local dir. Local skills take precedence when names collide.
``compact_categories`` (e.g. from the coding posture — see
agent/coding_context.py) demotes whole categories to a names-only line in
the rendered index. Nothing is ever hidden: every skill name stays
visible and loadable via ``skill_view`` / ``skills_list``; only the
descriptions are dropped, and a footer note explains the demotion.
"""
skills_dir = get_skills_dir()
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
@@ -1123,6 +1163,7 @@ def build_skills_system_prompt(
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
_platform_hint,
tuple(sorted(disabled)),
tuple(sorted(compact_categories or ())),
)
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
@@ -1256,18 +1297,44 @@ def build_skills_system_prompt(
except Exception as e:
logger.debug("Could not read external skill description %s: %s", desc_file, e)
# Posture-driven category demotion (e.g. non-coding skills while pairing
# on code). Demoted categories stay in the index as a single names-only
# line — descriptions are dropped to cut noise, but every skill name
# remains visible so memory-anchored recall ("load <name>") keeps working.
# NEVER remove entries entirely: agent-created skills are the model's
# project memory, and models don't reach for skills_list to rediscover
# what the index stops showing them. Match on the top-level category
# segment so nested categories ("social-media/twitter") are demoted with
# their parent.
demoted = frozenset(
cat for cat in skills_by_category
if cat.split("/", 1)[0] in (compact_categories or frozenset())
)
hidden_note = ""
if demoted:
hidden_note = (
"\n(Categories marked [names only] are outside the current coding "
"context, so their descriptions are omitted — the skills work "
"normally and load with skill_view(name) as usual.)"
)
if not skills_by_category:
result = ""
else:
index_lines = []
for category in sorted(skills_by_category.keys()):
# Deduplicate and sort skills within each category
seen = set()
if category in demoted:
names = sorted({name for name, _ in skills_by_category[category]})
index_lines.append(f" {category} [names only]: {', '.join(names)}")
continue
cat_desc = category_descriptions.get(category, "")
if cat_desc:
index_lines.append(f" {category}: {cat_desc}")
else:
index_lines.append(f" {category}:")
# Deduplicate and sort skills within each category
seen = set()
for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
if name in seen:
continue
@@ -1304,6 +1371,7 @@ def build_skills_system_prompt(
"</available_skills>\n"
"\n"
"Only proceed without loading a skill if genuinely none are relevant to the task."
+ hidden_note
)
# ── Store in LRU cache ────────────────────────────────────────────
@@ -1367,13 +1435,13 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
lines = [
"# Nous Subscription",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, OpenAI Whisper STT, and browser automation (Browser Use) by default. Modal execution is optional.",
"Current capability status:",
]
lines.extend(_status_line(feature) for feature in features.items())
lines.extend(
[
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.",
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, or Browser-Use API keys.",
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",

View File

@@ -274,6 +274,7 @@ def _platform_asset_name() -> str:
capture_output=True,
text=True,
timeout=2,
stdin=subprocess.DEVNULL,
)
if "musl" in (res.stdout + res.stderr).lower():
libc = "musl"
@@ -525,6 +526,7 @@ def _run_bws_list(
capture_output=True,
text=True,
timeout=_BWS_RUN_TIMEOUT,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(

View File

@@ -74,6 +74,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
text=True,
timeout=max(1, int(timeout)),
check=False,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"[inline-shell timeout after {timeout}s: {command}]"

View File

@@ -191,9 +191,23 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
if toolset
}
# Focus mode (opt-in) demotes non-coding skill categories to
# names-only in the index (never hidden — skill_view/skills_list
# reach everything, and every name stays visible for recall). The
# default coding posture leaves the index untouched.
_compact_cats = frozenset()
try:
from agent.coding_context import coding_compact_skill_categories
_compact_cats = coding_compact_skill_categories(
platform=agent.platform, cwd=resolve_context_cwd()
)
except Exception:
_compact_cats = frozenset()
skills_prompt = _r.build_skills_system_prompt(
available_tools=agent.valid_tool_names,
available_toolsets=avail_toolsets,
compact_categories=_compact_cats or None,
)
else:
skills_prompt = ""
@@ -221,6 +235,26 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if _env_hints:
stable_parts.append(_env_hints)
# Coding posture (base Hermes, any interactive coding surface in a code
# workspace — see agent/coding_context.py). The operating brief + the live
# git/workspace snapshot are built once here and cached for the session;
# the snapshot is never re-probed per turn (that would break the prompt
# cache), so the brief tells the model to re-check git before relying on it.
if agent.valid_tool_names:
try:
from agent.coding_context import coding_system_blocks
stable_parts.extend(
coding_system_blocks(
platform=agent.platform,
cwd=resolve_context_cwd(),
model=agent.model,
)
)
except Exception:
# Coding-context probing must never block prompt build.
pass
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
# something is non-default so the model can pick the right install
# strategy without discovering by failure. Emits a single line; emits

View File

@@ -417,7 +417,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# ── Logging / callbacks ──────────────────────────────────────────
tool_names_str = ", ".join(name for _, name, _, _, _, _ in parsed_calls)
if not agent.quiet_mode:
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
args_str = json.dumps(args, ensure_ascii=False)
@@ -702,7 +702,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
if agent._should_emit_quiet_tool_messages():
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
agent._safe_print(f" {cute_msg}")
elif getattr(agent, "tool_progress_mode", "all") != "off":
elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
_preview_str = _multimodal_text_summary(function_result)
if agent.verbose_logging:
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
@@ -866,7 +866,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
elif function_name == "skill_manage":
agent._iters_since_skill = 0
if not agent.quiet_mode:
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
args_str = json.dumps(function_args, ensure_ascii=False)
if agent.verbose_logging:
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})")
@@ -1065,6 +1065,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}")
elif function_name == "read_terminal":
def _execute(next_args: dict) -> Any:
from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool
return _read_terminal_tool(
start_line=next_args.get("start_line"),
count=next_args.get("count"),
callback=getattr(agent, "read_terminal_callback", None),
)
function_result, function_args = _run_agent_tool_execution_middleware(
agent,
function_name=function_name,
function_args=function_args,
effective_task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", "") or "",
execute=_execute,
)
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('read_terminal', function_args, tool_duration, result=function_result)}")
elif function_name == "delegate_task":
tasks_arg = function_args.get("tasks")
if tasks_arg and isinstance(tasks_arg, list):
@@ -1365,7 +1384,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# entire batch. The model sees it on the next API iteration.
agent._apply_pending_steer_to_tool_results(messages, 1)
if not agent.quiet_mode:
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
if agent.verbose_logging:
print(f" ✅ Tool {i} completed in {tool_duration:.2f}s")
print(agent._wrap_verbose("Result: ", function_result))

View File

@@ -84,7 +84,7 @@ class AnthropicTransport(ProviderTransport):
to OpenAI finish_reason, and collects reasoning_details in provider_data.
"""
import json
from agent.anthropic_adapter import _to_plain_data
from agent.anthropic_adapter import _to_plain_data, _sanitize_replay_block
from agent.transports.types import ToolCall
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
@@ -94,14 +94,40 @@ class AnthropicTransport(ProviderTransport):
reasoning_parts = []
reasoning_details = []
tool_calls = []
# Verbatim, order-preserving copy of every content block in the turn.
# Anthropic signs each thinking block against the turn content that
# PRECEDES it at its position; when a turn interleaves thinking and
# tool_use (adaptive/interleaved thinking, Claude 4.6+), the parallel
# reasoning_details + tool_calls lists below lose that cross-type
# ordering. Replaying the latest assistant message in the wrong order
# invalidates the signatures -> HTTP 400 "thinking ... blocks in the
# latest assistant message cannot be modified". Preserve the exact
# block sequence here so the adapter can replay it unchanged. See
# tests/agent/test_anthropic_thinking_block_order.py.
ordered_blocks = []
for block in response.content:
block_dict = _to_plain_data(block)
clean_block = None
if isinstance(block_dict, dict):
# Sanitize at capture so output-only SDK fields (parsed_output,
# caller, citations=None, …) never persist to state.db and leak
# back as request input on replay → HTTP 400 "Extra inputs are
# not permitted". Defence-in-depth with the replay-side sanitize.
clean_block = _sanitize_replay_block(block_dict)
if clean_block is not None:
ordered_blocks.append(clean_block)
if block.type == "text":
text_parts.append(block.text)
elif block.type == "thinking":
reasoning_parts.append(block.thinking)
block_dict = _to_plain_data(block)
if isinstance(block_dict, dict):
elif block.type in ("thinking", "redacted_thinking"):
if block.type == "thinking":
reasoning_parts.append(block.thinking)
# Use the sanitized block (clean_block) for reasoning_details too,
# since _extract_preserved_thinking_blocks replays these on the
# non-ordered path. Falls back to raw only if sanitize dropped it.
if isinstance(clean_block, dict):
reasoning_details.append(clean_block)
elif isinstance(block_dict, dict):
reasoning_details.append(block_dict)
elif block.type == "tool_use":
name = block.name
@@ -130,6 +156,23 @@ class AnthropicTransport(ProviderTransport):
provider_data = {}
if reasoning_details:
provider_data["reasoning_details"] = reasoning_details
# Only worth carrying the ordered-blocks channel when the turn
# actually interleaves signed thinking with tool_use — that's the
# only shape the parallel lists reconstruct incorrectly. A turn that
# is purely text, or thinking-then-tools with a single leading
# thinking block, replays correctly without it.
_has_signed_thinking = any(
isinstance(b, dict)
and b.get("type") in ("thinking", "redacted_thinking")
and (b.get("signature") or b.get("data"))
for b in ordered_blocks
)
_has_tool_use = any(
isinstance(b, dict) and b.get("type") == "tool_use"
for b in ordered_blocks
)
if _has_signed_thinking and _has_tool_use:
provider_data["anthropic_content_blocks"] = ordered_blocks
return NormalizedResponse(
content="\n".join(text_parts) if text_parts else None,

View File

@@ -378,6 +378,7 @@ def check_codex_binary(
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
return False, (

View File

@@ -72,6 +72,9 @@ class TurnResult:
error: Optional[str] = None # Set if turn ended in a non-recoverable error
turn_id: Optional[str] = None
thread_id: Optional[str] = None
token_usage_last: Optional[dict[str, Any]] = None
token_usage_total: Optional[dict[str, Any]] = None
model_context_window: Optional[int] = None
# Hint to the caller that the underlying codex subprocess is likely
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
# token-refresh failure killed the child). The caller should retire
@@ -501,6 +504,7 @@ class CodexAppServerSession:
pending = self._client.take_notification(timeout=0)
if pending is None:
break
_apply_token_usage_notification(result, pending)
self._track_pending_file_change(pending)
proj = projector.project(pending)
if proj.messages:
@@ -536,6 +540,8 @@ class CodexAppServerSession:
except Exception: # pragma: no cover - display callback
logger.debug("on_event callback raised", exc_info=True)
_apply_token_usage_notification(result, note)
# Track in-progress fileChange items so the approval bridge
# can surface a real change summary when codex requests
# approval (the approval params themselves don't carry the
@@ -802,6 +808,30 @@ class CodexAppServerSession:
return cached
def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
"""Capture Codex app-server token usage updates for caller accounting.
Codex does not put token usage on turn/completed. It emits a separate
thread/tokenUsage/updated notification containing cumulative totals and
the latest turn breakdown.
"""
if not isinstance(note, dict) or note.get("method") != "thread/tokenUsage/updated":
return
params = note.get("params") or {}
token_usage = params.get("tokenUsage") or {}
if not isinstance(token_usage, dict):
return
last = token_usage.get("last")
total = token_usage.get("total")
if isinstance(last, dict):
result.token_usage_last = dict(last)
if isinstance(total, dict):
result.token_usage_total = dict(total)
window = token_usage.get("modelContextWindow")
if isinstance(window, int) and window > 0:
result.model_context_window = window
def _approval_choice_to_codex_decision(choice: str) -> str:
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
/ FileChangeApprovalDecision wire values.

View File

@@ -121,6 +121,18 @@ class NormalizedResponse:
pd = self.provider_data or {}
return pd.get("reasoning_details")
@property
def anthropic_content_blocks(self):
"""Verbatim, order-preserving Anthropic content blocks for a turn.
Present only when an Anthropic turn interleaves signed thinking with
tool_use — the one shape the parallel reasoning_details + tool_calls
lists reconstruct in the wrong order, invalidating thinking-block
signatures on replay. See agent/transports/anthropic.py.
"""
pd = self.provider_data or {}
return pd.get("anthropic_content_blocks")
@property
def codex_reasoning_items(self):
pd = self.provider_data or {}

View File

@@ -13,6 +13,7 @@ DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
_ZERO = Decimal("0")
_ONE_MILLION = Decimal("1000000")
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
CostStatus = Literal["actual", "estimated", "included", "unknown"]
CostSource = Literal[
@@ -570,6 +571,8 @@ def resolve_billing_route(
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
if provider_name == "anthropic":
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name == "openai":

View File

@@ -11,7 +11,8 @@
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:build:debug": "tauri build --debug"
"tauri:build:debug": "tauri build --debug",
"typecheck": "tsc -p . --noEmit"
},
"dependencies": {
"@nous-research/ui": "0.16.0",
@@ -40,7 +41,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "~5.9.3",
"typescript": "^6.0.3",
"vite": "^7.3.1"
}
}

View File

@@ -16,9 +16,8 @@
"noUnusedParameters": true,
"esModuleInterop": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["./src/*"]
}
},
"include": ["src"],

View File

@@ -93,7 +93,7 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl
```bash
npm run fix
npm run type-check
npm run typecheck
npm run lint
npm run test:desktop:all
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

After

Width:  |  Height:  |  Size: 561 KiB

View File

@@ -0,0 +1,101 @@
const path = require('node:path')
// Match the POSIX fallback surface used by the Python terminal environment.
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex.
const POSIX_SANE_PATH_ENTRIES = Object.freeze([
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
'/usr/local/sbin',
'/usr/local/bin',
'/usr/sbin',
'/usr/bin',
'/sbin',
'/bin'
])
function delimiterForPlatform(platform = process.platform) {
return platform === 'win32' ? ';' : ':'
}
function pathModuleForPlatform(platform = process.platform) {
return platform === 'win32' ? path.win32 : path.posix
}
function pathEnvKey(env = process.env, platform = process.platform) {
if (platform !== 'win32') return 'PATH'
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
}
function currentPathValue(env = process.env, platform = process.platform) {
const key = pathEnvKey(env, platform)
return env?.[key] || ''
}
function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
const seen = new Set()
const ordered = []
for (const entry of entries) {
if (!entry) continue
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
for (const part of parts) {
if (!part || seen.has(part)) continue
seen.add(part)
ordered.push(part)
}
}
return ordered.join(delimiter)
}
function buildDesktopBackendPath({
hermesHome,
venvRoot,
currentPath = '',
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
const delimiter = delimiterForPlatform(platform)
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
return appendUniquePathEntries(
[hermesNodeBin, venvBin, currentPath, saneEntries],
{ delimiter }
)
}
function buildDesktopBackendEnv({
hermesHome,
pythonPathEntries = [],
venvRoot,
currentEnv = process.env,
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
const delimiter = delimiterForPlatform(platform)
const currentPythonPath = currentEnv?.PYTHONPATH || ''
const key = pathEnvKey(currentEnv, platform)
return {
PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
[key]: buildDesktopBackendPath({
hermesHome,
venvRoot,
currentPath: currentPathValue(currentEnv, platform),
platform,
pathModule
})
}
}
module.exports = {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
pathEnvKey
}

View File

@@ -0,0 +1,95 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')
const {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
pathEnvKey
} = require('./backend-env.cjs')
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
const result = buildDesktopBackendPath({
hermesHome: '/Users/test/.hermes',
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
platform: 'darwin',
pathModule: path.posix
})
const entries = result.split(':')
assert.equal(entries[0], '/Users/test/.hermes/node/bin')
assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin')
assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added')
assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added')
assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added')
for (const expected of POSIX_SANE_PATH_ENTRIES) {
assert.ok(entries.includes(expected), `${expected} should be present`)
}
})
test('desktop backend PATH preserves first occurrence and avoids duplicates', () => {
const result = buildDesktopBackendPath({
hermesHome: '/Users/test/.hermes',
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin',
platform: 'darwin',
pathModule: path.posix
})
const entries = result.split(':')
assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1)
assert.ok(
entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'),
'existing Homebrew bin keeps its precedence over appended missing sane entries'
)
})
test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => {
const env = buildDesktopBackendEnv({
hermesHome: '/Users/test/.hermes',
pythonPathEntries: ['/repo/hermes-agent'],
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentEnv: {
PATH: '/usr/bin:/bin',
PYTHONPATH: '/existing/pythonpath'
},
platform: 'darwin',
pathModule: path.posix
})
assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath')
assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:'))
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
})
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
const env = buildDesktopBackendEnv({
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
pythonPathEntries: ['C:\\repo\\hermes-agent'],
venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv',
currentEnv: {
Path: 'C:\\Windows\\System32;C:\\Windows',
PYTHONPATH: 'C:\\existing\\pythonpath'
},
platform: 'win32',
pathModule: path.win32
})
assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path')
assert.equal(env.PATH, undefined)
assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;'))
assert.ok(env.Path.includes('\\venv\\Scripts;'))
assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows'))
assert.equal(env.Path.includes('/opt/homebrew/bin'), false)
})
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
assert.equal(
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
'/a:/b:/c'
)
})

View File

@@ -40,6 +40,15 @@ const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
const IS_WINDOWS = process.platform === 'win32'
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
// Stages flagged needs_user_input=true in the manifest are skipped by the
@@ -284,7 +293,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
const child = spawn(ps, fullArgs, {
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
@@ -292,7 +301,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
})
}))
let stdout = ''
let stderr = ''

View File

@@ -0,0 +1,99 @@
/**
* Helpers for local dashboard session-token discovery.
*
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
* spawns the local dashboard, but the dashboard is the source of truth for the
* token it actually serves to the renderer. If those drift, HTTP readiness
* probes still pass while /api/ws rejects the renderer's token.
*/
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
async function fetchPublicText(url, options = {}) {
const { protocol } = new URL(url)
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
if (error.name === 'TimeoutError') {
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
}
throw error
})
const text = await res.text()
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
return text
}
function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) return null
try {
return JSON.parse(match[1])
} catch {
return null
}
}
function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
}
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
})
const servedToken = extractInjectedDashboardToken(html)
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
}
return servedToken || fallbackToken
}
/**
* A served token that differs from our spawn token while our child is DEAD
* came from a process we did not spawn (orphan/port squatter that satisfied
* the public /api/status readiness probe). With a live child the mismatch is
* benign: our own backend regenerated the token because the env pin did not
* survive the spawn.
*/
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
}
/**
* Resolve the token the backend actually serves, adopting benign drift and
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
* sampled after the fetch, not before.
*/
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
return spawnToken
})
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
throw new Error(
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
)
}
return servedToken
}
module.exports = {
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
}

View File

@@ -0,0 +1,142 @@
/**
* Tests for electron/dashboard-token.cjs.
*
* Run with: node --test electron/dashboard-token.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
} = require('./dashboard-token.cjs')
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
assert.equal(extractInjectedDashboardToken(html), 'served-token')
})
test('extractInjectedDashboardToken handles escaped token strings', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
})
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
})
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
})
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'served-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /served a different session token/)
})
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => '<html></html>',
rememberLog: () => {
throw new Error('should not log when no served token is present')
}
})
assert.equal(token, 'spawn-token')
})
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
rememberLog: () => {
throw new Error('should not log when token already matches')
}
})
assert.equal(token, 'same-token')
})
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
await assert.rejects(
() =>
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => {
throw new Error('boom')
}
}),
/boom/
)
})
test('fetchPublicText rejects unsupported protocols', async () => {
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
})
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
const cases = [
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
// Live child + drift = our backend regenerated the token (env pin lost).
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
]
for (const [input, expected] of cases) {
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
}
})
test('adoptServedDashboardToken adopts drift from a live child', async () => {
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
})
assert.equal(token, 'served-token')
})
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
await assert.rejects(
() =>
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => false,
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
label: 'Hermes backend for profile "work"'
}),
/profile "work".*process we did not spawn/
)
})
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
const logs = []
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => {
throw new Error('boom')
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'spawn-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
})

View File

@@ -0,0 +1,109 @@
'use strict'
const fs = require('node:fs')
const path = require('node:path')
const { resolveDirectoryForIpc } = require('./hardening.cjs')
const FS_READDIR_STAT_CONCURRENCY = 16
// Always-hidden noise (covers non-git projects too; gitignore catches many of
// these, but the project tree should keep the same hygiene without one).
const FS_READDIR_HIDDEN = new Set([
'.git',
'.hg',
'.svn',
'.cache',
'.next',
'.turbo',
'.venv',
'__pycache__',
'build',
'dist',
'node_modules',
'target',
'venv'
])
function direntIsDirectory(dirent) {
return typeof dirent.isDirectory === 'function' && dirent.isDirectory()
}
function direntIsFile(dirent) {
return typeof dirent.isFile === 'function' && dirent.isFile()
}
function direntIsSymbolicLink(dirent) {
return typeof dirent.isSymbolicLink === 'function' && dirent.isSymbolicLink()
}
function shouldStatDirent(dirent) {
if (direntIsDirectory(dirent)) return false
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
}
async function entryForDirent(dirent, resolved, fsImpl) {
const fullPath = path.join(resolved, dirent.name)
let isDirectory = direntIsDirectory(dirent)
if (!isDirectory && shouldStatDirent(dirent)) {
try {
isDirectory = (await fsImpl.promises.stat(fullPath)).isDirectory()
} catch {
isDirectory = false
}
}
return { name: dirent.name, path: fullPath, isDirectory }
}
async function mapWithStatConcurrency(items, mapper) {
const results = new Array(items.length)
let nextIndex = 0
async function runWorker() {
while (nextIndex < items.length) {
const index = nextIndex
nextIndex += 1
results[index] = await mapper(items[index])
}
}
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
const workers = Array.from({ length: workerCount }, () => runWorker())
await Promise.all(workers)
return results
}
async function readDirForIpc(dirPath, options = {}) {
const fsImpl = options.fs || fs
let resolved
try {
;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, {
fs: fsImpl,
purpose: 'Directory read'
}))
} catch (error) {
return { entries: [], error: error?.code || 'read-error' }
}
try {
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
entryForDirent(dirent, resolved, fsImpl)
)
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
return { entries }
} catch (error) {
return { entries: [], error: error?.code || 'read-error' }
}
}
module.exports = {
readDirForIpc
}

View File

@@ -0,0 +1,364 @@
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
const { readDirForIpc } = require('./fs-read-dir.cjs')
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
}
function fakeDirent(name, flags = {}) {
return {
name,
isDirectory: () => Boolean(flags.directory),
isFile: () => Boolean(flags.file),
isSymbolicLink: () => Boolean(flags.symlink)
}
}
test('readDirForIpc hides noisy directories and files from the project tree', async () => {
const root = mkTmpDir()
try {
fs.mkdirSync(path.join(root, 'node_modules'))
fs.mkdirSync(path.join(root, 'src'))
fs.writeFileSync(path.join(root, 'target'), 'hidden file')
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
const result = await readDirForIpc(root)
assert.equal(result.error, undefined)
assert.deepEqual(
result.entries.map(entry => entry.name),
['src', 'README.md']
)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc filters a hidden basename whether it is a file or directory', async () => {
const dirRoot = mkTmpDir()
const fileRoot = mkTmpDir()
try {
fs.mkdirSync(path.join(dirRoot, 'node_modules'))
fs.writeFileSync(path.join(dirRoot, 'visible.txt'), 'visible')
fs.writeFileSync(path.join(fileRoot, 'node_modules'), 'hidden file')
fs.writeFileSync(path.join(fileRoot, 'visible.txt'), 'visible')
assert.deepEqual(
(await readDirForIpc(dirRoot)).entries.map(entry => entry.name),
['visible.txt']
)
assert.deepEqual(
(await readDirForIpc(fileRoot)).entries.map(entry => entry.name),
['visible.txt']
)
} finally {
fs.rmSync(dirRoot, { recursive: true, force: true })
fs.rmSync(fileRoot, { recursive: true, force: true })
}
})
test('readDirForIpc returns directories before files and sorts by name within groups', async () => {
const root = mkTmpDir()
try {
fs.writeFileSync(path.join(root, 'z.txt'), 'z')
fs.mkdirSync(path.join(root, 'src'))
fs.writeFileSync(path.join(root, 'a.txt'), 'a')
fs.mkdirSync(path.join(root, 'lib'))
const result = await readDirForIpc(root)
assert.equal(result.error, undefined)
assert.deepEqual(
result.entries.map(entry => entry.name),
['lib', 'src', 'a.txt', 'z.txt']
)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc accepts file URLs for directories', async () => {
const root = mkTmpDir()
try {
fs.mkdirSync(path.join(root, 'src'))
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
const result = await readDirForIpc(pathToFileURL(root).toString())
assert.equal(result.error, undefined)
assert.deepEqual(
result.entries.map(entry => entry.name),
['src', 'README.md']
)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
return []
}
}
}
assert.deepEqual(await readDirForIpc('', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
assert.deepEqual(await readDirForIpc(' ', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
assert.deepEqual(await readDirForIpc(null, { fs: fsImpl }), { entries: [], error: 'invalid-path' })
assert.equal(readdirCalls, 0)
})
test('readDirForIpc rejects Windows device paths before readdir', async () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
return []
}
}
}
assert.deepEqual(await readDirForIpc('\\\\?\\C:\\secret', { fs: fsImpl }), {
entries: [],
error: 'device-path'
})
assert.equal(readdirCalls, 0)
})
test('readDirForIpc returns filesystem error codes instead of throwing', async () => {
const root = mkTmpDir()
try {
const result = await readDirForIpc(path.join(root, 'missing'))
assert.deepEqual(result, { entries: [], error: 'ENOENT' })
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc marks a symlink to a directory as a directory', async t => {
const root = mkTmpDir()
try {
fs.mkdirSync(path.join(root, 'actual-dir'))
try {
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'linked-dir'), 'dir')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
const result = await readDirForIpc(root)
const linked = result.entries.find(entry => entry.name === 'linked-dir')
assert.equal(result.error, undefined)
assert.equal(linked?.isDirectory, true)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc marks a Windows junction to a directory as a directory', async t => {
if (process.platform !== 'win32') {
t.skip('junctions are a Windows-specific symlink type')
return
}
const root = mkTmpDir()
try {
fs.mkdirSync(path.join(root, 'actual-dir'))
try {
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'junction-dir'), 'junction')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`junction creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
const result = await readDirForIpc(root)
const junction = result.entries.find(entry => entry.name === 'junction-dir')
assert.equal(result.error, undefined)
assert.equal(junction?.isDirectory, true)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc allows expanding symlink or junction directories outside the project root', async t => {
const root = mkTmpDir()
const outside = mkTmpDir()
try {
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
const linkPath = path.join(root, 'outside-link')
try {
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
const result = await readDirForIpc(linkPath)
assert.equal(result.error, undefined)
assert.deepEqual(result.entries, [
{ name: 'outside.txt', path: path.join(linkPath, 'outside.txt'), isDirectory: false }
])
} finally {
fs.rmSync(root, { recursive: true, force: true })
fs.rmSync(outside, { recursive: true, force: true })
}
})
test('readDirForIpc stats symbolic links and unknown entries without dropping the whole listing', async () => {
const input = path.join('virtual-root')
const resolved = path.resolve(input)
const statCalls = []
const fsImpl = {
promises: {
readdir: async () => [
fakeDirent('unknown-entry'),
fakeDirent('linked-dir', { symlink: true }),
fakeDirent('broken-link', { symlink: true }),
fakeDirent('plain.txt', { file: true })
],
stat: async fullPath => {
if (fullPath === resolved) {
return { isDirectory: () => true }
}
statCalls.push(fullPath)
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
return { isDirectory: () => true }
}
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
}
}
}
const result = await readDirForIpc(input, { fs: fsImpl })
assert.equal(result.error, undefined)
assert.deepEqual(
statCalls.sort(),
[path.join(resolved, 'broken-link'), path.join(resolved, 'linked-dir'), path.join(resolved, 'unknown-entry')].sort()
)
assert.deepEqual(result.entries, [
{ name: 'linked-dir', path: path.join(resolved, 'linked-dir'), isDirectory: true },
{ name: 'broken-link', path: path.join(resolved, 'broken-link'), isDirectory: false },
{ name: 'plain.txt', path: path.join(resolved, 'plain.txt'), isDirectory: false },
{ name: 'unknown-entry', path: path.join(resolved, 'unknown-entry'), isDirectory: false }
])
})
test('readDirForIpc bounds concurrent stats while preserving complete sorted output', async () => {
const input = path.join('virtual-root')
const resolved = path.resolve(input)
const names = Array.from({ length: 105 }, (_, index) => `entry-${String(104 - index).padStart(3, '0')}`)
const failedName = 'entry-100'
const directoryNames = new Set(names.filter((_, index) => index % 10 === 4))
const successfulDirectoryNames = new Set([...directoryNames].filter(name => name !== failedName))
const statCalls = []
let active = 0
let peak = 0
let releaseStats
let markFirstStatStarted
const statsReleased = new Promise(resolve => {
releaseStats = resolve
})
const firstStatStarted = new Promise(resolve => {
markFirstStatStarted = resolve
})
const fsImpl = {
promises: {
readdir: async () => [
fakeDirent('node_modules', { symlink: true }),
...names.map((name, index) => fakeDirent(name, { symlink: index % 2 === 0 }))
],
stat: async fullPath => {
if (fullPath === resolved) {
return { isDirectory: () => true }
}
statCalls.push(fullPath)
active += 1
peak = Math.max(peak, active)
markFirstStatStarted()
await statsReleased
active -= 1
const name = path.basename(fullPath)
if (name === failedName) {
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
}
return { isDirectory: () => successfulDirectoryNames.has(name) }
}
}
}
const resultPromise = readDirForIpc(input, { fs: fsImpl })
await firstStatStarted
await new Promise(resolve => setImmediate(resolve))
releaseStats()
const result = await resultPromise
const expectedNames = [
...names.filter(name => successfulDirectoryNames.has(name)).sort(),
...names.filter(name => !successfulDirectoryNames.has(name)).sort()
]
assert.equal(result.error, undefined)
assert.equal(result.entries.length, names.length)
assert.equal(statCalls.length, names.length)
assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
assert.deepEqual(
result.entries.map(entry => entry.name),
expectedNames
)
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
assert.equal(
result.entries.filter(entry => entry.isDirectory).length,
successfulDirectoryNames.size
)
})

View File

@@ -0,0 +1,54 @@
'use strict'
const fs = require('node:fs')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
function findGitRoot(start, fsImpl = fs) {
let dir = start
for (let i = 0; i < 50; i += 1) {
try {
if (fsImpl.existsSync(path.join(dir, '.git'))) {
return dir
}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
return null
}
async function gitRootForIpc(startPath, options = {}) {
const fsImpl = options.fs || fs
let resolved
try {
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Git root' })
} catch {
return null
}
try {
const stat = await fsImpl.promises.stat(resolved)
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
return findGitRoot(start, fsImpl)
} catch {
return findGitRoot(resolved, fsImpl)
}
}
module.exports = {
findGitRoot,
gitRootForIpc
}

View File

@@ -0,0 +1,40 @@
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
const { gitRootForIpc } = require('./git-root.cjs')
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
}
test('gitRootForIpc returns null for invalid and device paths', async () => {
assert.equal(await gitRootForIpc(''), null)
assert.equal(await gitRootForIpc(' '), null)
assert.equal(await gitRootForIpc(null), null)
assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null)
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
})
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
const root = mkTmpDir()
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
const gitDir = path.join(root, '.git')
const srcDir = path.join(root, 'src')
const filePath = path.join(srcDir, 'index.ts')
fs.mkdirSync(gitDir)
fs.mkdirSync(srcDir)
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
assert.equal(await gitRootForIpc(root), root)
assert.equal(await gitRootForIpc(srcDir), root)
assert.equal(await gitRootForIpc(filePath), root)
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
})

View File

@@ -106,71 +106,155 @@ function sensitiveFileBlockReason(filePath) {
return null
}
function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
const raw = String(filePath || '').trim()
function ipcPathError(code, message) {
const error = new Error(message)
error.code = code
return error
}
function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
if (typeof filePath !== 'string') {
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
}
const raw = filePath.trim()
if (!raw) {
throw new Error(`${purpose} failed: file path is required.`)
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
}
if (raw.includes('\0')) {
throw new Error(`${purpose} failed: file path is invalid.`)
throw ipcPathError('invalid-path', `${purpose} failed: file path is invalid.`)
}
const normalized = raw.replace(/\\/g, '/').toLowerCase()
if (
normalized.startsWith('//?/') ||
normalized.startsWith('//./') ||
normalized.startsWith('globalroot/device/') ||
normalized.includes('/globalroot/device/')
) {
throw ipcPathError('device-path', `${purpose} blocked: Windows device paths are not allowed.`)
}
return raw
}
function resolveRequestedPathForIpc(filePath, options = {}) {
const purpose = String(options.purpose || 'File read')
const raw = rejectUnsafePathSyntax(filePath, purpose)
if (/^file:/i.test(raw)) {
let resolvedPath
try {
return fileURLToPath(raw)
const parsed = new URL(raw)
if (parsed.protocol !== 'file:') {
throw new Error('not a file URL')
}
resolvedPath = fileURLToPath(parsed)
} catch {
throw new Error(`${purpose} failed: file URL is invalid.`)
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
}
rejectUnsafePathSyntax(resolvedPath, purpose)
return path.resolve(resolvedPath)
}
const resolvedBase = path.resolve(String(baseDir || process.cwd()))
return path.resolve(resolvedBase, raw)
const baseInput = typeof options.baseDir === 'string' && options.baseDir.trim() ? options.baseDir : process.cwd()
const safeBaseInput = rejectUnsafePathSyntax(baseInput, purpose)
const resolvedBase = path.resolve(safeBaseInput)
rejectUnsafePathSyntax(resolvedBase, purpose)
const resolvedPath = path.resolve(resolvedBase, raw)
rejectUnsafePathSyntax(resolvedPath, purpose)
return resolvedPath
}
async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
try {
return await fsImpl.promises.stat(resolvedPath)
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
}
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
async function realpathForIpc(fsImpl, resolvedPath, purpose) {
if (typeof fsImpl.promises.realpath !== 'function') {
return resolvedPath
}
try {
const realPath = await fsImpl.promises.realpath(resolvedPath)
rejectUnsafePathSyntax(realPath, purpose)
return realPath
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
function rejectSensitiveFilePath(filePath, purpose) {
const blockReason = sensitiveFileBlockReason(filePath)
if (blockReason) {
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
}
}
async function resolveDirectoryForIpc(dirPath, options = {}) {
const purpose = String(options.purpose || 'Directory read')
const fsImpl = options.fs || fs
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'directory')
if (!stat.isDirectory()) {
throw ipcPathError('ENOTDIR', `${purpose} failed: path is not a directory.`)
}
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
return { realPath, resolvedPath, stat }
}
async function resolveReadableFileForIpc(filePath, options = {}) {
const purpose = String(options.purpose || 'File read')
const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
const fsImpl = options.fs || fs
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
if (options.blockSensitive !== false) {
const blockReason = sensitiveFileBlockReason(resolvedPath)
if (blockReason) {
throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
}
rejectSensitiveFilePath(resolvedPath, purpose)
}
let stat
try {
stat = await fs.promises.stat(resolvedPath)
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw new Error(`${purpose} failed: file does not exist.`)
}
throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
}
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'file')
if (stat.isDirectory()) {
throw new Error(`${purpose} failed: path points to a directory.`)
throw ipcPathError('EISDIR', `${purpose} failed: path points to a directory.`)
}
if (!stat.isFile()) {
throw new Error(`${purpose} failed: only regular files can be read.`)
throw ipcPathError('EINVAL', `${purpose} failed: only regular files can be read.`)
}
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
if (options.blockSensitive !== false) {
rejectSensitiveFilePath(realPath, purpose)
}
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
if (maxBytes && stat.size > maxBytes) {
throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
}
try {
await fs.promises.access(resolvedPath, fs.constants.R_OK)
await fsImpl.promises.access(resolvedPath, fs.constants.R_OK)
} catch {
throw new Error(`${purpose} failed: file is not readable.`)
throw ipcPathError('EACCES', `${purpose} failed: file is not readable.`)
}
return { resolvedPath, stat }
return { realPath, resolvedPath, stat }
}
module.exports = {
@@ -178,7 +262,10 @@ module.exports = {
DEFAULT_FETCH_TIMEOUT_MS,
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret,
rejectUnsafePathSyntax,
resolveDirectoryForIpc,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
}

View File

@@ -8,11 +8,20 @@ const { pathToFileURL } = require('node:url')
const {
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
resolveDirectoryForIpc,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
} = require('./hardening.cjs')
async function rejectsWithCode(promise, code) {
await assert.rejects(promise, error => {
assert.equal(error?.code, code)
return true
})
}
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
@@ -51,6 +60,52 @@ test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
})
test('path helpers reject blank non-string NUL and Windows device syntax', async () => {
await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path')
const devicePaths = [
'\\\\?\\C:\\secret.txt',
'\\\\.\\C:\\secret.txt',
'\\\\?\\UNC\\server\\share\\secret.txt',
'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt'
]
for (const devicePath of devicePaths) {
assert.throws(
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
error => {
assert.equal(error?.code, 'device-path')
return true
}
)
await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path')
}
assert.throws(
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
error => {
assert.equal(error?.code, 'invalid-path')
return true
}
)
await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path')
})
test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => {
const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base')
assert.equal(
resolveRequestedPathForIpc('notes.txt', {
baseDir: ` ${baseDir} `,
purpose: 'File preview'
}),
path.resolve(baseDir, 'notes.txt')
)
})
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
@@ -71,6 +126,13 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
})
assert.equal(fromFileUrl.resolvedPath, textPath)
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
await assert.rejects(
resolveReadableFileForIpc('missing.txt', {
baseDir: tempDir,
@@ -114,3 +176,91 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
})
assert.equal(envTemplate.resolvedPath, envTemplatePath)
})
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const sshDir = path.join(tempDir, '.ssh')
fs.mkdirSync(sshDir)
const blockedFiles = [
path.join(tempDir, '.env'),
path.join(tempDir, '.npmrc'),
path.join(sshDir, 'id_ed25519'),
path.join(tempDir, 'cert.pem'),
path.join(tempDir, 'cert.p12'),
path.join(tempDir, 'cert.pfx')
]
for (const filePath of blockedFiles) {
fs.writeFileSync(filePath, 'secret', 'utf8')
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
}
const allowed = path.join(tempDir, '.env.example')
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
})
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const envPath = path.join(tempDir, '.env')
const linkPath = path.join(tempDir, 'safe-name.txt')
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
try {
fs.symlinkSync(envPath, linkPath, 'file')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
})
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const directory = path.join(tempDir, 'project')
const filePath = path.join(tempDir, 'file.txt')
fs.mkdirSync(directory)
fs.writeFileSync(filePath, 'not a directory', 'utf8')
const resolved = await resolveDirectoryForIpc(directory)
assert.equal(resolved.resolvedPath, directory)
assert.equal(resolved.stat.isDirectory(), true)
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
})
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const directory = path.join(tempDir, 'actual-project')
const linkPath = path.join(tempDir, 'linked-project')
fs.mkdirSync(directory)
try {
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
const resolved = await resolveDirectoryForIpc(linkPath)
assert.equal(resolved.resolvedPath, linkPath)
assert.equal(resolved.stat.isDirectory(), true)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
'use strict'
/**
* In-process port reservation pool for the desktop backend launcher.
*
* pickPort() probes a localhost port with a throwaway server and closes it
* before the real bind happens in a separate Python child. Between that probe
* and the child's bind there is a TOCTOU window: a second concurrent spawn
* (the primary backend racing a pool backend) can be handed the SAME port, and
* one then dies with EADDRINUSE ("address already in use" -> "Object has been
* destroyed" boot loop). Reserving the chosen port in THIS process until the
* child exits closes that window.
*
* The OS bind remains the source of truth; this only deconflicts racers inside
* this process — it can't stop a foreign squatter, which the probe + the
* EADDRINUSE self-heal still cover.
*
* The pool is dependency-injected (the availability probe is passed in) and
* free of Electron/Node socket I/O, so it is unit-tested without real sockets
* (see port-pool.test.cjs).
*/
class PortPool {
/**
* @param {number} floor inclusive lowest port to hand out
* @param {number} ceiling inclusive highest port to hand out
*/
constructor(floor, ceiling) {
this.floor = floor
this.ceiling = ceiling
this._reserved = new Set()
}
/** @returns {boolean} whether `port` is currently reserved in-process. */
has(port) {
return this._reserved.has(port)
}
/** Release a previously reserved port. No-op if it was not reserved. */
release(port) {
this._reserved.delete(port)
}
/** Drop all reservations. */
clear() {
this._reserved.clear()
}
/** @returns {number} count of currently reserved ports. */
get size() {
return this._reserved.size
}
/**
* Reserve and return the lowest port in [floor, ceiling] that is neither
* already reserved in-process nor rejected by `isAvailable(port)`, or null
* if every port is taken. `isAvailable` may be sync (boolean) or async
* (Promise<boolean>); it is awaited either way.
*
* @param {(port: number) => boolean | Promise<boolean>} isAvailable
* @returns {Promise<number|null>}
*/
async reserve(isAvailable) {
for (let port = this.floor; port <= this.ceiling; port += 1) {
if (this._reserved.has(port)) continue
if (!(await isAvailable(port))) continue
this._reserved.add(port)
return port
}
return null
}
}
module.exports = { PortPool }

View File

@@ -0,0 +1,77 @@
/**
* Tests for electron/port-pool.cjs.
*
* Run with: node --test electron/port-pool.test.cjs
*
* PortPool is the in-process reservation that closes the pickPort() TOCTOU
* window. These cover selection order, skipping reserved/unavailable ports,
* release/reuse, exhaustion, and async probes — without real sockets.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { PortPool } = require('./port-pool.cjs')
const allFree = () => true
test('reserve returns the lowest free port and reserves it', async () => {
const pool = new PortPool(9120, 9199)
const port = await pool.reserve(allFree)
assert.equal(port, 9120)
assert.ok(pool.has(9120))
assert.equal(pool.size, 1)
})
test('reserve skips ports already reserved in-process', async () => {
const pool = new PortPool(9120, 9199)
const first = await pool.reserve(allFree)
const second = await pool.reserve(allFree)
assert.equal(first, 9120)
assert.equal(second, 9121)
})
test('reserve skips ports the probe rejects', async () => {
const pool = new PortPool(9120, 9199)
const busy = new Set([9120, 9121])
const port = await pool.reserve(p => !busy.has(p))
assert.equal(port, 9122)
})
test('reserve returns null when every port is taken', async () => {
const pool = new PortPool(9120, 9121)
await pool.reserve(allFree)
await pool.reserve(allFree)
assert.equal(await pool.reserve(allFree), null)
})
test('release frees a reserved port for reuse', async () => {
const pool = new PortPool(9120, 9120)
assert.equal(await pool.reserve(allFree), 9120)
assert.equal(await pool.reserve(allFree), null) // exhausted
pool.release(9120)
assert.ok(!pool.has(9120))
assert.equal(await pool.reserve(allFree), 9120) // reusable
})
test('release is a no-op for an unreserved port', () => {
const pool = new PortPool(9120, 9199)
pool.release(9120)
assert.equal(pool.size, 0)
})
test('reserve awaits an async probe', async () => {
const pool = new PortPool(9120, 9199)
const busy = new Set([9120])
const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
assert.equal(port, 9121)
})
test('clear drops all reservations', async () => {
const pool = new PortPool(9120, 9199)
await pool.reserve(allFree)
await pool.reserve(allFree)
assert.equal(pool.size, 2)
pool.clear()
assert.equal(pool.size, 0)
})

View File

@@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId),
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),
@@ -41,6 +42,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd),
settings: {
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
@@ -78,6 +80,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:open-updates', listener)
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
},
onDeepLink: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:deep-link', listener)
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
},
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
onWindowStateChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:window-state-changed', listener)
@@ -132,5 +140,9 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:updates:progress', listener)
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
}
},
themes: {
fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id),
searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query)
}
})

View File

@@ -0,0 +1,86 @@
// Secondary "session windows" — one extra OS window per chat so a user can
// work with multiple chats side by side. The pure, Electron-free pieces live
// here so they can be unit-tested with node --test (mirroring how the rest of
// electron/*.cjs splits testable logic out of the main.cjs monolith).
const { pathToFileURL } = require('node:url')
// Build the renderer URL for a secondary window. The renderer uses a
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
// 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.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) {
const route = `#/${encodeURIComponent(sessionId)}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
return `${base}/?win=secondary${route}`
}
return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}`
}
// A small registry keyed by sessionId that guarantees one window per chat:
// opening a session that already has a live window focuses it instead of
// spawning a duplicate, and a window removes itself from the registry when it
// closes. The actual BrowserWindow construction is injected (the `factory`) so
// this module stays free of Electron and is unit-testable.
function createSessionWindowRegistry() {
const windows = new Map()
function openOrFocus(sessionId, factory) {
const key = typeof sessionId === 'string' ? sessionId.trim() : ''
if (!key) {
return null
}
const existing = windows.get(key)
if (existing && !existing.isDestroyed()) {
// Focus-or-create: never duplicate a window for the same chat.
if (typeof existing.isMinimized === 'function' && existing.isMinimized()) {
existing.restore?.()
}
if (typeof existing.isVisible === 'function' && !existing.isVisible()) {
existing.show?.()
}
existing.focus?.()
return existing
}
const win = factory(key)
if (!win) {
return null
}
windows.set(key, win)
// Self-cleanup on close so the registry never holds a destroyed window.
win.on?.('closed', () => {
if (windows.get(key) === win) {
windows.delete(key)
}
})
return win
}
return {
openOrFocus,
get: key => windows.get(key),
has: key => windows.has(key),
get size() {
return windows.size
}
}
}
module.exports = { buildSessionWindowUrl, createSessionWindowRegistry }

View File

@@ -0,0 +1,165 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// test fire the 'closed' event, mirroring the slice of the Electron API the
// registry actually touches.
function makeFakeWindow() {
const listeners = {}
const calls = { focus: 0, show: 0, restore: 0 }
let destroyed = false
let minimized = false
let visible = true
return {
on(event, handler) {
listeners[event] = handler
return this
},
emit(event) {
listeners[event]?.()
},
isDestroyed: () => destroyed,
destroy() {
destroyed = true
},
isMinimized: () => minimized,
setMinimized(value) {
minimized = value
},
isVisible: () => visible,
setVisible(value) {
visible = value
},
restore() {
calls.restore += 1
minimized = false
},
show() {
calls.show += 1
visible = true
},
focus() {
calls.focus += 1
},
calls
}
}
test('buildSessionWindowUrl puts the secondary flag before the hash route (dev server)', () => {
const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173' })
assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
})
test('buildSessionWindowUrl avoids a double slash when the dev server has a trailing slash', () => {
const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173/' })
assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
})
test('buildSessionWindowUrl encodes the session id in the hash route', () => {
const url = buildSessionWindowUrl('a b/c', { devServer: 'http://localhost:5173' })
// The query flag must precede the '#' or HashRouter would swallow it as the
// route; the id is URL-encoded so slashes/spaces survive routeSessionId().
assert.equal(url, 'http://localhost:5173/?win=secondary#/a%20b%2Fc')
assert.ok(url.indexOf('?win=secondary') < url.indexOf('#'))
})
test('buildSessionWindowUrl builds a packaged file URL with the flag before the hash', () => {
const url = buildSessionWindowUrl('abc', { rendererIndexPath: '/opt/app/index.html' })
assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/)
})
test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry()
let built = 0
const win = makeFakeWindow()
const factory = () => {
built += 1
return win
}
const first = registry.openOrFocus('s1', factory)
const second = registry.openOrFocus('s1', factory)
assert.equal(built, 1, 'factory runs once for the same session')
assert.equal(first, second)
assert.equal(registry.size, 1)
assert.equal(win.calls.focus, 1, 'second open focuses the existing window')
})
test('registry restores + shows a minimized/hidden window on re-open', () => {
const registry = createSessionWindowRegistry()
const win = makeFakeWindow()
registry.openOrFocus('s1', () => win)
win.setMinimized(true)
win.setVisible(false)
registry.openOrFocus('s1', () => win)
assert.equal(win.calls.restore, 1)
assert.equal(win.calls.show, 1)
assert.equal(win.calls.focus, 1)
})
test('registry drops the entry when the window closes', () => {
const registry = createSessionWindowRegistry()
const win = makeFakeWindow()
registry.openOrFocus('s1', () => win)
assert.equal(registry.size, 1)
win.emit('closed')
assert.equal(registry.size, 0)
assert.equal(registry.has('s1'), false)
})
test('registry rebuilds a fresh window after the previous one was destroyed', () => {
const registry = createSessionWindowRegistry()
const first = makeFakeWindow()
registry.openOrFocus('s1', () => first)
first.destroy()
let built = 0
const second = makeFakeWindow()
const result = registry.openOrFocus('s1', () => {
built += 1
return second
})
assert.equal(built, 1, 'a destroyed window is replaced, not focused')
assert.equal(result, second)
})
test('registry ignores empty / non-string session ids', () => {
const registry = createSessionWindowRegistry()
let built = 0
const factory = () => {
built += 1
return makeFakeWindow()
}
assert.equal(registry.openOrFocus('', factory), null)
assert.equal(registry.openOrFocus(' ', factory), null)
assert.equal(registry.openOrFocus(null, factory), null)
assert.equal(registry.openOrFocus(42, factory), null)
assert.equal(built, 0)
assert.equal(registry.size, 0)
})
test('registry trims the session id before keying', () => {
const registry = createSessionWindowRegistry()
const win = makeFakeWindow()
registry.openOrFocus(' s1 ', () => win)
assert.equal(registry.has('s1'), true)
})

View File

@@ -0,0 +1,56 @@
/**
* Pure helpers for choosing a remote URL during passive update checks.
*
* A public install can end up with `origin=git@github.com:NousResearch/hermes-agent.git`.
* If the user's GitHub SSH key is FIDO2/passkey-backed, a background `git fetch
* origin` triggers an unexplained hardware-touch prompt. For passive checks
* against the official repo we substitute the public HTTPS `ls-remote` path,
* which needs no auth and cannot prompt. Active update/apply flows are left
* unchanged.
*
* Extracted from main.cjs so the security-critical remote detection is unit
* testable without booting Electron (main.cjs requires('electron') at load).
*/
const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git'
const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent'
// Normalize common GitHub remote URL forms to `host/owner/repo` (lowercased,
// no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo
// compare equal.
function canonicalGitHubRemote(url) {
if (!url) return ''
let value = String(url).trim()
if (value.startsWith('git@github.com:')) {
value = `github.com/${value.slice('git@github.com:'.length)}`
} else if (value.startsWith('ssh://git@github.com/')) {
value = `github.com/${value.slice('ssh://git@github.com/'.length)}`
} else {
try {
const parsed = new URL(value)
if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}`
} catch {
// Leave non-URL forms unchanged.
}
}
value = value.trim().replace(/\/+$/, '')
if (value.endsWith('.git')) value = value.slice(0, -4)
return value.toLowerCase()
}
function isSshRemote(url) {
const value = String(url || '').trim().toLowerCase()
return value.startsWith('git@') || value.startsWith('ssh://')
}
function isOfficialSshRemote(url) {
return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL
}
module.exports = {
OFFICIAL_REPO_HTTPS_URL,
OFFICIAL_REPO_CANONICAL,
canonicalGitHubRemote,
isSshRemote,
isOfficialSshRemote
}

View File

@@ -0,0 +1,78 @@
/**
* Tests for electron/update-remote.cjs — the remote-detection helpers that
* keep passive update checks off the SSH origin for official installs.
*
* Run with: node --test electron/update-remote.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Why this matters: a public install can carry
* origin=git@github.com:NousResearch/hermes-agent.git. A background
* `git fetch origin` then authenticates over SSH and, with a FIDO2/passkey
* key, triggers an unexplained hardware-touch prompt. isOfficialSshRemote
* must reliably recognize the official SSH remote (in every URL form,
* case-insensitively) so the caller can swap in the anonymous HTTPS path —
* while NOT misclassifying forks, other hosts, or the HTTPS remote (which
* never prompts and should keep the normal fetch path).
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
OFFICIAL_REPO_HTTPS_URL,
OFFICIAL_REPO_CANONICAL,
canonicalGitHubRemote,
isSshRemote,
isOfficialSshRemote
} = require('./update-remote.cjs')
test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => {
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent'), OFFICIAL_REPO_CANONICAL)
assert.equal(canonicalGitHubRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
// Case-insensitive: an uppercased owner still canonicalizes to the same repo.
assert.equal(canonicalGitHubRemote('git@github.com:nousresearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
// Trailing slashes are stripped.
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent/'), OFFICIAL_REPO_CANONICAL)
})
test('canonicalGitHubRemote is empty for falsy input', () => {
assert.equal(canonicalGitHubRemote(''), '')
assert.equal(canonicalGitHubRemote(null), '')
assert.equal(canonicalGitHubRemote(undefined), '')
})
test('isSshRemote detects scp-like and ssh:// forms only', () => {
assert.equal(isSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
assert.equal(isSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
assert.equal(isSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
assert.equal(isSshRemote(''), false)
assert.equal(isSshRemote(null), false)
})
test('isOfficialSshRemote is true only for the official repo over SSH', () => {
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent'), true)
assert.equal(isOfficialSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
// Case-insensitive owner/repo match.
assert.equal(isOfficialSshRemote('git@github.com:nousresearch/hermes-agent.git'), true)
})
test('isOfficialSshRemote does NOT match forks, other hosts, or HTTPS', () => {
// A fork over SSH belongs to the user — fetching it is their own remote,
// not the official upstream, so the SSH-avoidance swap must not apply.
assert.equal(isOfficialSshRemote('git@github.com:someuser/hermes-agent.git'), false)
// Same repo name on a different host is not the official repo.
assert.equal(isOfficialSshRemote('git@gitlab.com:NousResearch/hermes-agent.git'), false)
// HTTPS to the official repo never prompts for SSH/FIDO2, so it keeps the
// normal fetch path — must not be flagged as an official SSH remote.
assert.equal(isOfficialSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
assert.equal(isOfficialSshRemote(''), false)
assert.equal(isOfficialSshRemote(null), false)
})
test('OFFICIAL_REPO_HTTPS_URL canonicalizes to OFFICIAL_REPO_CANONICAL', () => {
// Invariant: the URL we substitute in must be the same repo we detect.
assert.equal(canonicalGitHubRemote(OFFICIAL_REPO_HTTPS_URL), OFFICIAL_REPO_CANONICAL)
})

View File

@@ -0,0 +1,331 @@
'use strict'
/**
* VS Code Marketplace color-theme fetcher (main process).
*
* Resolves an extension's latest version via the (undocumented but stable)
* gallery ExtensionQuery API, downloads the `.vsix` (a zip), and extracts the
* color-theme JSON files it contributes. No theme code is ever executed — we
* only read `package.json` + the referenced `*.json` theme files out of the
* archive and hand their text back to the renderer to convert.
*
* Dependency-free on purpose: a `.vsix` is a plain zip, so we parse the central
* directory and inflate just the entries we need with `zlib`. Avoids pulling a
* zip library into the desktop bundle for a feature this small.
*/
const https = require('node:https')
const zlib = require('node:zlib')
const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'
const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage'
const MAX_VSIX_BYTES = 40 * 1024 * 1024 // 40 MB — themes are tiny; this is paranoia.
const MAX_REDIRECTS = 5
const REQUEST_TIMEOUT_MS = 20_000
const ID_RE = /^[\w-]+\.[\w-]+$/
/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */
function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
const req = https.request(url, { method, headers }, res => {
const status = res.statusCode ?? 0
if (status >= 300 && status < 400 && res.headers.location) {
if (redirectsLeft <= 0) {
res.resume()
reject(new Error('Too many redirects.'))
return
}
const next = new URL(res.headers.location, url).toString()
res.resume()
// Redirects to the CDN are plain GETs (drop the POST body).
resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))
return
}
if (status < 200 || status >= 300) {
res.resume()
reject(new Error(`Request failed (${status}) for ${url}`))
return
}
const chunks = []
let total = 0
res.on('data', chunk => {
total += chunk.length
if (total > maxBytes) {
req.destroy()
reject(new Error('Response exceeded the size limit.'))
return
}
chunks.push(chunk)
})
res.on('end', () => resolve(Buffer.concat(chunks)))
})
req.on('error', reject)
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out.')))
if (body) {
req.write(body)
}
req.end()
})
}
/** Resolve `{ displayName, vsixUrl }` for the latest version of `id`. */
async function resolveExtension(id) {
const json = await queryGallery({
// FilterType 7 = ExtensionName (the full publisher.extension id).
filters: [{ criteria: [{ filterType: 7, value: id }], pageNumber: 1, pageSize: 1 }],
// Flags: IncludeFiles | IncludeVersionProperties | IncludeAssetUri |
// IncludeCategoryAndTags | IncludeLatestVersionOnly = 914.
flags: 914
})
const extension = json?.results?.[0]?.extensions?.[0]
if (!extension) {
throw new Error(`Extension "${id}" was not found on the Marketplace.`)
}
const version = extension.versions?.[0]
if (!version) {
throw new Error(`Extension "${id}" has no published versions.`)
}
const asset = (version.files ?? []).find(file => file.assetType === VSIX_ASSET_TYPE)
const vsixUrl = asset?.source
if (!vsixUrl) {
throw new Error(`Could not find a downloadable package for "${id}".`)
}
return { displayName: extension.displayName || id, vsixUrl }
}
/** POST an ExtensionQuery payload and return the parsed gallery response. */
async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) {
const body = JSON.stringify(payload)
const raw = await request(GALLERY_QUERY_URL, {
method: 'POST',
headers: {
Accept: 'application/json;api-version=3.0-preview.1',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'User-Agent': 'Hermes-Desktop'
},
body,
maxBytes
})
return JSON.parse(raw.toString('utf8'))
}
/**
* Search the Marketplace for color-theme extensions. With an empty query this
* returns the most-installed themes; with a query it's a full-text search
* scoped to the Themes category. Returns lightweight cards (no download).
*/
/**
* The "Themes" category also contains file-icon and product-icon themes (the
* gallery has no color-only category). We can't see an extension's actual
* contributions without downloading it, so filter the obvious icon packs out by
* tag + name/description. Color themes that also ship icons are rare; worst case
* a user installs them by exact id from settings.
*/
function looksLikeIconTheme(extension) {
const tags = (extension.tags ?? []).map(tag => String(tag).toLowerCase())
if (tags.includes('icon-theme') || tags.includes('product-icon-theme')) {
return true
}
const text = `${extension.displayName ?? ''} ${extension.shortDescription ?? ''}`.toLowerCase()
return /\b(icon theme|file icons?|product icons?|icon pack|fileicons)\b/.test(text)
}
async function searchMarketplaceThemes(query, limit = 20) {
const text = String(query || '').trim()
const pageSize = Math.min(Math.max(Number(limit) || 20, 1), 50)
// FilterType: 8=Target, 5=Category, 10=SearchText, 12=ExcludeWithFlags.
const criteria = [
{ filterType: 8, value: 'Microsoft.VisualStudio.Code' },
{ filterType: 5, value: 'Themes' },
{ filterType: 12, value: '4096' } // Exclude unpublished (Unpublished = 0x1000).
]
if (text) {
criteria.push({ filterType: 10, value: text })
}
const json = await queryGallery({
// Over-fetch so the icon-theme filter below still leaves a full page.
filters: [{ criteria, pageNumber: 1, pageSize: Math.min(pageSize * 2, 50), sortBy: 4, sortOrder: 0 }],
// IncludeStatistics (0x100) | IncludeLatestVersionOnly (0x200) | IncludeCategoryAndTags (0x4).
flags: 772
})
const extensions = json?.results?.[0]?.extensions ?? []
return extensions
.filter(extension => !looksLikeIconTheme(extension))
.slice(0, pageSize)
.map(extension => {
const publisherName = extension.publisher?.publisherName ?? ''
const installStat = (extension.statistics ?? []).find(stat => stat.statisticName === 'install')
return {
extensionId: `${publisherName}.${extension.extensionName}`,
displayName: extension.displayName || extension.extensionName,
publisher: extension.publisher?.displayName || publisherName,
description: extension.shortDescription || '',
installs: Math.round(installStat?.value ?? 0)
}
})
}
// ─── Minimal zip reader ─────────────────────────────────────────────────────
function findEndOfCentralDirectory(buf) {
// EOCD signature 0x06054b50, scanning back from the end (comment is rare).
for (let i = buf.length - 22; i >= 0; i--) {
if (buf.readUInt32LE(i) === 0x06054b50) {
return i
}
}
throw new Error('Not a valid zip archive (no end-of-central-directory).')
}
/** Parse the central directory into a name → record map. */
function readCentralDirectory(buf) {
const eocd = findEndOfCentralDirectory(buf)
const count = buf.readUInt16LE(eocd + 10)
let offset = buf.readUInt32LE(eocd + 16)
const records = new Map()
for (let i = 0; i < count; i++) {
if (buf.readUInt32LE(offset) !== 0x02014b50) {
break
}
const method = buf.readUInt16LE(offset + 10)
const compressedSize = buf.readUInt32LE(offset + 20)
const nameLen = buf.readUInt16LE(offset + 28)
const extraLen = buf.readUInt16LE(offset + 30)
const commentLen = buf.readUInt16LE(offset + 32)
const localOffset = buf.readUInt32LE(offset + 42)
const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen)
records.set(name, { method, compressedSize, localOffset })
offset += 46 + nameLen + extraLen + commentLen
}
return records
}
/** Inflate a single entry to a string. */
function extractEntry(buf, record) {
// The local header's name/extra lengths can differ from the central record,
// so re-read them here to locate the compressed payload.
if (buf.readUInt32LE(record.localOffset) !== 0x04034b50) {
throw new Error('Corrupt zip: bad local file header.')
}
const nameLen = buf.readUInt16LE(record.localOffset + 26)
const extraLen = buf.readUInt16LE(record.localOffset + 28)
const dataStart = record.localOffset + 30 + nameLen + extraLen
const data = buf.subarray(dataStart, dataStart + record.compressedSize)
// 0 = stored, 8 = deflate. Theme files are one or the other.
return record.method === 0 ? data.toString('utf8') : zlib.inflateRawSync(data).toString('utf8')
}
/** Normalize a package.json theme path to its zip entry name. */
function themeEntryName(themePath) {
const clean = String(themePath).replace(/^\.\//, '').replace(/^\//, '')
return `extension/${clean}`
}
/** Extract every contributed color theme from a `.vsix` buffer. */
function extractThemes(vsixBuffer) {
const records = readCentralDirectory(vsixBuffer)
const pkgRecord = records.get('extension/package.json')
if (!pkgRecord) {
throw new Error('Package manifest missing from the extension.')
}
const pkg = JSON.parse(extractEntry(vsixBuffer, pkgRecord))
const contributed = pkg?.contributes?.themes
if (!Array.isArray(contributed) || contributed.length === 0) {
return []
}
const themes = []
for (const entry of contributed) {
if (!entry?.path) {
continue
}
const record = records.get(themeEntryName(entry.path))
if (!record) {
continue
}
try {
themes.push({
label: entry.label || entry.id || pkg.displayName || pkg.name || 'VS Code Theme',
uiTheme: entry.uiTheme,
contents: extractEntry(vsixBuffer, record)
})
} catch {
// Skip an entry we can't inflate rather than failing the whole install.
}
}
return themes
}
/**
* Public entry: resolve, download, and extract color themes for `id`
* (`publisher.extension`). Returns `{ extensionId, displayName, themes }`.
*/
async function fetchMarketplaceThemes(id) {
const trimmed = String(id || '').trim()
if (!ID_RE.test(trimmed)) {
throw new Error('Expected a Marketplace id like "publisher.extension".')
}
const { displayName, vsixUrl } = await resolveExtension(trimmed)
const vsix = await request(vsixUrl, { headers: { 'User-Agent': 'Hermes-Desktop' } })
const themes = extractThemes(vsix)
return { extensionId: trimmed, displayName, themes }
}
module.exports = {
fetchMarketplaceThemes,
searchMarketplaceThemes,
extractThemes,
readCentralDirectory,
__testing: { themeEntryName, looksLikeIconTheme }
}

View File

@@ -0,0 +1,113 @@
'use strict'
const assert = require('node:assert')
const test = require('node:test')
const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs')
// Build a minimal zip with stored (uncompressed) entries so the test controls
// the bytes exactly — exercises the central-directory reader + theme extraction
// without a deflate dependency.
function makeZip(entries) {
const locals = []
const centrals = []
let offset = 0
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, 'utf8')
const body = Buffer.from(data, 'utf8')
const local = Buffer.alloc(30 + nameBuf.length)
local.writeUInt32LE(0x04034b50, 0)
local.writeUInt16LE(0, 8) // method: stored
local.writeUInt32LE(body.length, 18) // compressed size
local.writeUInt32LE(body.length, 22) // uncompressed size
local.writeUInt16LE(nameBuf.length, 26)
nameBuf.copy(local, 30)
locals.push(local, body)
const central = Buffer.alloc(46 + nameBuf.length)
central.writeUInt32LE(0x02014b50, 0)
central.writeUInt16LE(0, 10) // method: stored
central.writeUInt32LE(body.length, 20)
central.writeUInt32LE(body.length, 24)
central.writeUInt16LE(nameBuf.length, 28)
central.writeUInt32LE(offset, 42) // local header offset
nameBuf.copy(central, 46)
centrals.push(central)
offset += local.length + body.length
}
const centralStart = offset
const centralBuf = Buffer.concat(centrals)
const eocd = Buffer.alloc(22)
eocd.writeUInt32LE(0x06054b50, 0)
eocd.writeUInt16LE(entries.length, 8)
eocd.writeUInt16LE(entries.length, 10)
eocd.writeUInt32LE(centralBuf.length, 12)
eocd.writeUInt32LE(centralStart, 16)
return Buffer.concat([...locals, centralBuf, eocd])
}
test('readCentralDirectory finds every entry', () => {
const zip = makeZip([
{ name: 'extension/package.json', data: '{}' },
{ name: 'extension/themes/x.json', data: '{}' }
])
const records = readCentralDirectory(zip)
assert.ok(records.has('extension/package.json'))
assert.ok(records.has('extension/themes/x.json'))
})
test('extractThemes reads contributed color themes (resolving ./ paths)', () => {
const pkg = JSON.stringify({
name: 'theme-dracula',
displayName: 'Dracula',
contributes: {
themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }]
}
})
const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } })
const zip = makeZip([
{ name: 'extension/package.json', data: pkg },
{ name: 'extension/themes/dracula.json', data: themeJson }
])
const themes = extractThemes(zip)
assert.strictEqual(themes.length, 1)
assert.strictEqual(themes[0].label, 'Dracula')
assert.strictEqual(themes[0].uiTheme, 'vs-dark')
assert.match(themes[0].contents, /editor\.background/)
})
test('extractThemes returns empty when the extension contributes no themes', () => {
const zip = makeZip([{ name: 'extension/package.json', data: JSON.stringify({ name: 'x', contributes: {} }) }])
assert.deepStrictEqual(extractThemes(zip), [])
})
test('extractThemes throws when the manifest is missing', () => {
const zip = makeZip([{ name: 'extension/other.txt', data: 'hi' }])
assert.throws(() => extractThemes(zip), /manifest missing/i)
})
test('looksLikeIconTheme filters icon/product-icon packs out of theme search', () => {
const { looksLikeIconTheme } = __testing
// Tagged contribution points are the strongest signal.
assert.strictEqual(looksLikeIconTheme({ tags: ['theme', 'icon-theme'] }), true)
assert.strictEqual(looksLikeIconTheme({ tags: ['product-icon-theme'] }), true)
// Name/description fallback for packs that don't tag themselves.
assert.strictEqual(looksLikeIconTheme({ displayName: 'Material Icon Theme' }), true)
assert.strictEqual(looksLikeIconTheme({ shortDescription: 'A pack of file icons.' }), true)
// Real color themes survive.
assert.strictEqual(looksLikeIconTheme({ displayName: 'Dracula Official', tags: ['theme', 'color-theme'] }), false)
assert.strictEqual(looksLikeIconTheme({ displayName: 'One Dark Pro' }), false)
})

View File

@@ -0,0 +1,54 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
}
function requireHiddenChildOptions(source, needle) {
const index = source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(
snippet,
/hiddenWindowsChildOptions\(/,
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
)
}
test('desktop background child processes opt into hidden Windows consoles', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
requireHiddenChildOptions(source, 'execFileSync(pyExe')
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
requireHiddenChildOptions(source, "execFileSync('taskkill'")
requireHiddenChildOptions(source, 'spawn(command, args')
requireHiddenChildOptions(source, "spawn('curl'")
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
})
test('intentional or interactive desktop child processes stay documented', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /windowsHide: false/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})
test('bootstrap PowerShell runner hides Windows console children', () => {
const source = readElectronFile('bootstrap-runner.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
})

View File

@@ -0,0 +1,38 @@
const path = require('node:path')
/** True when `dir` lives inside a packaged app bundle / install tree. */
function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
if (!isPackaged || !dir) {
return false
}
let resolved
try {
resolved = path.resolve(String(dir))
} catch {
return false
}
const roots = new Set(
(installRoots ?? [])
.filter(Boolean)
.map(candidate => path.resolve(String(candidate)))
)
for (const root of roots) {
if (resolved === root) {
return true
}
const rel = path.relative(root, resolved)
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
return true
}
}
return false
}
module.exports = { isPackagedInstallPath }

View File

@@ -0,0 +1,45 @@
/**
* Tests for electron/workspace-cwd.cjs.
*
* Run with: node --test electron/workspace-cwd.test.cjs
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')
const { isPackagedInstallPath } = require('./workspace-cwd.cjs')
const installRoot = path.resolve('/opt/Hermes')
test('isPackagedInstallPath returns false when not packaged', () => {
assert.equal(
isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }),
false
)
})
test('isPackagedInstallPath flags the install root itself', () => {
assert.equal(
isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }),
true
)
})
test('isPackagedInstallPath flags paths nested under the install root', () => {
const nested = path.join(installRoot, 'resources', 'app.asar')
assert.equal(
isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }),
true
)
})
test('isPackagedInstallPath ignores paths outside the install root', () => {
const homeProject = path.resolve('/home/user/projects/demo')
assert.equal(
isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }),
false
)
})

View File

@@ -3,7 +3,6 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'
import perfectionist from 'eslint-plugin-perfectionist'
import reactPlugin from 'eslint-plugin-react'
import reactCompiler from 'eslint-plugin-react-compiler'
import hooksPlugin from 'eslint-plugin-react-hooks'
import unusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals'
@@ -47,7 +46,6 @@ export default [
'custom-rules': customRules,
perfectionist,
react: reactPlugin,
'react-compiler': reactCompiler,
'react-hooks': hooksPlugin,
'unused-imports': unusedImports
},
@@ -98,7 +96,6 @@ export default [
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
'react-compiler/react-compiler': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/rules-of-hooks': 'error',
'unused-imports/no-unused-imports': 'error'

View File

@@ -18,7 +18,8 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"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 && node scripts/assert-dist-built.cjs",
"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",
"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",
@@ -35,8 +36,8 @@
"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-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs",
"type-check": "tsc -b",
"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/port-pool.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",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
@@ -72,6 +73,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dnd-core": "^14.0.1",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
@@ -83,6 +85,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dnd-html5-backend": "^14.0.3",
"react-dom": "^19.2.5",
"react-router-dom": "^7.17.0",
"react-shiki": "^0.9.3",
@@ -103,20 +106,19 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
"@types/node": "^24.12.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"electron": "^40.9.3",
"electron-builder": "^26.8.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^16.5.0",
@@ -133,6 +135,14 @@
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",
"protocols": [
{
"name": "Hermes Protocol",
"schemes": [
"hermes"
]
}
],
"artifactName": "Hermes-${version}-${os}-${arch}.${ext}",
"icon": "assets/icon",
"directories": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 528 KiB

View File

@@ -18,7 +18,7 @@ import {
} from '@/components/ui/pagination'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listSessions } from '@/hermes'
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
@@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setRefreshing(true)
try {
const sessions = (await listSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
const sessions = (await listAllProfileSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
const nextArtifacts: ArtifactRecord[] = []
results.forEach((result, index) => {

View File

@@ -3,8 +3,9 @@ import { useStore } from '@nanostores/react'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
@@ -31,7 +32,9 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const c = t.composer
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
const isUploading = attachment.uploadState === 'uploading'
const hasUploadError = attachment.uploadState === 'error'
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
async function openPreview() {
@@ -59,7 +62,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
throw new Error(c.couldNotPreview(attachment.label))
}
setCurrentSessionPreviewTarget(preview, 'manual', target)
// We already hold the image bytes (the card thumbnail) — render those
// directly so a screenshot/clipboard image previews even when its only
// on-disk copy is a transient path the renderer can't re-read.
const withBytes =
attachment.kind === 'image' && attachment.previewUrl
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
: preview
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
} catch (error) {
notifyError(error, c.previewUnavailable)
}
@@ -69,30 +80,51 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<button
aria-busy={isUploading || undefined}
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
className={cn(
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
hasUploadError
? 'border-destructive/45 hover:border-destructive/60'
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
)}
disabled={!canPreview}
onClick={() => void openPreview()}
type="button"
>
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-full object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<Icon className="size-3.5" />
</span>
)}
)}
{isUploading && (
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
</span>
)}
{hasUploadError && (
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
<AlertCircle className="size-3.5 text-destructive" />
</span>
)}
</span>
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
<span
className={cn(
'block truncate text-[0.62rem] leading-3.5',
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
)}
>
{detail}
</span>
)}

View File

@@ -3,32 +3,25 @@ import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
export const COMPLETION_DRAWER_CLASS = [
'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
'w-60 max-w-[calc(100vw-2rem)]',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-lg border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-md',
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
'w-80 max-w-[calc(100vw-2rem)]',
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-xl border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-lg',
'backdrop-blur-md'
].join(' ')
export const COMPLETION_DRAWER_BELOW_CLASS = [
'absolute left-0 top-[calc(100%+0.25rem)] z-50',
'w-60 max-w-[calc(100vw-2rem)]',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-lg border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-md',
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
'w-80 max-w-[calc(100vw-2rem)]',
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-xl border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-lg',
'backdrop-blur-md'
].join(' ')
export const COMPLETION_DRAWER_ROW_CLASS = [
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
'hover:bg-(--ui-bg-tertiary)',
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
].join(' ')
export function ComposerCompletionDrawer({
adapter,
ariaLabel,

View File

@@ -4,6 +4,7 @@ import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
@@ -62,6 +63,7 @@ export function ComposerControls({
}) {
const { t } = useI18n()
const c = t.composer
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
@@ -73,9 +75,9 @@ export function ComposerControls({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{canSteer && (
<Tip label={c.steer}>
<Tip label={steerLabel}>
<Button
aria-label={c.steer}
aria-label={steerLabel}
className={GHOST_ICON_BTN}
disabled={disabled}
onClick={onSteer}

View File

@@ -0,0 +1,189 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
// No global setupFiles registers auto-cleanup, so unmount between tests —
// otherwise a second render() leaks the first editor and getByTestId('editor')
// matches multiple nodes.
afterEach(cleanup)
// Faithful mirror of index.tsx's Enter wiring (handleEditorKeyDown's Enter
// branch + submitDraft), driven through REAL DOM keydown events on a
// contentEditable.
//
// Regression repro for #39630: pressing Enter right after typing (fast typing /
// IME) did nothing. The composer state (`draft` from useAuiState) and its
// derived `hasComposerPayload` lag the DOM by a render, so the keydown handler
// read empty state and either dropped the message, drained a queued prompt
// instead of sending, or (while busy) refused to queue. The fix reads the live
// editor text — `hasLivePayload` in the handler and a DOM re-sync at the top of
// submitDraft — so the just-typed text always wins.
//
// We model the race deterministically the way the IME repro does: mutate the
// editor's textContent WITHOUT firing an input event, so the React `draft`
// state stays stale while the DOM already holds the text.
function Harness({
busy = false,
queued = [],
onSubmit,
onQueue,
onCancel,
onDrain
}: {
busy?: boolean
queued?: readonly string[]
onSubmit: (text: string) => void
onQueue: (text: string) => void
onCancel: () => void
onDrain: () => void
}) {
const editorRef = useRef<HTMLDivElement>(null)
const draftRef = useRef('')
// Mirrors `useAuiState(s => s.composer.text)` — updated only via setText, so
// it lags the DOM until React re-renders (the source of the bug).
const [draft, setDraft] = useState('')
const attachments: unknown[] = []
const composerPlainText = (el: HTMLElement) => el.textContent ?? ''
const setText = (next: string) => {
draftRef.current = next
setDraft(next)
}
const submitDraft = () => {
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
setDraft(domText)
}
}
const text = draftRef.current
const payloadPresent = text.trim().length > 0 || attachments.length > 0
if (busy) {
if (payloadPresent) {
onQueue(text)
} else {
onCancel()
}
} else if (!payloadPresent && queued.length > 0) {
onDrain()
} else if (payloadPresent) {
onSubmit(text)
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (!busy && !hasLivePayload && queued.length > 0) {
onDrain()
return
}
if (busy && !hasLivePayload) {
return
}
submitDraft()
}
}
// `draft` is read so the lint/compiler treats the stale-state mirror as live;
// the assertions prove the handler never relies on it.
void draft
return (
<div
contentEditable
data-testid="editor"
onInput={event => setText(composerPlainText(event.currentTarget))}
onKeyDown={handleKeyDown}
ref={editorRef}
suppressContentEditableWarning
/>
)
}
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
// Fast typing: the DOM has the text but NO input event fired, so `draft`
// state is still empty (the exact stale-state race).
await act(async () => {
editor.textContent = 'hello world'
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onSubmit).toHaveBeenCalledWith('hello world')
})
it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {
const onQueue = vi.fn()
const onDrain = vi.fn()
const onCancel = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = 'urgent follow-up'
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onQueue).toHaveBeenCalledWith('urgent follow-up')
expect(onDrain).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
const onCancel = vi.fn()
const onSubmit = vi.fn()
const onQueue = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onCancel).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onQueue).not.toHaveBeenCalled()
})
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
const onDrain = vi.fn()
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onDrain).toHaveBeenCalledTimes(1)
expect(onSubmit).not.toHaveBeenCalled()
})
})

View File

@@ -5,6 +5,13 @@ export interface CompletionEntry {
text: string
display?: unknown
meta?: unknown
/** Optional section label (e.g. "Commands", "Skills"). The popover renders a
* header whenever this changes between consecutive items, so the fetcher must
* emit entries already grouped contiguously. */
group?: string
/** Optional completion-action id. When set, picking the item runs that action
* (e.g. opening an overlay) instead of inserting a chip + waiting for submit. */
action?: string
}
export interface CompletionPayload {

View File

@@ -2,12 +2,17 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
desktopSkinSlashCompletions,
desktopSlashDescription,
type DesktopThemeCommandOption,
filterDesktopCommandsCatalog,
isDesktopSlashExtensionCommand,
isDesktopSlashSuggestion
} from '@/lib/desktop-slash-commands'
import { $sessions } from '@/store/session'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
@@ -16,7 +21,10 @@ interface SlashItemMetadata extends Record<string, string> {
command: string
display: string
meta: string
group: string
rawText: string
/** Completion-action id; empty for ordinary insert-a-chip completions. */
action: string
}
function textValue(value: unknown, fallback = ''): string {
@@ -38,12 +46,21 @@ function commandText(value: string): string {
return value.startsWith('/') ? value : `/${value}`
}
/** How many recent sessions to surface inline before the "Browse all…" entry. */
const SESSION_INLINE_LIMIT = 7
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
export function useSlashCompletions(options: {
gateway: HermesGateway | null
/** Desktop theme list — `/skin` is owned client-side, so its arg completions
* come from here, not the backend (whose skin list is CLI/TUI-only). */
skinThemes?: DesktopThemeCommandOption[]
activeSkin?: string
}): {
adapter: Unstable_TriggerAdapter
loading: boolean
} {
const { gateway } = options
const { gateway, skinThemes, activeSkin } = options
const enabled = Boolean(gateway)
const fetcher = useCallback(
@@ -54,34 +71,136 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
const text = `/${query}`
// The desktop owns /skin entirely (client-side theme context). Surface its
// theme list inside this single popover instead of a bespoke one, and skip
// the backend skin completions (which describe CLI/TUI skins that don't
// apply here). Matches once we're past `/skin ` into the arg stage.
const skinArg = /^\/skin\s+(.*)$/is.exec(text)
if (skinArg && skinThemes) {
const items = desktopSkinSlashCompletions(skinThemes, activeSkin ?? '', skinArg[1] ?? '').map(entry => ({
text: entry.text,
display: entry.display,
meta: entry.meta,
group: 'Themes'
}))
return { items, query }
}
// /resume (and its aliases) completes recent sessions inline — the same
// client-side list the picker overlay shows — instead of the backend
// (whose /resume opens an interactive TUI picker we can't render here).
const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
if (sessionArg) {
const needle = (sessionArg[1] ?? '').trim().toLowerCase()
const matches = (
needle
? $sessions.get().filter(
session =>
sessionTitle(session).toLowerCase().includes(needle) ||
(session.preview ?? '').toLowerCase().includes(needle) ||
session.id.toLowerCase().includes(needle)
)
: $sessions.get()
).slice(0, SESSION_INLINE_LIMIT)
const items: CompletionEntry[] = matches.map(session => ({
text: `/resume ${session.id}`,
display: sessionTitle(session),
meta: (session.preview ?? '').trim(),
group: 'Sessions'
}))
// Trailing "more" affordance (Cursor-style): picking it opens the full
// session picker overlay directly. `text` stays a bare `/resume` so that
// submitting it (Enter) still opens the overlay if the action is skipped.
items.push({
text: '/resume',
display: 'Browse all sessions…',
meta: '',
group: 'Sessions',
action: 'session-picker'
})
return { items, query }
}
try {
if (!query) {
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
const items = (catalog.pairs ?? []).map(([command, meta]) => ({
text: command,
display: command,
meta
}))
// Prefer the categorized layout so the popover renders section headers
// (Session, Tools & Skills, ...). Fall back to the flat list when the
// backend didn't categorize.
const sections = catalog.categories?.length
? catalog.categories
: [{ name: '', pairs: catalog.pairs ?? [] }]
const items = sections.flatMap(section =>
section.pairs.map(([command, meta]) => ({
text: command,
display: command,
group: section.name || undefined,
meta
}))
)
return { items, query }
}
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
'complete.slash',
{ text }
)
const items = (result.items ?? [])
.filter(item => isDesktopSlashSuggestion(item.text))
// Arg-completion items (replace_from > 1) carry just the arg stub —
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
// with replace_from = 14. Rewrite those entries so the popover inserts
// the full `/personality alice` token instead of stranding `/alice`.
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
const isArgCompletion = replaceFrom > 1
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
const decorated = (result.items ?? [])
.map(item => {
if (!isArgCompletion) {
return item
}
const argText = typeof item.text === 'string' ? item.text : ''
return { ...item, text: `${prefix}${argText}` }
})
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
.map(item => ({
...item,
meta: desktopSlashDescription(item.text, textValue(item.meta))
// Arg suggestions (e.g. `/handoff <platform>`) live under one
// header; otherwise split skills out from built-in commands.
group: isArgCompletion ? 'Options' : isDesktopSlashExtensionCommand(item.text) ? 'Skills' : 'Commands',
// Arg items carry their own meta (the personality/toolset/platform
// blurb). Only command rows get the registry description — looking
// one up for `/personality none` would clobber it with the parent
// command's text.
meta: isArgCompletion ? textValue(item.meta) : desktopSlashDescription(item.text, textValue(item.meta))
}))
// Keep each group contiguous so headers render once: Commands before
// Skills (stable within a group, preserving backend relevance order).
const groupOrder = ['Commands', 'Skills', 'Options']
const items = isArgCompletion
? decorated
: [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
return { items, query }
} catch {
return { items: [], query }
}
},
[gateway]
[gateway, skinThemes, activeSkin]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
@@ -93,6 +212,8 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
command,
display,
meta,
group: textValue(entry.group),
action: textValue(entry.action),
// Provide rawText so hermesDirectiveFormatter.serialize uses the
// direct-insertion path instead of the legacy @type:id fallback.
// Without this, the item.id (which includes a "|index" suffix for

View File

@@ -13,17 +13,25 @@ import {
useState
} from 'react'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import {
$composerAttachments,
clearComposerAttachments,
clearSessionDraft,
type ComposerAttachment,
stashSessionDraft,
takeSessionDraft
} from '@/store/composer'
import {
browseBackward,
browseForward,
@@ -40,10 +48,11 @@ import {
shouldAutoDrainOnSettle,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $gatewayState, $messages } from '@/store/session'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { useTheme } from '@/themes'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
import { AttachmentList } from './attachments'
import { ContextMenu } from './context-menu'
@@ -64,7 +73,7 @@ import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import {
dragHasAttachments,
droppedFileInlineRef,
droppedFileInlineRefs,
type InlineRefInput,
insertInlineRefsIntoEditor
} from './inline-refs'
@@ -74,9 +83,9 @@ import {
placeCaretEnd,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT
RICH_INPUT_SLOT,
slashChipElement
} from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -95,6 +104,30 @@ const COMPOSER_FADE_BACKGROUND =
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
/** Completion items can carry an `action` (set in use-slash-completions) that
* runs a side effect on pick instead of inserting a chip — e.g. the session
* picker's "Browse all…" entry opens the overlay. Table-driven so new action
* items are a registry row, not a composer branch. */
const COMPLETION_ACTIONS: Record<string, () => void> = {
'session-picker': () => setSessionPickerOpen(true)
}
/** Map a picked `/` completion to its pill accent. Driven by the completion
* group set in use-slash-completions (Skills / Themes / Commands|Options). */
function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
const group = (item.metadata as { group?: unknown } | undefined)?.group
if (group === 'Skills') {
return 'skill'
}
if (group === 'Themes') {
return 'theme'
}
return 'command'
}
interface QueueEditState {
attachments: ComposerAttachment[]
draft: string
@@ -104,6 +137,10 @@ interface QueueEditState {
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
// Quiet period after the last keystroke before persisting the draft;
// unmount/pagehide flushes bypass it.
const DRAFT_PERSIST_DEBOUNCE_MS = 400
export function ChatBar({
busy,
cwd,
@@ -145,6 +182,9 @@ export function ChatBar({
const editorRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
activeQueueSessionKeyRef.current = activeQueueSessionKey
const drainingQueueRef = useRef(false)
const urlInputRef = useRef<HTMLInputElement | null>(null)
@@ -156,14 +196,17 @@ export function ChatBar({
const [dragActive, setDragActive] = useState(false)
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
const [focusRequestId, setFocusRequestId] = useState(0)
const queueEditRef = useRef(queueEdit)
queueEditRef.current = queueEdit
const dragDepthRef = useRef(0)
const composingRef = useRef(false) // true during IME composition (CJK input)
const lastSpokenIdRef = useRef<string | null>(null)
const narrow = useMediaQuery('(max-width: 30rem)')
const { availableThemes, themeName } = useTheme()
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
const slash = useSlashCompletions({ gateway: gateway ?? null })
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
const stacked = expanded || narrow || tight
const trimmedDraft = draft.trim()
@@ -171,10 +214,12 @@ export function ChatBar({
const canSubmit = busy || hasComposerPayload
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
// into a tool result) and never for a slash command (those execute inline).
const canSteer =
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
const showHelpHint = draft === '?'
const { t } = useI18n()
@@ -462,12 +507,6 @@ export function ChatBar({
})
}, [])
const selectSkinSlashCommand = (command: string) => {
draftRef.current = command
aui.composer().setText(command)
requestMainFocus()
}
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
@@ -620,16 +659,50 @@ export function ChatBar({
return
}
// Action items (e.g. "Browse all sessions…") run a side effect instead of
// inserting a chip: strip the typed trigger token, then fire the action.
const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
if (runAction) {
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
renderComposerContents(editor, prefix)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
aui.composer().setText(draftRef.current)
closeTrigger()
runAction()
requestMainFocus()
return
}
const serialized = hermesDirectiveFormatter.serialize(item)
const starter = serialized.endsWith(':')
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
// it — expand to its options step so the popover shows the inline list, just
// as typing `/personality ` by hand would. A serialized value with a space is
// already an arg pick (`/personality alice`), so it commits normally.
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
const expandsToArgs =
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
// No pill while expanding — the bare command stays plain text until an arg
// is picked, at which point a single pill is emitted for the full command.
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
const keepTriggerOpen = starter || expandsToArgs
const finish = () => {
draftRef.current = composerPlainText(editor)
aui.composer().setText(draftRef.current)
requestMainFocus()
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
const sel = window.getSelection()
@@ -639,7 +712,20 @@ export function ChatBar({
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
const current = composerPlainText(editor)
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
if (slashKind) {
// Two-step arg picks (e.g. `/handoff` pill already inserted, now picking
// the platform) land here because the caret sits past a contenteditable
// chip. Rebuild the prefix and re-emit a single pill for the full command.
renderComposerContents(editor, prefix)
editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' '))
placeCaretEnd(editor)
return finish()
}
renderComposerContents(editor, `${prefix}${text}`)
placeCaretEnd(editor)
return finish()
@@ -650,8 +736,13 @@ export function ChatBar({
replaceRange.setEnd(node, offset)
replaceRange.deleteContents()
if (directive) {
const chip = refChipElement(directive[1], directive[2])
const chip = slashKind
? slashChipElement(serialized, slashKind)
: directive
? refChipElement(directive[1], directive[2])
: null
if (chip) {
const space = document.createTextNode(' ')
const fragment = document.createDocumentFragment()
fragment.append(chip, space)
@@ -814,7 +905,16 @@ export function ChatBar({
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
// Decide from the DOM, not React state. `hasComposerPayload` is derived
// from the AUI composer state, which lags the latest keystroke by a
// render, so on fast typing / IME the just-typed text isn't in state yet.
// Without the live read, a real message typed while prompts are queued
// would drain the queue instead of sending. submitDraft() re-syncs and
// sends the live editor text.
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
void drainNextQueued()
return
@@ -822,7 +922,10 @@ export function ChatBar({
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
// never a stray Enter after sending. With a payload, submitDraft queues it.
if (busy && !hasComposerPayload) {
// Gate on the live DOM payload (not the render-lagged composer state) so a
// message typed fast / via IME while busy still reaches submitDraft() and
// gets queued instead of being mistaken for an empty Enter.
if (busy && !hasLivePayload) {
return
}
@@ -919,24 +1022,25 @@ export function ChatBar({
return
}
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
// In-app drags (project tree / gutter) are workspace-relative paths the
// gateway resolves directly, so they stay inline @file:/@line: refs. OS
// drops are absolute local paths a remote gateway can't read (and images
// need byte upload for vision), so route them through the upload pipeline.
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(inAppRefs, cwd)
if (insertInlineRefs(refs)) {
triggerHaptic('selection')
}
return
if (refs.length && insertInlineRefs(refs)) {
triggerHaptic('selection')
}
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
if (osDrops.length) {
void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
}
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
@@ -956,11 +1060,7 @@ export function ChatBar({
const candidates = extractDroppedFiles(event.dataTransfer)
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
if (!refs.length) {
if (!candidates.length) {
return
}
@@ -968,9 +1068,27 @@ export function ChatBar({
event.stopPropagation()
resetDragState()
if (insertInlineRefs(refs)) {
// Dropping straight onto the text box used to inline-ref *every* file —
// including OS/Finder drops, whose absolute local path a remote gateway
// can't read and whose image bytes never reached vision. Split by origin:
// in-app drags stay inline refs; OS drops go through the upload pipeline.
// (When no upload handler is wired, fall back to inline refs for all.)
const attach = onAttachDroppedItems
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd)
if (refs.length && insertInlineRefs(refs)) {
triggerHaptic('selection')
}
if (attach && osDrops.length) {
void Promise.resolve(attach(osDrops)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
}
const clearDraft = useCallback(() => {
@@ -995,6 +1113,69 @@ export function ChatBar({
}
}
const stashAt = (
scope: string | null,
text = draftRef.current,
attachments = $composerAttachments.get()
) => stashSessionDraft(scope, text, attachments)
// Per-thread draft swap — the composer's only session coupling. Lifecycle
// never clears composer state; this effect alone stashes on leave, restores
// on enter. Keyed writes are idempotent, so no skip-sentinel.
useEffect(() => {
const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
loadIntoComposer(text, attachments)
return () => {
const editing = queueEditRef.current
if (editing?.sessionKey === activeQueueSessionKey) {
stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
} else if (!isBrowsingHistory(sessionId)) {
stashAt(activeQueueSessionKey)
}
}
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
// Debounced stash into the active scope. Skipped while browsing history or
// editing a queued prompt — recalled text must not clobber the real draft.
useEffect(() => {
if (isBrowsingHistory(sessionId) || queueEdit) {
return
}
pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
const handle = window.setTimeout(() => {
pendingDraftPersistRef.current = null
stashAt(activeQueueSessionKey, draft)
}, DRAFT_PERSIST_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
}, [activeQueueSessionKey, draft, queueEdit, sessionId])
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
// inside the debounce window would drop trailing keystrokes without this.
useEffect(() => {
const flushPendingDraftPersist = () => {
const pending = pendingDraftPersistRef.current
if (!pending) {
return
}
pendingDraftPersistRef.current = null
stashAt(pending.scope, pending.text)
}
window.addEventListener('pagehide', flushPendingDraftPersist)
return () => {
window.removeEventListener('pagehide', flushPendingDraftPersist)
flushPendingDraftPersist()
}
}, [])
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
if (!activeQueueSessionKey || queueEdit) {
return
@@ -1197,21 +1378,61 @@ export function ChatBar({
}
}, [busy, drainNextQueued, queuedPrompts.length])
// Clean up queue edit when its target disappears (session swap or external delete).
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
useEffect(() => {
if (!queueEdit) {
return
}
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
return
if (queueEdit.sessionKey === activeQueueSessionKey) {
if (editingQueuedPrompt) {
return
}
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
}
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
setQueueEdit(null)
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
const submittedScope = activeQueueSessionKeyRef.current
const submittedAttachments = attachments ?? []
const restore = () => {
loadIntoComposer(text, submittedAttachments)
stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
}
void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
.catch(restore)
}
const submitDraft = () => {
// Source the text from the DOM editor, not React state. The AUI composer
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
// render, so on fast typing or IME composition the final keystroke(s) may
// not have synced yet — reading state here drops the message (Enter looks
// like it does nothing; typing a trailing space only "fixes" it because the
// extra input event forces a state sync). draftRef is updated on every
// input event; refresh it from the editor once more to also cover an
// in-flight keystroke that hasn't fired its input event yet.
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
aui.composer().setText(domText)
}
}
const text = draftRef.current
const payloadPresent = text.trim().length > 0 || attachments.length > 0
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
@@ -1222,12 +1443,11 @@ export function ChatBar({
// busy guard for commands that genuinely need an idle session (skill
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
const submitted = draft
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
triggerHaptic('submit')
clearDraft()
void onSubmit(submitted)
} else if (hasComposerPayload) {
dispatchSubmit(text)
} else if (payloadPresent) {
queueCurrentDraft()
} else {
// Stop button (the only way to reach here while busy with an empty
@@ -1235,15 +1455,15 @@ export function ChatBar({
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
} else if (!hasComposerPayload && queuedPrompts.length > 0) {
} else if (!payloadPresent && queuedPrompts.length > 0) {
void drainNextQueued()
} else if (draft.trim() || attachments.length > 0) {
const submitted = draft
} else if (payloadPresent) {
const submittedAttachments = cloneAttachments(attachments)
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
clearComposerAttachments()
void onSubmit(submitted, { attachments })
dispatchSubmit(text, submittedAttachments)
}
focusInput()
@@ -1410,7 +1630,7 @@ export function ChatBar({
onPaste={handlePaste}
ref={editorRef}
role="textbox"
spellCheck="true"
spellCheck={false}
suppressContentEditableWarning
/>
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
@@ -1429,7 +1649,15 @@ export function ChatBar({
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
plain <textarea>, which carries the binding but skips autosize. */}
<ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
<textarea aria-hidden className="sr-only" tabIndex={-1} />
<textarea
aria-hidden
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="sr-only"
spellCheck={false}
tabIndex={-1}
/>
</ComposerPrimitive.Input>
</div>
)
@@ -1468,7 +1696,6 @@ export function ChatBar({
onPick={replaceTriggerWithChip}
/>
)}
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
{activeQueueSessionKey && queuedPrompts.length > 0 && (
// Out of flow so the queue never inflates the composer's measured
// height (that drives thread bottom padding → chat resizes on

View File

@@ -83,6 +83,12 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
return `@${kind}:${formatRefValue(rel)}`
}
/** Resolve a batch of drops to their inline `@file:`/`@line:`/`@folder:` refs,
* dropping any that carry no path. */
export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | null | undefined): string[] {
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
if (!refs.length) {
return null

View File

@@ -10,7 +10,10 @@ import {
DIRECTIVE_CHIP_CLASS,
directiveIconElement,
directiveIconSvg,
formatRefValue
formatRefValue,
slashChipClass,
type SlashChipKind,
slashIconElement
} from '@/components/assistant-ui/directive-text'
export const RICH_INPUT_SLOT = 'composer-rich-input'
@@ -77,6 +80,24 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
return chip
}
/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
* it back to the exact text that gets submitted. */
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
const chip = document.createElement('span')
const text = document.createElement('span')
chip.contentEditable = 'false'
chip.dataset.refText = command
chip.dataset.slashKind = kind
chip.className = slashChipClass(kind)
text.className = 'truncate'
text.textContent = label || command
chip.append(slashIconElement(kind), text)
return chip
}
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
const lines = text.split('\n')

View File

@@ -1,61 +0,0 @@
import { useI18n } from '@/i18n'
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
interface SkinSlashPopoverProps {
draft: string
onSelect: (command: string) => void
}
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
const { t } = useI18n()
const c = t.composer
const { availableThemes, themeName } = useTheme()
const match = draft.match(/^\/skin\s+(\S*)$/i)
if (!match) {
return null
}
const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
return (
<div
aria-label={c.themeSuggestions}
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-skin-completion-drawer"
data-state="open"
role="listbox"
>
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title={c.noMatchingThemes}>
{c.themeTryPre}
<span className="font-mono text-foreground/80">/skin list</span>
{c.themeTryPost}
</CompletionDrawerEmpty>
) : (
items.map(item => (
<button
className={COMPLETION_DRAWER_ROW_CLASS}
key={item.text}
onClick={() => {
triggerHaptic('selection')
onSelect(item.text)
}}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
>
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span>
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span>
</button>
))
)}
</div>
</div>
)
}

View File

@@ -22,6 +22,33 @@ describe('detectTrigger', () => {
it('returns null for plain text', () => {
expect(detectTrigger('hello there')).toBeNull()
})
it('keeps the slash trigger live while typing args', () => {
expect(detectTrigger('/personality ')).toEqual({
kind: '/',
query: 'personality ',
tokenLength: 13
})
expect(detectTrigger('/personality alic')).toEqual({
kind: '/',
query: 'personality alic',
tokenLength: 17
})
expect(detectTrigger('/tools enable foo')).toEqual({
kind: '/',
query: 'tools enable foo',
tokenLength: 17
})
})
it('does not treat file-style paths as slash triggers', () => {
expect(detectTrigger('src/foo/bar')).toBeNull()
expect(detectTrigger('/path/to/file')).toBeNull()
})
it('still anchors at-mention triggers strictly at the token edge', () => {
expect(detectTrigger('@file:path with space')).toBeNull()
})
})
describe('extractClipboardImageBlobs', () => {

View File

@@ -6,7 +6,13 @@ export interface TriggerState {
tokenLength: number
}
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
// single tokens. `/` triggers keep going so the popover stays live while the
// user types args (`/personality alic` → arg completer suggests `alice`).
// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
// paths like `src/foo/bar`.
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string {
@@ -97,11 +103,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
}
export function detectTrigger(textBefore: string): TriggerState | null {
const match = TRIGGER_RE.exec(textBefore)
const slash = SLASH_TRIGGER_RE.exec(textBefore)
if (!match) {
return null
if (slash) {
return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
}
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
const at = AT_TRIGGER_RE.exec(textBefore)
if (at) {
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
}
return null
}

View File

@@ -34,9 +34,17 @@ describe('ComposerTriggerPopover i18n', () => {
})
it('renders localized loading copy for slash commands', () => {
const { container } = renderPopover('/', true)
renderPopover('/', true)
// While loading the popover shows only the spinner + loading copy — the
// `/help` empty-state hint is reserved for the resolved (not-loading) state.
expect(screen.getByText('查找中…')).toBeTruthy()
})
it('renders the slash empty-state hint when not loading', () => {
const { container } = renderPopover('/')
expect(screen.getByText('没有匹配项。')).toBeTruthy()
expect(container.textContent).toContain('/help')
})
})

View File

@@ -1,5 +1,7 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { Fragment } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
@@ -7,7 +9,6 @@ import { cn } from '@/lib/utils'
import {
COMPLETION_DRAWER_BELOW_CLASS,
COMPLETION_DRAWER_CLASS,
COMPLETION_DRAWER_ROW_CLASS,
CompletionDrawerEmpty
} from './completion-drawer'
@@ -23,11 +24,7 @@ const AT_ICON_BY_TYPE: Record<string, string> = {
url: 'globe'
}
function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
if (kind === '/') {
return 'terminal'
}
function atIcon(item: Unstable_TriggerItem) {
const meta = item.metadata as { rawText?: string } | undefined
const raw = meta?.rawText || item.label
@@ -42,6 +39,18 @@ function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
}
interface RowMeta {
display?: string
group?: string
meta?: string
}
const ROW_BASE_CLASS = [
'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left',
'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)',
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
].join(' ')
interface ComposerTriggerPopoverProps {
activeIndex: number
items: readonly Unstable_TriggerItem[]
@@ -63,6 +72,9 @@ export function ComposerTriggerPopover({
}: ComposerTriggerPopoverProps) {
const { t } = useI18n()
const copy = t.composer
const isSlash = kind === '/'
let lastGroup: string | undefined
return (
<div
@@ -73,41 +85,94 @@ export function ComposerTriggerPopover({
role="listbox"
>
{items.length === 0 ? (
<CompletionDrawerEmpty title={loading ? copy.lookupLoading : copy.lookupNoMatches}>
{kind === '@' ? (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
</>
)}
</CompletionDrawerEmpty>
loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
<span>{copy.lookupLoading}</span>
</div>
) : (
<CompletionDrawerEmpty title={copy.lookupNoMatches}>
{kind === '@' ? (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
</>
)}
</CompletionDrawerEmpty>
)
) : (
items.map((item, index) => {
const meta = item.metadata as { display?: string; meta?: string } | undefined
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
const meta = item.metadata as RowMeta | undefined
const display = meta?.display ?? (isSlash ? `/${item.label}` : item.label)
const description = meta?.meta || item.description
const group = meta?.group?.trim()
const showHeader = isSlash && Boolean(group) && group !== lastGroup
const isFirstHeader = lastGroup === undefined
lastGroup = group || lastGroup
const active = index === activeIndex
return (
<button
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
data-highlighted={index === activeIndex ? '' : undefined}
key={item.id}
onClick={() => onPick(item)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
</span>
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">{display}</span>
{description && (
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
<Fragment key={item.id}>
{showHeader && (
<div
className={cn(
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)',
isFirstHeader ? 'pt-0.5' : 'pt-2'
)}
>
{group}
</div>
)}
</button>
<button
className={cn(ROW_BASE_CLASS, isSlash ? 'flex-col gap-0' : 'items-center gap-2')}
data-highlighted={active ? '' : undefined}
onClick={() => onPick(item)}
onMouseEnter={() => onHover(index)}
type="button"
>
{isSlash ? (
<>
{/* Active row (keyboard nav or hover) un-truncates inline so
long command names / descriptions stay readable without a
floating tooltip. */}
<span
className={cn(
'text-[0.8125rem] font-medium leading-snug text-foreground',
active ? 'whitespace-normal break-words' : 'truncate'
)}
>
{display}
</span>
{description && (
<span
className={cn(
'text-[0.6875rem] leading-snug text-(--ui-text-tertiary)',
active ? 'whitespace-normal break-words' : 'truncate'
)}
>
{description}
</span>
)}
</>
) : (
<>
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
<Codicon name={atIcon(item)} size="0.875rem" />
</span>
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
{display}
</span>
{description && (
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
)}
</>
)}
</button>
</Fragment>
)
})
)}

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { type DroppedFile, partitionDroppedFiles } from './use-composer-actions'
// A Finder/Explorer drop carries a native File handle; an in-app drag (project
// tree, gutter line ref) is path-only. The split decides whether a drop becomes
// an inline @file: ref (in-app, workspace-relative, gateway-resolvable) or goes
// through the upload pipeline (OS drop — absolute local path a remote gateway
// can't read, plus image bytes for vision).
const osDrop = (path: string): DroppedFile => ({ file: new File(['x'], path.split('/').pop() || 'f'), path })
const inAppRef = (path: string, extra: Partial<DroppedFile> = {}): DroppedFile => ({ path, ...extra })
describe('partitionDroppedFiles', () => {
it('routes File-bearing OS drops to osDrops and path-only in-app drags to inAppRefs', () => {
const finderPdf = osDrop('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
const projectFile = inAppRef('src/index.ts')
const { inAppRefs, osDrops } = partitionDroppedFiles([finderPdf, projectFile])
expect(osDrops).toEqual([finderPdf])
expect(inAppRefs).toEqual([projectFile])
})
it('treats an OS screenshot drop as an upload target (so it gets byte upload + vision)', () => {
const screenshot = osDrop('/var/folders/tmp/Screenshot 2026-06-09.png')
const { inAppRefs, osDrops } = partitionDroppedFiles([screenshot])
expect(osDrops).toEqual([screenshot])
expect(inAppRefs).toEqual([])
})
it('keeps gutter line-range drags inline (no File handle)', () => {
const lineRef = inAppRef('src/app.ts', { line: 10, lineEnd: 20 })
const { inAppRefs, osDrops } = partitionDroppedFiles([lineRef])
expect(osDrops).toEqual([])
expect(inAppRefs).toEqual([lineRef])
})
it('splits a mixed drop and preserves order within each group', () => {
const a = inAppRef('a.ts')
const b = osDrop('/abs/b.pdf')
const c = inAppRef('c.ts')
const d = osDrop('/abs/d.png')
const { inAppRefs, osDrops } = partitionDroppedFiles([a, b, c, d])
expect(inAppRefs).toEqual([a, c])
expect(osDrops).toEqual([b, d])
})
it('returns empty groups for an empty drop', () => {
expect(partitionDroppedFiles([])).toEqual({ inAppRefs: [], osDrops: [] })
})
})

View File

@@ -33,7 +33,7 @@ function blobExtension(blob: Blob): string {
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
}
function isImagePath(filePath: string): boolean {
export function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
@@ -181,6 +181,35 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
return result
}
/**
* Split dropped entries by origin. OS/Finder drops carry a native `File`
* handle; in-app drags (project tree, gutter line refs) are path-only.
*
* The distinction is load-bearing: an in-app path is workspace-relative and
* resolves on the gateway as-is, so it stays an inline `@file:`/`@line:` ref.
* An OS drop is an absolute path on *this* machine — the gateway can't read it
* in remote mode, and an image needs its bytes uploaded to get vision either
* way. So OS drops must go through the attachment/upload pipeline rather than
* leaking a local path into the prompt text.
*/
export function partitionDroppedFiles(candidates: DroppedFile[]): {
osDrops: DroppedFile[]
inAppRefs: DroppedFile[]
} {
const osDrops: DroppedFile[] = []
const inAppRefs: DroppedFile[] = []
for (const candidate of candidates) {
if (candidate.file) {
osDrops.push(candidate)
} else {
inAppRefs.push(candidate)
}
}
return { osDrops, inAppRefs }
}
interface ComposerActionsOptions {
activeSessionId: string | null
currentCwd: string

View File

@@ -49,9 +49,9 @@ import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import type { ChatBarState } from './composer/types'
import type { DroppedFile } from './hooks/use-composer-actions'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
@@ -126,7 +126,10 @@ function ChatHeader({
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div
className="min-w-0 flex-1"
style={{ maxWidth: 'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)' }}
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
}}
>
<SessionActionsMenu
align="start"
@@ -299,19 +302,25 @@ export function ChatView({
})
// Drop files anywhere in the conversation area, not just on the composer
// input — appending the same inline `@file:` ref chips the composer drop
// produces (vs. attachment cards) so both surfaces behave identically.
// input. In-app drags (project tree / gutter) carry workspace-relative paths
// the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder
// drops carry absolute local paths that don't exist on a remote gateway (and
// images need byte upload for vision), so route them through the attachment
// pipeline — otherwise the local path leaks into the prompt verbatim.
const onDropFiles = useCallback(
(candidates: DroppedFile[]) => {
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, currentCwd))
.filter((ref): ref is string => Boolean(ref))
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
if (refs.length) {
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
}
if (osDrops.length) {
void onAttachDroppedItems(osDrops)
}
},
[currentCwd]
[currentCwd, onAttachDroppedItems]
)
// Dropping a sidebar session inserts an @session link the agent can resolve

View File

@@ -13,6 +13,7 @@ import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -180,15 +181,13 @@ function looksBinaryBytes(bytes: Uint8Array) {
}
async function readTextPreview(filePath: string) {
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
try {
return await readDesktopFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
}
@@ -288,7 +287,7 @@ const MARKDOWN_COMPONENTS = {
function MarkdownPreview({ text }: { text: string }) {
return (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
@@ -384,7 +383,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
)
})}
</div>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
<div
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
data-selectable-text="true"
>
{selection && (
<div
aria-hidden
@@ -446,7 +448,9 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
try {
if (isImage) {
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
// Prefer bytes the caller already handed us (a pasted/dropped
// screenshot) over re-reading a path that may be transient/unreadable.
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
if (active) {
setState({ dataUrl, loading: false })
@@ -484,7 +488,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
return () => {
active = false
}
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
if (state.loading) {
return <PageLoader label={t.preview.loading} />

View File

@@ -1,11 +1,50 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
})
afterEach(() => {
cleanup()
$connection.set(null)
vi.unstubAllGlobals()
})
it('does not watch backend-only remote filesystem previews locally', () => {
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
const onPreviewFileChanged = vi.fn(() => vi.fn())
$connection.set({ mode: 'remote' } as never)
vi.stubGlobal('window', {
...window,
hermesDesktop: {
onPreviewFileChanged,
watchPreviewFile
}
})
render(
<PreviewPane
setTitlebarToolGroup={vi.fn()}
target={{
kind: 'file',
label: 'file.txt',
path: '/remote/file.txt',
previewKind: 'text',
source: '/remote/file.txt',
url: 'file:///remote/file.txt'
}}
/>
)
expect(watchPreviewFile).not.toHaveBeenCalled()
expect(onPreviewFileChanged).not.toHaveBeenCalled()
})
it('does not rebuild the pane titlebar group for streamed console logs', () => {

View File

@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -406,6 +407,7 @@ export function PreviewPane({
useEffect(() => {
if (
target.kind !== 'file' ||
isDesktopFsRemoteMode() ||
!window.hermesDesktop?.watchPreviewFile ||
!window.hermesDesktop?.onPreviewFileChanged
) {

View File

@@ -14,6 +14,8 @@ import type { CronJob } from '@/types/hermes'
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { SidebarLoadMoreRow } from './load-more-row'
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
// Recent runs shown in the inline quick-peek — enough to glance at history
@@ -24,6 +26,11 @@ const PEEK_RUN_LIMIT = 5
// open peek so a freshly-fired run shows up within a few seconds.
const PEEK_POLL_INTERVAL_MS = 8000
// Keep the section compact: show a few jobs up front, reveal more in larger
// steps on demand (mirrors the messaging sections in the sidebar).
const INITIAL_VISIBLE_JOBS = 3
const LOAD_MORE_STEP = 10
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
@@ -33,17 +40,25 @@ function relativeTime(targetMs: number, nowMs: number): string {
const abs = Math.abs(diff)
const sign = diff < 0 ? -1 : 1
if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')}
if (abs < 60_000) {
return relativeFmt.format(sign * Math.round(abs / 1000), 'second')
}
if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')}
if (abs < 3_600_000) {
return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')
}
if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')}
if (abs < 86_400_000) {
return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')
}
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
}
function nextRunMs(job: CronJob): null | number {
if (!job.next_run_at) {return null}
if (!job.next_run_at) {
return null
}
const ms = Date.parse(job.next_run_at)
@@ -54,7 +69,9 @@ function nextRunMs(job: CronJob): null | number {
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
// narrow sidebar.
function formatRunTime(seconds?: null | number): string {
if (!seconds) {return '—'}
if (!seconds) {
return '—'
}
const date = new Date(seconds * 1000)
@@ -90,11 +107,15 @@ export function SidebarCronJobsSection({
const [nowMs, setNowMs] = useState(() => Date.now())
// Single-open inline peek so the section stays scannable.
const [peekJobId, setPeekJobId] = useState<null | string>(null)
// Rows revealed so far; starts compact, grows in steps via "load more".
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_JOBS)
// One clock for the whole section (rows are pure) so the countdowns tick
// without re-rendering the rest of the sidebar. Only runs while expanded.
useEffect(() => {
if (!open) {return}
if (!open) {
return
}
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
@@ -108,17 +129,25 @@ export function SidebarCronJobsSection({
const an = nextRunMs(a)
const bn = nextRunMs(b)
if (an !== null && bn !== null && an !== bn) {return an - bn}
if (an !== null && bn !== null && an !== bn) {
return an - bn
}
if (an === null && bn !== null) {return 1}
if (an === null && bn !== null) {
return 1
}
if (an !== null && bn === null) {return -1}
if (an !== null && bn === null) {
return -1
}
return jobTitle(a).localeCompare(jobTitle(b))
})
}, [jobs])
const shown = sorted.slice(0, max)
const cap = Math.min(visibleCount, max)
const shown = sorted.slice(0, cap)
const hiddenCount = Math.min(sorted.length, max) - shown.length
// When capped, signal "50+" rather than implying the list is complete.
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
@@ -139,7 +168,7 @@ export function SidebarCronJobsSection({
</button>
</div>
{open && (
<SidebarGroupContent className="flex max-h-72 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
{shown.map(job => (
<CronJobSidebarRow
expanded={peekJobId === job.id}
@@ -152,6 +181,12 @@ export function SidebarCronJobsSection({
onTrigger={() => onTriggerJob(job.id)}
/>
))}
{hiddenCount > 0 && (
<SidebarLoadMoreRow
onClick={() => setVisibleCount(count => count + LOAD_MORE_STEP)}
step={Math.min(LOAD_MORE_STEP, hiddenCount)}
/>
)}
</SidebarGroupContent>
)}
</SidebarGroup>
@@ -181,11 +216,7 @@ function CronJobSidebarRow({
const next = nextRunMs(job)
const label = jobTitle(job)
const meta = INACTIVE_STATES.has(state)
? (c.states[state] ?? state)
: next !== null
? relativeTime(next, nowMs)
: '—'
const meta = INACTIVE_STATES.has(state) ? (c.states[state] ?? state) : next !== null ? relativeTime(next, nowMs) : '—'
return (
<div>
@@ -257,13 +288,7 @@ function CronJobSidebarRow({
)
}
function CronJobSidebarRuns({
jobId,
onOpenRun
}: {
jobId: string
onOpenRun: (sessionId: string) => void
}) {
function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (sessionId: string) => void }) {
const { t } = useI18n()
const c = t.cron
const selectedSessionId = useStore($selectedStoredSessionId)
@@ -275,16 +300,22 @@ function CronJobSidebarRuns({
const load = () =>
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
.then(result => {
if (!cancelled) {setRuns(result)}
if (!cancelled) {
setRuns(result)
}
})
.catch(() => {
if (!cancelled) {setRuns(prev => prev ?? [])}
if (!cancelled) {
setRuns(prev => prev ?? [])
}
})
void load()
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {void load()}
if (document.visibilityState === 'visible') {
void load()
}
}, PEEK_POLL_INTERVAL_MS)
return () => {

View File

@@ -48,6 +48,7 @@ import {
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarCronOpen,
$sidebarMessagingOpenIds,
$sidebarOpen,
$sidebarOverlayMounted,
$sidebarPinsOpen,
@@ -64,6 +65,7 @@ import {
setSidebarSessionOrderIds,
setSidebarWorkspaceOrderIds,
SIDEBAR_SESSIONS_PAGE_SIZE,
toggleSidebarMessagingOpen,
unpinSession
} from '@/store/layout'
import {
@@ -76,6 +78,9 @@ import {
} from '@/store/profile'
import {
$cronSessions,
$messagingPlatformTotals,
$messagingSessions,
$messagingTruncated,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
@@ -90,12 +95,19 @@ import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { SidebarCronJobsSection } from './cron-jobs-section'
import { SidebarLoadMoreRow } from './load-more-row'
import { ProfileRail } from './profile-switcher'
import { SidebarSessionRow } from './session-row'
import { VirtualSessionList } from './virtual-session-list'
const VIRTUALIZE_THRESHOLD = 25
// Non-session groups (messaging platforms) stay compact: show a few rows up
// front, reveal more in larger steps on demand. Keeps a busy platform from
// dominating the sidebar before the user asks to see it.
const NON_SESSION_INITIAL_ROWS = 3
const NON_SESSION_LOAD_STEP = 10
// Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory.
@@ -124,7 +136,16 @@ const WORKSPACE_PAGE = 5
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const GROUP_DND_ID_PREFIX = 'group:'
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
// Two modes via the `compact` height variant (styles.css):
// tall → each section is shrink-0, capped, its own scroller; Sessions is flex-1.
// compact → COMPACT_FLAT drops the caps so the whole stack scrolls as one.
// Sections stay shrink-0 so none can be squeezed below its content and bleed onto
// the next — the flexbox `min-height: auto` overlap trap that caused the bug.
const COMPACT_FLAT = 'compact:max-h-none compact:overflow-visible'
// A non-session group's scroll body: own scroller when tall, flattened when compact.
const GROUP_BODY = cn('overflow-y-auto overscroll-contain', COMPACT_FLAT)
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
@@ -141,24 +162,25 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
const byId = new Map(items.map(item => [getId(item), item]))
const seen = new Set<string>()
const out: T[] = []
const ordered: T[] = []
for (const id of orderIds) {
const item = byId.get(id)
if (item) {
out.push(item)
ordered.push(item)
seen.add(id)
}
}
for (const item of items) {
if (!seen.has(getId(item))) {
out.push(item)
}
}
// Items missing from the persisted order are new since it was last
// reconciled. Callers pass recency-sorted lists (newest first), so surface
// these at the TOP instead of burying them beneath the saved order —
// otherwise a brand-new session sinks to the bottom of the sidebar and reads
// as "my latest session never showed up".
const fresh = items.filter(item => !seen.has(getId(item)))
return out
return fresh.length ? [...fresh, ...ordered] : ordered
}
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
@@ -171,17 +193,15 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
}
const current = new Set(currentIds)
const next = orderIds.filter(id => current.has(id))
const known = new Set(next)
const retained = orderIds.filter(id => current.has(id))
const retainedSet = new Set(retained)
for (const id of currentIds) {
if (!known.has(id)) {
next.push(id)
known.add(id)
}
}
// New ids (absent from the saved order) are the newest sessions/groups; keep
// them ahead of the persisted order so fresh activity surfaces at the top of
// the sidebar rather than being appended to the bottom.
const fresh = currentIds.filter(id => !retainedSet.has(id))
return next
return [...fresh, ...retained]
}
function sameIds(left: string[], right: string[]) {
@@ -251,43 +271,6 @@ function workspaceGroupsFor(
return [...groups.values()]
}
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
localSessions: SessionInfo[]
sourceGroups: SidebarSessionGroup[]
} {
const groups = new Map<string, SidebarSessionGroup>()
const localSessions: SessionInfo[] = []
for (const session of sessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
localSessions.push(session)
continue
}
const label = sessionSourceLabel(sourceId) ?? sourceId
const group = groups.get(sourceId) ?? {
id: `source:${sourceId}`,
label,
mode: 'source',
path: null,
sessions: [],
sourceId
}
group.sessions.push(session)
groups.set(sourceId, group)
}
return {
localSessions,
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@@ -309,6 +292,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onNavigate: (item: SidebarNavItem) => void
onLoadMoreSessions: () => void
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
@@ -322,6 +306,7 @@ export function ChatSidebar({
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
onLoadMoreMessaging,
onResumeSession,
onDeleteSession,
onArchiveSession,
@@ -345,6 +330,9 @@ export function ChatSidebar({
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
const messagingSessions = useStore($messagingSessions)
const messagingPlatformTotals = useStore($messagingPlatformTotals)
const messagingTruncated = useStore($messagingTruncated)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
@@ -364,6 +352,10 @@ export function ChatSidebar({
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})
const messagingOpenIds = useStore($sidebarMessagingOpenIds)
// Per-platform count of rows currently revealed (starts at NON_SESSION_INITIAL_ROWS).
const [messagingVisible, setMessagingVisible] = useState<Record<string, number>>({})
const searchInputRef = useRef<HTMLInputElement>(null)
const trimmedQuery = searchQuery.trim()
@@ -529,24 +521,12 @@ export function ChatSidebar({
[unpinnedAgentSessions, agentOrderIds]
)
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
() => sourceSessionGroupsFor(agentSessions),
[agentSessions]
)
const orderedSourceGroups = useMemo(
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
[sourceGroups, workspaceOrderIds]
)
// Recents are local-only: messaging-platform sessions are fetched as their
// own slice ($messagingSessions) and rendered in self-managed per-platform
// sections below, so there is no source-grouping magic to untangle here.
const agentGroups = useMemo(
() =>
orderByIds(
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
g => g.id,
workspaceOrderIds
),
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -564,6 +544,76 @@ export function ChatSidebar({
[onLoadMoreProfileSessions]
)
const loadMoreForMessaging = useCallback(
(platform: string) => {
if (!onLoadMoreMessaging) {
return
}
setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true }))
void Promise.resolve(onLoadMoreMessaging(platform))
.catch(() => undefined)
.finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest))
},
[onLoadMoreMessaging]
)
// Reveal another batch of a platform's rows; fetch from the backend too if we
// run past what's loaded and more remain on disk.
const revealMoreMessaging = (platform: string, loaded: number, hasMore: boolean) => {
const next = (messagingVisible[platform] ?? NON_SESSION_INITIAL_ROWS) + NON_SESSION_LOAD_STEP
setMessagingVisible(prev => ({ ...prev, [platform]: next }))
if (next > loaded && hasMore) {
loadMoreForMessaging(platform)
}
}
// Each messaging platform is its own self-managed section: split the
// separately-fetched messaging slice by source, newest platform first, rows
// within a platform by recency. Per-platform totals (when a "load more" has
// resolved them) drive the count + whether more remain on disk.
const messagingGroups = useMemo<MessagingSection[]>(() => {
if (!messagingSessions.length) {
return []
}
const bySource = new Map<string, SessionInfo[]>()
for (const session of messagingSessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId) {
continue
}
const list = bySource.get(sourceId) ?? []
list.push(session)
bySource.set(sourceId, list)
}
return [...bySource.entries()]
.map(([sourceId, list]) => {
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
const known = messagingPlatformTotals[sourceId]
const total = Math.max(ordered.length, known ?? 0)
return {
// Known exact total → more exist iff total exceeds loaded; otherwise
// the seed fetch was capped, so assume more until a per-platform load
// resolves the count.
hasMore: known != null ? known > ordered.length : messagingTruncated,
label: sessionSourceLabel(sourceId) ?? sourceId,
sessions: ordered,
sourceId,
total
}
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [messagingSessions, messagingPlatformTotals, messagingTruncated])
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
@@ -610,56 +660,7 @@ export function ChatSidebar({
sessionProfileTotals
])
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
const displayAgentGroups = useMemo(() => {
if (orderedSourceGroups.length) {
const localGroups = agentsGrouped
? agentGroups
: localAgentSessions.length
? [
{
id: 'local-sessions',
label: 'Local',
mode: 'workspace' as const,
path: null,
sessions: localAgentSessions
}
]
: []
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
}
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
}, [
agentGroups,
agentsGrouped,
localAgentSessions,
orderedSourceGroups,
profileGroups,
showAllProfiles,
workspaceOrderIds
])
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
return
}
const next = reconcileOrderIds(
displayAgentGroups.map(g => g.id),
workspaceOrderIds
)
if (!sameIds(next, workspaceOrderIds)) {
setSidebarWorkspaceOrderIds(next)
}
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
const displayAgentSessions = agentSessions
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
@@ -680,6 +681,33 @@ export function ChatSidebar({
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
// The recents list owns its own (virtualized) scroll container only when it's a
// long flat list. In that case it must keep its scroller even in short mode, so
// we don't flatten it (flattening would defeat virtualization). Short flat lists
// and grouped views flatten into the single outer scroll instead.
const recentsVirtualizes = !displayAgentGroups?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
return
}
const next = reconcileOrderIds(
displayAgentGroups.map(g => g.id),
workspaceOrderIds
)
if (!sameIds(next, workspaceOrderIds)) {
setSidebarWorkspaceOrderIds(next)
}
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
@@ -769,7 +797,14 @@ export function ChatSidebar({
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
// no-drag: these rows sit directly under the titlebar's
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
// 6px of clearance. Drag regions win hit-testing over DOM
// (pointer-events can't override), and on Linux/WSLg the
// resolved region has been observed to swallow clicks on the
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
// thread.tsx.
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
@@ -792,9 +827,7 @@ export function ChatSidebar({
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{contentVisible && (
<>
<span className="min-w-0 flex-1 truncate">
{s.nav[item.id] ?? item.label}
</span>
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
@@ -823,135 +856,191 @@ export function ChatSidebar({
</div>
)}
{contentVisible && showSessionSections && trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
{s.noMatch(trimmedQuery)}
</div>
}
label={s.results}
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => undefined}
onTogglePin={pinSession}
open
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={searchResults}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{contentVisible && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label={s.pinned}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
onTogglePin={unpinSession}
open={pinsOpen}
pinned
rootClassName="shrink-0 p-0 pb-1"
sessions={pinnedSessions}
sortable={pinnedSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{contentVisible && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
// Separate profile sections clearly in the ALL view; rows inside
// each group keep their own tight gap-px rhythm.
showAllProfiles ? 'gap-3' : 'gap-px'
{contentVisible && showSessionSections && (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75">
{trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
{s.noMatch(trimmedQuery)}
</div>
}
label={s.results}
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => undefined}
onTogglePin={pinSession}
open
pinned={false}
rootClassName="min-h-32 flex-1 overflow-hidden p-0"
sessions={searchResults}
workingSessionIdSet={workingSessionIdSet}
/>
)}
dndSensors={dndSensors}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
// Hide "load more" only when workspace-grouped (those groups page
// themselves). ALL-profiles now pages per-profile from each profile
// header; the global footer only applies to non-ALL views.
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading}
onClick={onLoadMoreSessions}
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
/>
) : null
}
forceEmptyState={showSessionSkeletons}
groups={displayAgentGroups}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
// "Sessions" label jumps when switching to the ALL-profiles view.
// Grouping operates on unpinned recents; if everything is pinned
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && localAgentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
className={cn(
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
</Tip>
) : null}
</div>
}
label={s.sessions}
labelMeta={recentsMeta}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
open={agentsOpen}
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={displayAgentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
label={s.cronJobs}
onManageJob={onManageCronJob}
onOpenRun={onResumeSession}
onToggle={() => setSidebarCronOpen(!cronOpen)}
onTriggerJob={onTriggerCronJob}
open={cronOpen}
/>
{!trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn('flex max-h-44 flex-col gap-px rounded-lg pb-2 pt-1', GROUP_BODY)}
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label={s.pinned}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
onTogglePin={unpinSession}
open={pinsOpen}
pinned
rootClassName="shrink-0 p-0 pb-1"
sessions={pinnedSessions}
sortable={pinnedSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{!trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
// Separate profile sections clearly in the ALL view; rows inside
// each group keep their own tight gap-px rhythm.
showAllProfiles ? 'gap-3' : 'gap-px',
// Flatten into the single scroll when compact — unless this is the
// virtualized long list, which must keep its own scroller.
!recentsVirtualizes && COMPACT_FLAT
)}
dndSensors={dndSensors}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
// Hide "load more" only when workspace-grouped (those groups page
// themselves). ALL-profiles now pages per-profile from each profile
// header; the global footer only applies to non-ALL views.
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading}
onClick={onLoadMoreSessions}
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
/>
) : null
}
forceEmptyState={showSessionSkeletons}
groups={displayAgentGroups}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
// "Sessions" label jumps when switching to the ALL-profiles view.
// Grouping operates on unpinned recents; if everything is pinned
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
className={cn(
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
</Tip>
) : null}
</div>
}
label={s.sessions}
labelMeta={recentsMeta}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
open={agentsOpen}
pinned={false}
rootClassName={cn(
'min-h-32 flex-1 overflow-hidden p-0',
!recentsVirtualizes && 'compact:min-h-0 compact:flex-none compact:overflow-visible'
)}
sessions={displayAgentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{!trimmedQuery &&
messagingGroups.map(group => {
const visible = messagingVisible[group.sourceId] ?? NON_SESSION_INITIAL_ROWS
const shownSessions = group.sessions.slice(0, visible)
// More to show if rows are hidden behind the cap, or the backend
// still has older threads on disk.
const canRevealMore = visible < group.sessions.length || group.hasMore
return (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn('flex max-h-56 flex-col gap-px pb-1.75', GROUP_BODY)}
emptyState={null}
footer={
canRevealMore ? (
<SidebarLoadMoreRow
loading={Boolean(messagingLoadMorePending[group.sourceId])}
onClick={() => revealMoreMessaging(group.sourceId, group.sessions.length, group.hasMore)}
step={Math.min(NON_SESSION_LOAD_STEP, Math.max(0, group.total - shownSessions.length))}
/>
) : null
}
key={group.sourceId}
label={group.label}
labelIcon={
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
platformId={group.sourceId}
platformName={group.label}
/>
}
labelMeta={countLabel(group.sessions.length, group.total)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => toggleSidebarMessagingOpen(group.sourceId)}
onTogglePin={pinSession}
open={messagingOpenIds.includes(group.sourceId)}
pinned={false}
rootClassName="shrink-0 p-0"
sessions={shownSessions}
workingSessionIdSet={workingSessionIdSet}
/>
)
})}
{!trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
label={s.cronJobs}
onManageJob={onManageCronJob}
onOpenRun={onResumeSession}
onToggle={() => setSidebarCronOpen(!cronOpen)}
onTriggerJob={onTriggerCronJob}
open={cronOpen}
/>
)}
</div>
)}
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
@@ -972,9 +1061,10 @@ interface SidebarSectionHeaderProps {
onToggle: () => void
action?: React.ReactNode
meta?: React.ReactNode
icon?: React.ReactNode
}
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {
return (
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
@@ -982,6 +1072,7 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSe
onClick={onToggle}
type="button"
>
{icon}
<SidebarPanelLabel>{label}</SidebarPanelLabel>
{meta && <SidebarCount>{meta}</SidebarCount>}
<DisclosureCaret
@@ -1044,6 +1135,14 @@ interface SidebarSessionGroup {
totalCount?: number
}
interface MessagingSection {
sourceId: string
label: string
sessions: SessionInfo[]
total: number
hasMore: boolean
}
interface SidebarSessionsSectionProps {
label: string
open: boolean
@@ -1065,6 +1164,7 @@ interface SidebarSessionsSectionProps {
footer?: React.ReactNode
groups?: SidebarSessionGroup[]
labelMeta?: React.ReactNode
labelIcon?: React.ReactNode
sortable?: boolean
onReorder?: (event: DragEndEvent) => void
dndSensors?: ReturnType<typeof useSensors>
@@ -1091,6 +1191,7 @@ function SidebarSessionsSection({
footer,
groups,
labelMeta,
labelIcon,
sortable = false,
onReorder,
dndSensors
@@ -1181,6 +1282,7 @@ function SidebarSessionsSection({
inner = (
<VirtualSessionList
activeSessionId={activeSessionId}
className={contentClassName}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
@@ -1209,7 +1311,14 @@ function SidebarSessionsSection({
return (
<SidebarGroup className={rootClassName}>
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
<SidebarSectionHeader
action={headerAction}
icon={labelIcon}
label={label}
meta={labelMeta}
onToggle={onToggle}
open={open}
/>
{open && (
<SidebarGroupContent className={resolvedContentClassName}>
{body}
@@ -1398,30 +1507,3 @@ interface SortableSessionRowProps {
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
}
interface SidebarLoadMoreRowProps {
loading: boolean
onClick: () => void
step: number
}
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
{/* Seat the icon in the same w-3.5 column session rows use for their dot
so the chevron + label line up with the rows above. */}
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
</button>
)
}

View File

@@ -0,0 +1,30 @@
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
interface SidebarLoadMoreRowProps {
step: number
onClick: () => void
loading?: boolean
}
// "Load N more" affordance shared by the recents, messaging, and cron sections.
// The chevron sits in the same w-3.5 column the rows use for their dot, so it
// lines up with the list above.
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
</button>
)
}

View File

@@ -83,8 +83,9 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
// left, the colored named profiles scrolling between, and Manage pinned right.
// The active profile pops in its own color — the "where am I" cue. Single-
// profile users see only the "+" (create their first profile); everything else
// appears once a second profile exists.
// profile users see the "+" (create their first profile) and the Manage
// overflow (edit the default profile's SOUL.md); the colored named squares
// and the default↔all toggle only appear once a second profile exists.
export function ProfileRail() {
const { t } = useI18n()
const p = t.profiles
@@ -268,9 +269,11 @@ export function ProfileRail() {
</Tip>
</div>
{multiProfile && (
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
)}
{/* Always reachable, even with only the default profile: the manage
overlay is the only place to edit a profile's SOUL.md, and a
single-profile user must be able to edit the default's persona
without first creating a throwaway second profile. */}
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
{/* Land in the new profile on a fresh chat (selectProfile triggers the
new-session reset), not stuck on the session you were just in. */}

View File

@@ -21,6 +21,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { exportSession } from '@/lib/session-export'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
interface SessionActions {
sessionId: string
@@ -68,13 +69,26 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
}
},
...(canOpenSessionWindow()
? [
{
disabled: !sessionId,
icon: 'link-external',
label: r.newWindow,
onSelect: () => {
triggerHaptic('selection')
void openSessionInNewWindow(sessionId)
}
}
]
: []),
{
disabled: !sessionId,
icon: 'cloud-download',
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
void exportSession(sessionId, { profile, title })
}
},
{

View File

@@ -2,14 +2,18 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
@@ -67,6 +71,11 @@ export function SidebarSessionRow({
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
// A handed-off session's live source is local, but it originated on a
// messaging platform — surface that origin as a small badge so e.g. a
// Telegram thread continued here still reads as Telegram.
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
@@ -124,11 +133,15 @@ export function SidebarSessionRow({
return
}
if (event.metaKey || event.ctrlKey) {
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
// window — the universal "open in a new window" gesture. Archive
// lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed).
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onArchive()
void openSessionInNewWindow(session.id)
return
}
@@ -179,6 +192,15 @@ export function SidebarSessionRow({
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
{handoffSource && handoffLabel ? (
<Tip label={r.handoffOrigin(handoffLabel)}>
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
platformId={handoffSource}
platformName={handoffLabel}
/>
</Tip>
) : null}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>

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