Compare 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
loongfayandloongfay 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-zhyandClaude Fable 5 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-zhyandClaude Fable 5 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-zhyandClaude Fable 5 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
Kysstaandkyssta-exe 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
Kysstaandkyssta-exe 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 SmithandClaude Opus 4.7 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 SmithandClaude Opus 4.7 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
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 Pickettandflyinhigh 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 NicholsonandLeonSGP43 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 Fiebertandmollusk 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!andAJ 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!andAJ 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 PickettandCursor 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 PickettandCursor 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
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 PickettandCursor 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
xxxigmandBrooklyn Nicholson 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 BaradeiandOmar 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
emozilla bfcc9f92b4 Merge commit '6110aed9b' into feat/whatsapp-cloud-api 2026-06-10 21:39:22 -04: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
285 changed files with 23527 additions and 1524 deletions
+3
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)
+90 -2
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:
+20 -7
View File
@@ -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
+45
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, "
+51 -1
View File
@@ -1615,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")
@@ -1623,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):
@@ -2424,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 "
+14 -7
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
+65 -27
View File
@@ -40,9 +40,11 @@ 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 untouched.
Prompt-only; toolsets and the skill index untouched.
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema.
``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.
"""
@@ -104,13 +106,19 @@ _GIT_TIMEOUT = 2.5
# 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 prefer `patch` with `mode='patch'` (V4A multi-file diff) "
"for structured or multi-file changes — it's the diff format you handle "
"most reliably. Use `mode='replace'` for a single small swap.",
"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",
@@ -182,6 +190,10 @@ CODING_AGENT_GUIDANCE = (
"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 "
@@ -212,11 +224,13 @@ class ContextProfile:
``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).
``hidden_skill_categories`` — skill categories pruned from the system-prompt
skill index while this posture is active. Discovery-only:
nothing is disabled — ``skills_list`` still returns the
full catalog and ``skill_view`` loads anything. Deny-list
semantics so unknown/custom categories stay visible.
``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
@@ -224,14 +238,14 @@ class ContextProfile:
guidance: str = ""
model_hint: Optional[str] = None
memory_policy: str = "default"
hidden_skill_categories: tuple[str, ...] = ()
compact_skill_categories: tuple[str, ...] = ()
# Skill categories that are clearly not part of a coding workflow. Hidden from
# the prompt's skill index in the coding posture (deny-list — anything not
# listed here, incl. custom user categories, stays visible). Coding-adjacent
# categories (devops, github, mcp, data-science, diagramming, research,
# security, …) are intentionally absent.
# 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",
@@ -247,7 +261,7 @@ CODING_PROFILE = ContextProfile(
guidance=CODING_AGENT_GUIDANCE,
model_hint="coding",
memory_policy="project",
hidden_skill_categories=_NON_CODING_SKILL_CATEGORIES,
compact_skill_categories=_NON_CODING_SKILL_CATEGORIES,
)
_PROFILES: dict[str, ContextProfile] = {
@@ -432,9 +446,27 @@ class RuntimeMode:
blocks.append(workspace)
return blocks
def hidden_skill_categories(self) -> frozenset[str]:
"""Skill categories to prune from the prompt's skill index (may be empty)."""
return frozenset(self.profile.hidden_skill_categories)
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(
@@ -512,20 +544,23 @@ def coding_system_blocks(
).system_blocks()
def coding_hidden_skill_categories(
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 prunes from the prompt's skill index.
"""Skill categories the active posture demotes to names-only in the index.
Empty outside the coding posture. Discovery-only: hidden skills remain
loadable via ``skills_list`` / ``skill_view``.
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
).hidden_skill_categories()
).compact_skill_categories()
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
@@ -680,10 +715,13 @@ def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
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():
main_tree = Path(common_dir).resolve().parent
lines.append(f"- Worktree: linked (primary tree at {main_tree})")
lines.append("- Worktree: linked (git state shared with primary tree)")
dirty = [f"{n} {label}" for label, n in (
("staged", counts["staged"]), ("modified", counts["modified"]),
+101 -25
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):
+11 -1
View File
@@ -286,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
@@ -345,7 +355,7 @@ def evaluate_credits_notices(
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",
+52 -29
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. "
@@ -1101,7 +1118,7 @@ def _skill_should_show(
def build_skills_system_prompt(
available_tools: "set[str] | None" = None,
available_toolsets: "set[str] | None" = None,
hidden_categories: "frozenset[str] | None" = None,
compact_categories: "frozenset[str] | None" = None,
) -> str:
"""Build a compact skill index for the system prompt.
@@ -1117,11 +1134,11 @@ def build_skills_system_prompt(
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.
``hidden_categories`` (e.g. from the coding posture see
agent/coding_context.py) prunes whole categories from the rendered index.
Discovery-only: the snapshot stores everything, ``skills_list`` /
``skill_view`` still reach every skill, and a footer note tells the model
the full catalog exists.
``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)
@@ -1146,7 +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(hidden_categories or ())),
tuple(sorted(compact_categories or ())),
)
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
@@ -1280,38 +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 pruning (e.g. non-coding skills while pairing on
# code). Match on the top-level category segment so nested categories
# ("social-media/twitter") are pruned with their parent.
# 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 hidden_categories:
before = sum(len(v) for v in skills_by_category.values())
skills_by_category = {
cat: entries
for cat, entries in skills_by_category.items()
if cat.split("/", 1)[0] not in hidden_categories
}
pruned = before - sum(len(v) for v in skills_by_category.values())
if pruned:
hidden_note = (
f"\n(Note: {pruned} skill(s) in categories unrelated to the "
"current coding context are not listed here. The full catalog "
"is available via skills_list if the user asks for something "
"outside this list.)"
)
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
@@ -1412,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.",
+9 -7
View File
@@ -191,21 +191,23 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
if toolset
}
# Coding posture prunes non-coding skill categories from the index
# (discovery-only — skills_list/skill_view still reach everything).
_hidden_cats = frozenset()
# 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_hidden_skill_categories
from agent.coding_context import coding_compact_skill_categories
_hidden_cats = coding_hidden_skill_categories(
_compact_cats = coding_compact_skill_categories(
platform=agent.platform, cwd=resolve_context_cwd()
)
except Exception:
_hidden_cats = frozenset()
_compact_cats = frozenset()
skills_prompt = _r.build_skills_system_prompt(
available_tools=agent.valid_tool_names,
available_toolsets=avail_toolsets,
hidden_categories=_hidden_cats or None,
compact_categories=_compact_cats or None,
)
else:
skills_prompt = ""
+101
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
}
@@ -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'
)
})
+99
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
}
@@ -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/)
})
+109
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
}
+364
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
)
})
+54
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
}
+40
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)
})
+116 -29
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
}
+150
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)
})
+199 -119
View File
@@ -22,15 +22,20 @@ const http = require('node:http')
const https = require('node:https')
const net = require('node:net')
const path = require('node:path')
const { fileURLToPath, pathToFileURL } = require('node:url')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { PortPool } = require('./port-pool.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv } = require('./backend-env.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const {
OFFICIAL_REPO_HTTPS_URL,
isOfficialSshRemote
@@ -65,6 +70,7 @@ const {
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret: encryptDesktopSecretStrict,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs
} = require('./hardening.cjs')
@@ -90,6 +96,7 @@ try {
nodePty = require(nodePtyDir)
}
} catch {
console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`)
nodePty = null
nodePtyDir = null
}
@@ -104,6 +111,10 @@ if (USER_DATA_OVERRIDE) {
const PORT_FLOOR = 9120
const PORT_CEILING = 9199
// In-process port reservations that close the pickPort() TOCTOU window where
// two concurrent backend spawns could be handed the same port. See
// port-pool.cjs for the full rationale.
const portPool = new PortPool(PORT_FLOOR, PORT_CEILING)
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
const IS_PACKAGED = app.isPackaged
const IS_MAC = process.platform === 'darwin'
@@ -730,7 +741,7 @@ function openExternalUrl(rawUrl) {
if (parsed.protocol === 'file:') {
let localPath
try {
localPath = fileURLToPath(parsed.toString())
localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' })
} catch {
return false
}
@@ -2124,9 +2135,11 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
label,
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: {
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
},
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [root],
venvRoot: path.join(root, 'venv')
}),
root,
bootstrap: Boolean(options.bootstrap),
shell: false
@@ -2145,9 +2158,11 @@ function createActiveBackend(dashboardArgs) {
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
command: fileExists(venvPython) ? venvPython : findSystemPython(),
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: {
PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
},
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [ACTIVE_HERMES_ROOT],
venvRoot: VENV_ROOT
}),
root: ACTIVE_HERMES_ROOT,
bootstrap: true,
shell: false
@@ -2449,10 +2464,11 @@ function isPortAvailable(port) {
}
async function pickPort() {
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
if (await isPortAvailable(port)) return port
const port = await portPool.reserve(isPortAvailable)
if (port === null) {
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
}
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
return port
}
function fetchJson(url, token, options = {}) {
@@ -2878,10 +2894,10 @@ async function resourceBufferFromUrl(rawUrl) {
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
return { buffer, mimeType }
}
if (rawUrl.startsWith('file:')) {
const filePath = fileURLToPath(rawUrl)
const buffer = await fs.promises.readFile(filePath)
return { buffer, mimeType: mimeTypeForPath(filePath) }
if (/^file:/i.test(rawUrl)) {
const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' })
const buffer = await fs.promises.readFile(resolvedPath)
return { buffer, mimeType: mimeTypeForPath(resolvedPath) }
}
const parsed = new URL(rawUrl)
@@ -2959,11 +2975,13 @@ function expandUserPath(filePath) {
return value
}
function previewFileTarget(rawTarget, baseDir) {
async function previewFileTarget(rawTarget, baseDir) {
const raw = String(rawTarget || '').trim()
const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd()
const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw))
let resolved = filePath
let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), {
baseDir: base,
purpose: 'Preview target'
})
if (directoryExists(resolved)) {
resolved = path.join(resolved, 'index.html')
@@ -2974,6 +2992,8 @@ function previewFileTarget(rawTarget, baseDir) {
return null
}
;({ resolvedPath: resolved } = await resolveReadableFileForIpc(resolved, { purpose: 'Preview target' }))
const mimeType = mimeTypeForPath(resolved)
const metadata = previewFileMetadata(resolved, mimeType)
const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext)
@@ -3019,7 +3039,7 @@ function previewUrlTarget(rawTarget) {
}
}
function normalizePreviewTarget(rawTarget, baseDir) {
async function normalizePreviewTarget(rawTarget, baseDir) {
const raw = String(rawTarget || '').trim()
if (!raw) {
@@ -3031,20 +3051,15 @@ function normalizePreviewTarget(rawTarget, baseDir) {
return previewUrlTarget(raw)
}
return previewFileTarget(raw, baseDir)
return await previewFileTarget(raw, baseDir)
} catch {
return null
}
}
function filePathFromPreviewUrl(rawUrl) {
const filePath = fileURLToPath(String(rawUrl || ''))
if (!fileExists(filePath)) {
throw new Error('Preview file is not readable')
}
return filePath
async function filePathFromPreviewUrl(rawUrl) {
const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' })
return resolvedPath
}
function sendPreviewFileChanged(payload) {
@@ -3054,8 +3069,8 @@ function sendPreviewFileChanged(payload) {
webContents.send('hermes:preview-file-changed', payload)
}
function watchPreviewFile(rawUrl) {
const filePath = filePathFromPreviewUrl(rawUrl)
async function watchPreviewFile(rawUrl) {
const filePath = await filePathFromPreviewUrl(rawUrl)
const watchDir = path.dirname(filePath)
const targetName = path.basename(filePath)
const id = crypto.randomBytes(12).toString('base64url')
@@ -4537,9 +4552,20 @@ async function spawnPoolBackend(profile, entry) {
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
let backend
let hermesCwd
let webDist
try {
backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
hermesCwd = resolveHermesCwd()
webDist = resolveWebDist()
} catch (error) {
// These run before the child exists / its exit handler is attached, so a
// throw here would otherwise leak the reservation and slowly exhaust the
// 9120-9199 range across switch cycles in one app session.
portPool.release(port)
throw error
}
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
@@ -4577,11 +4603,13 @@ async function spawnPoolBackend(profile, entry) {
child.once('error', error => {
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
backendPool.delete(profile)
portPool.release(port)
rejectStart?.(error)
})
child.once('exit', (code, signal) => {
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
backendPool.delete(profile)
portPool.release(port)
if (!ready) {
rejectStart?.(
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
@@ -4592,15 +4620,21 @@ async function spawnPoolBackend(profile, entry) {
const baseUrl = `http://127.0.0.1:${port}`
await Promise.race([waitForHermes(baseUrl, token), startFailed])
ready = true
const authToken = await adoptServedDashboardToken(baseUrl, token, {
childAlive: () => child.exitCode === null && !child.killed,
label: `Hermes backend for profile "${profile}"`,
rememberLog
})
entry.token = authToken
return {
baseUrl,
mode: 'local',
source: 'local',
authMode: 'token',
token,
token: authToken,
profile,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4610,6 +4644,7 @@ function stopPoolBackend(profile) {
const entry = backendPool.get(profile)
if (!entry) return
backendPool.delete(profile)
if (entry.port) portPool.release(entry.port)
if (entry.process && !entry.process.killed) {
try {
entry.process.kill('SIGTERM')
@@ -4695,6 +4730,11 @@ async function startHermes() {
}
if (connectionPromise) return connectionPromise
// Hoisted so the outer .catch can release a port reserved by pickPort() when
// a throw (e.g. ensureRuntime failing) happens before the child's exit
// handler is attached. Stays null on the remote path (no port picked).
let reservedPort = null
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
@@ -4724,6 +4764,7 @@ async function startHermes() {
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
const port = await pickPort()
reservedPort = port
const token = crypto.randomBytes(32).toString('base64url')
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
// Pin the desktop's chosen profile via the global --profile flag. This is
@@ -4788,6 +4829,7 @@ async function startHermes() {
)
hermesProcess = null
connectionPromise = null
portPool.release(port)
sendBackendExit({ code: null, signal: null, error: error.message })
rejectBackendStart?.(error)
})
@@ -4795,6 +4837,7 @@ async function startHermes() {
rememberLog(`Hermes backend exited (${signal || code})`)
hermesProcess = null
connectionPromise = null
portPool.release(port)
sendBackendExit({ code, signal })
if (!backendReady) {
const message = `Hermes backend exited before it became ready (${signal || code}).`
@@ -4819,6 +4862,11 @@ async function startHermes() {
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
const authToken = await adoptServedDashboardToken(baseUrl, token, {
// The exit/error handlers null hermesProcess when the child dies.
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
rememberLog
})
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
@@ -4832,8 +4880,8 @@ async function startHermes() {
mode: 'local',
source: 'local',
authMode: 'token',
token,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
token: authToken,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4849,6 +4897,7 @@ async function startHermes() {
{ allowDecrease: true }
)
connectionPromise = null
portPool.release(reservedPort)
throw error
})
@@ -5123,8 +5172,8 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
// reset connection state so the next startHermes() call restarts the
// full backend flow (including a fresh runBootstrap pass).
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
await teardownPrimaryBackendAndWait()
bootstrapFailure = null
connectionPromise = null
bootstrapState = {
active: false,
manifest: null,
@@ -5587,48 +5636,6 @@ ipcMain.handle('hermes:logs:reveal', async () => {
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
// Always-hidden noise (covers non-git projects too — gitignore would catch
// these anyway when present, but we want 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 findGitRoot(start) {
let dir = start
for (let i = 0; i < 50; i += 1) {
try {
if (fs.existsSync(path.join(dir, '.git'))) {
return dir
}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
return null
}
function isExecutableFile(filePath) {
if (!filePath || !path.isAbsolute(filePath)) {
return false
@@ -5811,46 +5818,9 @@ function disposeTerminalSession(id) {
return true
}
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
const resolved = path.resolve(String(dirPath || ''))
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
if (!resolved) {
return { entries: [], error: 'invalid-path' }
}
try {
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true })
const entries = dirents
.filter(d => {
if (FS_READDIR_HIDDEN.has(d.name)) {
return false
}
return true
})
.map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() }))
.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' }
}
})
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
const input = String(startPath || '')
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
try {
const stat = await fs.promises.stat(resolved)
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
return findGitRoot(start)
} catch {
return findGitRoot(resolved)
}
})
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
@@ -6188,6 +6158,111 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla
// Search the Marketplace for color-theme extensions (empty query = top installs).
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))
// ---------------------------------------------------------------------------
// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00).
// A docs/dashboard "Send to App" button opens this URL; we route it into the
// running app's chat composer. Three delivery paths: macOS 'open-url',
// Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv.
// ---------------------------------------------------------------------------
const HERMES_PROTOCOL = 'hermes'
let _pendingDeepLink = null
let _rendererReadyForDeepLink = false
function _extractDeepLink(argv) {
if (!Array.isArray(argv)) return null
return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
}
function handleDeepLink(url) {
if (!url || typeof url !== 'string') return
let parsed
try {
parsed = new URL(url)
} catch {
rememberLog(`[deeplink] ignoring malformed url: ${url}`)
return
}
// hermes://blueprint/<key>?slot=val -> host="blueprint", path="/<key>"
const kind = parsed.hostname || ''
const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, ''))
const params = {}
parsed.searchParams.forEach((v, k) => {
params[k] = v
})
const payload = { kind, name, params }
if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) {
_pendingDeepLink = payload
return
}
try {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
mainWindow.webContents.send('hermes:deep-link', payload)
rememberLog(`[deeplink] delivered ${kind}/${name}`)
} catch (err) {
rememberLog(`[deeplink] delivery failed: ${err.message}`)
}
}
// Renderer calls this (via IPC) once it has mounted its deep-link listener, so
// a link that arrived during boot/install is flushed exactly once.
ipcMain.handle('hermes:deep-link-ready', () => {
_rendererReadyForDeepLink = true
if (_pendingDeepLink) {
const queued = _pendingDeepLink
_pendingDeepLink = null
handleDeepLink(
`${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
(Object.keys(queued.params).length
? '?' + new URLSearchParams(queued.params).toString()
: ''),
)
}
return { ok: true }
})
function registerDeepLinkProtocol() {
try {
if (process.defaultApp && process.argv.length >= 2) {
// Dev: register with the electron exec path + entry script so the OS can
// relaunch us with the URL.
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [
path.resolve(process.argv[1]),
])
} else {
app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
}
} catch (err) {
rememberLog(`[deeplink] protocol registration failed: ${err.message}`)
}
}
// Single-instance lock: deep links on a running app (Win/Linux) arrive as a
// second-instance argv. Without the lock a second `hermes://` launch spawns a
// whole new app instead of routing into the running one.
const _gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!_gotSingleInstanceLock) {
app.quit()
} else {
app.on('second-instance', (_event, argv) => {
const url = _extractDeepLink(argv)
if (url) handleDeepLink(url)
else if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
}
// macOS delivers deep links via 'open-url' — register early (can fire before
// whenReady; handleDeepLink queues until the renderer is ready).
app.on('open-url', (event, url) => {
event.preventDefault()
handleDeepLink(url)
})
app.whenReady().then(() => {
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
@@ -6196,11 +6271,16 @@ app.whenReady().then(() => {
}
installMediaPermissions()
registerMediaProtocol()
registerDeepLinkProtocol()
ensureWslWindowsFonts()
configureSpellChecker()
registerPowerResumeListeners()
createWindow()
// Win/Linux cold start: the launching hermes:// URL is in our own argv.
const _coldStartLink = _extractDeepLink(process.argv)
if (_coldStartLink) handleDeepLink(_coldStartLink)
app.on('activate', () => {
// Recreate the primary window if it's gone. Guard on mainWindow directly
// (not just total window count) so a dock click still restores the main
+73
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 }
+77
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)
})
+6
View File
@@ -80,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)
@@ -8,7 +8,7 @@ const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
}
function requireHiddenChildOptions(source, needle) {
+14 -3
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,7 +36,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-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 electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/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",
@@ -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,7 +106,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
"@types/node": "^24.12.0",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
@@ -132,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": {
+3 -3
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) => {
+10 -2
View File
@@ -1630,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
@@ -1649,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>
)
@@ -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
@@ -448,7 +450,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
if (isImage) {
// 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 window.hermesDesktop.readFileDataUrl(filePath))
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
if (active) {
setState({ dataUrl, loading: false })
@@ -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', () => {
@@ -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
) {
+8 -1
View File
@@ -797,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 &&
@@ -88,7 +88,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
void exportSession(sessionId, { profile, title })
}
},
{
@@ -8,7 +8,7 @@ import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/ap
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { KbdGroup } from '@/components/ui/kbd'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import {
@@ -119,7 +119,7 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
}
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
id: session.id,
@@ -218,13 +218,13 @@ export function CommandPalette() {
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
queryFn: () => listSessions(200, 1, 'exclude'),
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
enabled: open
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
queryFn: () => listSessions(200, 0, 'only'),
queryFn: () => listAllProfileSessions(200, 0, 'only'),
enabled: open
})
+29 -1
View File
@@ -11,6 +11,7 @@ import { Pane, PaneMain } from '@/components/pane-shell'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useSkinCommand } from '@/themes/use-skin-command'
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
@@ -266,6 +267,31 @@ export function DesktopController() {
}
}, [])
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
// Build the equivalent /blueprint slash command from the payload and drop
// it into the composer — the user reviews/edits, then sends; the agent (or
// the shared command handler) creates the job. Signal readiness so a link
// that arrived during boot is flushed exactly once.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => {
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
return
}
const slots = Object.entries(payload.params || {})
.map(([k, v]) => {
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
return `${k}=${sval}`
})
.join(' ')
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main')
})
// Tell the main process the renderer is ready to receive deep links.
void window.hermesDesktop?.signalDeepLinkReady?.()
return () => unsubscribe?.()
}, [])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
@@ -521,7 +547,9 @@ export function DesktopController() {
return
}
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
const storedProfile = $sessions
.get()
.find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { desktopDefaultCwd } from '@/lib/desktop-fs'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
@@ -25,12 +26,16 @@ import {
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$sessions,
$workingSessionIds,
ensureDefaultWorkspaceCwd,
setConnection,
setCurrentBranch,
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
@@ -353,6 +358,11 @@ export function useGatewayBoot({
progress: 97
})
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {
@@ -0,0 +1,27 @@
import { createDragDropManager, type DragDropManager } from 'dnd-core'
import { HTML5Backend } from 'react-dnd-html5-backend'
let manager: DragDropManager | null = null
/**
* A single, app-lifetime react-dnd manager for the file tree.
*
* react-arborist mounts its own react-dnd `DndProvider` with `HTML5Backend`
* inside every `<Tree>`. react-dnd v14 stores that provider's manager on a
* global, ref-counted singleton context and nulls it when the count hits 0.
* On a keyed remount (cwd / collapse changes force a fresh `<Tree>`), the
* singleton can be torn down and recreated while the previous `HTML5Backend`
* still owns the `window.__isReactDndHtml5Backend` setup flag so the new
* backend's `setup()` throws "Cannot have two HTML5 backends at the same
* time." and trips the file-tree error boundary (it never recovers, because
* "Try again" just remounts into the same race).
*
* Passing arborist a stable `dndManager` makes it skip the global-singleton
* path entirely and reuse one backend for the lifetime of the app, so the
* window flag is never double-claimed.
*/
export function getFileTreeDndManager(): DragDropManager {
manager ??= createDragDropManager(HTML5Backend)
return manager
}
@@ -0,0 +1,100 @@
/// <reference types="node" />
import { Buffer } from 'node:buffer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
import { clearProjectDirCache, readProjectDir } from './ipc'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const readFileDataUrl = vi.fn<(path: string) => Promise<string>>()
const gitRoot = vi.fn<(path: string) => Promise<string | null>>()
function ok(entries: HermesReadDirEntry[]): HermesReadDirResult {
return { entries }
}
function dataUrl(text: string) {
return `data:text/plain;base64,${Buffer.from(text, 'utf8').toString('base64')}`
}
function installBridge() {
;(
window as unknown as {
hermesDesktop: {
gitRoot: typeof gitRoot
readDir: typeof readDir
readFileDataUrl: typeof readFileDataUrl
}
}
).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
}
describe('readProjectDir', () => {
beforeEach(() => {
clearProjectDirCache()
readDir.mockReset()
readFileDataUrl.mockReset()
gitRoot.mockReset()
installBridge()
})
afterEach(() => {
clearProjectDirCache()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('returns no-bridge when the desktop bridge is unavailable', async () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
await expect(readProjectDir('/repo')).resolves.toEqual({ entries: [], error: 'no-bridge' })
})
it('filters gitignored entries when readDir returns Windows-style paths', async () => {
gitRoot.mockResolvedValue('C:\\repo')
readDir.mockImplementation(async path => {
if (path === 'C:\\repo\\src') {
return ok([
{ name: 'debug.log', path: 'C:\\repo\\src\\debug.log', isDirectory: false },
{ name: '临时.txt', path: 'C:\\repo\\src\\临时.txt', isDirectory: false },
{ name: 'keep.ts', path: 'C:\\repo\\src\\keep.ts', isDirectory: false }
])
}
if (path === 'C:/repo') {
return ok([{ name: '.gitignore', path: 'C:/repo/.gitignore', isDirectory: false }])
}
if (path === 'C:/repo/src') {
return ok([])
}
return ok([])
})
readFileDataUrl.mockResolvedValue(dataUrl('# Unicode 路径规则\nsrc/*.log\nsrc/临时.txt\n'))
const result = await readProjectDir('C:\\repo\\src', 'C:\\repo')
expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts'])
expect(gitRoot).toHaveBeenCalledWith('C:/repo')
expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore')
})
it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => {
gitRoot.mockResolvedValue('/repo')
readDir.mockImplementation(async path => {
if (path === '/repo/src') {
return ok([{ name: 'debug.log', path: '/repo/src/debug.log', isDirectory: false }])
}
return ok([])
})
const result = await readProjectDir('/repo/src', '/repo')
expect(result.entries.map(entry => entry.name)).toEqual(['debug.log'])
expect(readFileDataUrl).not.toHaveBeenCalled()
})
})
+12 -18
View File
@@ -1,5 +1,6 @@
import ignore from 'ignore'
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
export type ProjectTreeEntry = HermesReadDirEntry
@@ -27,7 +28,7 @@ function decodeDataUrl(dataUrl: string) {
}
function clean(path: string) {
return path.replace(/\/+$/, '') || '/'
return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
}
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
}
async function gitRootFor(start: string) {
if (!window.hermesDesktop?.gitRoot) {
return null
}
const key = clean(start)
const key = `${desktopFsCacheKey()}:${clean(start)}`
let cached = gitRootCache.get(key)
if (!cached) {
cached = window.hermesDesktop.gitRoot(key)
cached = desktopGitRoot(start)
gitRootCache.set(key, cached)
}
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
return null
}
try {
const listing = await window.hermesDesktop.readDir(dir)
const listing = await readDesktopDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
@@ -100,11 +93,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> {
}
async function gitignoreFor(dir: string) {
const key = clean(dir)
const key = `${desktopFsCacheKey()}:${clean(dir)}`
let cached = gitignoreCache.get(key)
if (!cached) {
cached = readGitignore(key)
cached = readGitignore(clean(dir))
gitignoreCache.set(key, cached)
}
@@ -142,9 +135,10 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
return { entries: [], error: 'no-bridge' }
}
const result = await window.hermesDesktop.readDir(dirPath)
const result = await readDesktopDir(dirPath)
const entries = result?.entries ?? []
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
}
export function clearProjectDirCache(rootPath?: string) {
@@ -155,7 +149,7 @@ export function clearProjectDirCache(rootPath?: string) {
return
}
const key = clean(rootPath)
const key = `${desktopFsCacheKey()}:${clean(rootPath)}`
gitRootCache.delete(key)
gitignoreCache.delete(key)
}
@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
function clean(path: string) {
return path.replace(/\/+$/, '') || '/'
}
function parentDir(path: string) {
const value = clean(path)
if (value === '/') {
return '/'
}
const parent = value.slice(0, value.lastIndexOf('/'))
return parent || '/'
}
function pathName(path: string) {
return path.split('/').filter(Boolean).pop() || path
}
interface PendingSelection {
defaultPath: string
resolve: (paths: string[]) => void
title: string
}
export function RemoteFolderPicker() {
const { t } = useI18n()
const r = t.rightSidebar
const [pending, setPending] = useState<PendingSelection | null>(null)
const [currentPath, setCurrentPath] = useState('/')
const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
setDesktopFsRemotePicker({
selectPaths: options =>
new Promise(resolve => {
const defaultPath = clean(options?.defaultPath || '/')
setCurrentPath(defaultPath)
setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
})
})
return () => setDesktopFsRemotePicker(null)
}, [r.remotePickerTitle])
useEffect(() => {
if (!pending) {
return
}
let active = true
setLoading(true)
setError(null)
void readDesktopDir(currentPath)
.then(result => {
if (!active) {
return
}
if (result.error) {
setError(result.error)
setEntries([])
return
}
setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
})
.catch(err => {
if (active) {
setError(err instanceof Error ? err.message : String(err))
setEntries([])
}
})
.finally(() => {
if (active) {
setLoading(false)
}
})
return () => {
active = false
}
}, [currentPath, pending])
const crumbs = useMemo(() => {
const parts = clean(currentPath).split('/').filter(Boolean)
const out = [{ label: '/', path: '/' }]
let acc = ''
for (const part of parts) {
acc += `/${part}`
out.push({ label: part, path: acc })
}
return out
}, [currentPath])
const close = (paths: string[] = []) => {
pending?.resolve(paths)
setPending(null)
setEntries([])
setError(null)
}
return (
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
<div className="border-b border-border/70 px-4 py-3">
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
</div>
<div className="flex min-h-[22rem] flex-col">
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
{crumbs.map((crumb, index) => (
<button
className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}
key={crumb.path}
onClick={() => setCurrentPath(crumb.path)}
type="button"
>
{crumb.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} />
{loading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Codicon name="loading" size="0.8rem" spinning />
{r.loadingFiles}
</div>
) : error ? (
<div className="px-2 py-3 text-xs text-destructive">{r.unreadableBody(error)}</div>
) : entries.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div>
) : (
entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)
)}
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => close()} size="sm" variant="ghost">
{t.common.cancel}
</Button>
<Button onClick={() => close([currentPath])} size="sm">
{r.remotePickerSelect}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
return (
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
disabled={disabled}
onClick={onClick}
type="button"
>
<Codicon name="folder" size="0.875rem" />
<span className="min-w-0 truncate">{name}</span>
</button>
)
}
@@ -7,6 +7,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { getFileTreeDndManager } from './dnd-manager'
import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 22
@@ -94,6 +95,7 @@ export function ProjectTree({
disableDrag
disableDrop
disableEdit
dndManager={getFileTreeDndManager()}
height={size.height}
indent={INDENT}
initialOpenState={openState}
@@ -145,7 +147,8 @@ function ProjectTreeRow({
}
const isFolder = node.data.isDirectory
const isPlaceholder = node.data.id.endsWith('::__loading__')
const isPlaceholder = Boolean(node.data.placeholder)
const isErrorPlaceholder = node.data.placeholder === 'error'
return (
<div
@@ -210,8 +213,10 @@ function ProjectTreeRow({
)}
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
{isPlaceholder ? (
{isPlaceholder && !isErrorPlaceholder ? (
<Codicon name="loading" size="0.75rem" spinning />
) : isErrorPlaceholder ? (
<Codicon name="warning" size="0.75rem" />
) : isFolder ? (
<Codicon name={node.isOpen ? 'folder-opened' : 'folder'} size="0.875rem" />
) : (
@@ -1,19 +1,24 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import type { HermesReadDirResult } from '@/global'
import { clearProjectDirCache, readProjectDir } from './ipc'
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
readDir.mockReset()
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
})
afterEach(() => {
cleanup()
$connection.set(null)
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
@@ -106,7 +111,37 @@ describe('useProjectTree', () => {
expect(readDir).toHaveBeenCalledTimes(1)
})
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
it('reads gitignore from the real path while caching per connection', async () => {
const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
const gitRoot = vi.fn(async () => '/repo')
readDir.mockImplementation(async path => {
if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
if (path === '/repo/src') {
return ok([
{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
{ name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
])
}
throw new Error(`unexpected path ${path}`)
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
$connection.set({ baseUrl: 'local-a', mode: 'local' } as never)
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir).toHaveBeenCalledWith('/repo')
expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))
$connection.set({ baseUrl: 'local-b', mode: 'local' } as never)
clearProjectDirCache()
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)
})
it('captures per-folder error code and shows an error placeholder child', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
@@ -119,7 +154,14 @@ describe('useProjectTree', () => {
})
expect(result.current.data[0].error).toBe('EACCES')
expect(result.current.data[0].children).toEqual([])
expect(result.current.data[0].children).toEqual([
{
id: '/p/priv::__error__',
isDirectory: false,
name: 'Unable to read (EACCES)',
placeholder: 'error'
}
])
})
it('dedupes concurrent loadChildren calls for the same id', async () => {
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { $connection } from '@/store/session'
import { clearProjectDirCache, readProjectDir } from './ipc'
export interface TreeNode {
@@ -14,11 +16,14 @@ export interface TreeNode {
children?: TreeNode[]
/** True while a readDir for this folder is in flight. */
loading?: boolean
/** Synthetic loading/error rows are not real filesystem entries. */
placeholder?: 'error' | 'loading'
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
error?: string
}
const PLACEHOLDER_ID = '__loading__'
const ERROR_PLACEHOLDER_ID = '__error__'
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
return { id: path, isDirectory, name }
@@ -43,7 +48,16 @@ function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n:
}
function placeholderChild(parentId: string): TreeNode {
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…', placeholder: 'loading' }
}
function errorChild(parentId: string, error: string | undefined): TreeNode {
return {
id: `${parentId}::${ERROR_PLACEHOLDER_ID}`,
isDirectory: false,
name: `Unable to read (${error || 'read-error'})`,
placeholder: 'error'
}
}
export interface UseProjectTreeResult {
@@ -84,6 +98,7 @@ const initialState: ProjectTreeState = {
const inflight = new Set<string>()
const $projectTree = atom<ProjectTreeState>(initialState)
let nextRootRequestId = 0
let lastConnectionKey = ''
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
$projectTree.set(updater($projectTree.get()))
@@ -145,6 +160,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
}
export function resetProjectTreeState() {
lastConnectionKey = ''
clearProjectTree()
clearProjectDirCache()
}
@@ -158,6 +174,8 @@ export function resetProjectTreeState() {
*/
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
const connection = useStore($connection)
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
@@ -227,7 +245,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
...n,
loading: false,
error: error || undefined,
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
children: error ? [errorChild(n.id, error)] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
}))
}
})
@@ -236,8 +254,15 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
)
useEffect(() => {
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
lastConnectionKey = connectionKey
if (connectionChanged) {
clearProjectDirCache()
void loadRoot(cwd, { force: true })
return
}
void loadRoot(cwd)
}, [cwd])
}, [connectionKey, cwd])
return useMemo(
() => ({
+5 -1
View File
@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { selectDesktopPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@@ -16,6 +17,7 @@ import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { RemoteFolderPicker } from './files/remote-picker'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
@@ -54,7 +56,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const canCollapse = Object.values(openState).some(Boolean)
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
const selected = await selectDesktopPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
@@ -90,6 +92,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
<RemoteFolderPicker />
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
@@ -315,8 +315,11 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
allowTransparency: true,
convertEol: true,
cursorBlink: true,
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
fontSize: 11,
fontWeight: '400',
fontWeightBold: '700',
letterSpacing: 0,
lineHeight: 1.12,
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
@@ -598,13 +601,13 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
startSession()
}
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
// fonts.ready settles only already-requested faces; bold/italic aren't asked
// for until styled output paints (past atlas init), so warm them up front.
const warm = document.fonts?.load
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
: Promise.resolve()
if (fonts?.ready) {
void fonts.ready.then(mount, mount)
} else {
mount()
}
void warm.then(mount, mount)
return () => {
disposed = true
@@ -64,6 +64,67 @@ interface QueuedStreamDeltas {
reasoning: string
}
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
| 'branch'
| 'cwd'
| 'fast'
| 'model'
| 'personality'
| 'provider'
| 'reasoningEffort'
| 'serviceTier'
| 'yolo'
>
>
function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): SessionRuntimeStatePatch {
const patch: SessionRuntimeStatePatch = {}
if (typeof payload?.model === 'string') {
patch.model = payload.model || ''
}
if (typeof payload?.provider === 'string') {
patch.provider = payload.provider || ''
}
if (typeof payload?.cwd === 'string') {
patch.cwd = payload.cwd
}
if (typeof payload?.branch === 'string') {
patch.branch = payload.branch
}
if (typeof payload?.personality === 'string') {
patch.personality = normalizePersonalityValue(payload.personality)
}
if (typeof payload?.reasoning_effort === 'string') {
patch.reasoningEffort = payload.reasoning_effort
}
if (typeof payload?.service_tier === 'string') {
patch.serviceTier = payload.service_tier
}
if (typeof payload?.fast === 'boolean') {
patch.fast = payload.fast
}
if (typeof payload?.yolo === 'boolean') {
patch.yolo = payload.yolo
}
return patch
}
function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boolean {
return Object.keys(patch).length > 0
}
// Minimum gap between two assistant-text flushes during a stream. Was 16ms
// (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every
// token got its own React commit + Streamdown markdown re-parse, scaling
@@ -628,36 +689,27 @@ export function useMessageStream({
// Apply session-scoped fields when the event targets the active
// session, OR when it's a global broadcast and we have no session.
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
const statePatch = sessionInfoStatePatch(payload)
const hasStatePatch = hasSessionInfoStatePatch(statePatch)
const modelChanged = typeof payload?.model === 'string'
const providerChanged = typeof payload?.provider === 'string'
const runningChanged = typeof payload?.running === 'boolean'
if (apply) {
const runtimeInfo: Partial<
Pick<
ClientSessionState,
'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
>
> = {}
if (modelChanged) {
setCurrentModel(payload!.model || '')
runtimeInfo.model = payload!.model || ''
}
if (providerChanged) {
setCurrentProvider(payload!.provider || '')
runtimeInfo.provider = payload!.provider || ''
}
if (typeof payload?.cwd === 'string') {
setCurrentCwd(payload.cwd)
runtimeInfo.cwd = payload.cwd
}
if (typeof payload?.branch === 'string') {
setCurrentBranch(payload.branch)
runtimeInfo.branch = payload.branch
}
if (typeof payload?.personality === 'string') {
@@ -666,28 +718,31 @@ export function useMessageStream({
if (typeof payload?.reasoning_effort === 'string') {
setCurrentReasoningEffort(payload.reasoning_effort)
runtimeInfo.reasoningEffort = payload.reasoning_effort
}
if (typeof payload?.service_tier === 'string') {
setCurrentServiceTier(payload.service_tier)
runtimeInfo.serviceTier = payload.service_tier
}
if (typeof payload?.fast === 'boolean') {
setCurrentFastMode(payload.fast)
runtimeInfo.fast = payload.fast
}
if (typeof payload?.yolo === 'boolean') {
setYoloActive(payload.yolo)
runtimeInfo.yolo = payload.yolo
}
}
if (sessionId && Object.keys(runtimeInfo).length > 0) {
updateSessionState(sessionId, state => ({ ...state, ...runtimeInfo }))
}
if (sessionId && hasStatePatch) {
updateSessionState(sessionId, state => ({
...state,
...statePatch,
branch: statePatch.branch ?? state.branch,
cwd: statePatch.cwd ?? state.cwd
}))
}
if (apply) {
if (runningChanged && sessionId) {
updateSessionState(sessionId, state => {
const busy = Boolean(payload!.running)
@@ -878,6 +933,8 @@ export function useMessageStream({
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
setApprovalRequest({
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: payload?.allow_permanent !== false,
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
sessionId: sessionId ?? null
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
@@ -43,7 +43,7 @@ import {
workspaceCwdForNewSession
} from '@/store/session'
import { reportBackendContract } from '@/store/updates'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../types'
@@ -209,16 +209,67 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
}
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial<
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
> | null {
function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
}
function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
const lineage = session._lineage_root_id ?? session.id
setSessions(prev => [
session,
...prev.filter(existing => {
if (sessionMatchesStoredId(existing, storedSessionId)) {
return false
}
return (existing._lineage_root_id ?? existing.id) !== lineage
})
])
}
async function resolveStoredSession(storedSessionId: string): Promise<SessionInfo | undefined> {
const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (cached) {
return cached
}
try {
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
if (resolved) {
upsertResolvedSession(resolved, storedSessionId)
}
return resolved
} catch {
return undefined
}
}
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
| 'branch'
| 'cwd'
| 'fast'
| 'model'
| 'personality'
| 'provider'
| 'reasoningEffort'
| 'serviceTier'
| 'yolo'
>
>
function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
if (!info) {
return null
}
const sessionState: Partial<
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
> = {}
const sessionState: SessionRuntimeStatePatch = {}
reportBackendContract(info.desktop_contract)
@@ -226,12 +277,12 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
requestDesktopOnboarding(info.credential_warning)
}
if (info.model) {
if (typeof info.model === 'string') {
setCurrentModel(info.model)
sessionState.model = info.model
}
if (info.provider) {
if (typeof info.provider === 'string') {
setCurrentProvider(info.provider)
sessionState.provider = info.provider
}
@@ -247,7 +298,9 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
}
if (typeof info.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(info.personality))
const personality = normalizePersonalityValue(info.personality)
setCurrentPersonality(personality)
sessionState.personality = personality
}
if (typeof info.reasoning_effort === 'string') {
@@ -277,6 +330,16 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
return sessionState
}
function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) {
setCurrentModel(stored?.model || '')
setCurrentProvider('')
setCurrentReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
setYoloActive(false)
setCurrentPersonality('')
}
export function useSessionActions({
activeSessionId,
activeSessionIdRef,
@@ -457,23 +520,41 @@ export function useSessionActions({
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
const storedForProfile = await resolveStoredSession(storedSessionId)
const sessionProfile = storedForProfile?.profile
if (resumeRequestRef.current !== requestId) {
return
}
await ensureGatewayProfile(sessionProfile)
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
if (cachedRuntimeId && cachedState) {
const stored = $sessions.get().find(session => session.id === storedSessionId)
const cachedViewState =
!cachedState.model && stored?.model != null
? {
...cachedState,
model: stored.model || ''
}
: cachedState
if (cachedViewState !== cachedState) {
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
}
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setActiveSessionId(cachedRuntimeId)
activeSessionIdRef.current = cachedRuntimeId
syncSessionStateToView(cachedRuntimeId, cachedState)
setCurrentCwd(cachedState.cwd)
setCurrentBranch(cachedState.branch)
syncSessionStateToView(cachedRuntimeId, cachedViewState)
setCurrentCwd(cachedViewState.cwd)
setCurrentBranch(cachedViewState.branch)
setSessionStartedAt(Date.now())
try {
@@ -513,7 +594,8 @@ export function useSessionActions({
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setSessionStartedAt(Date.now())
const stored = $sessions.get().find(session => session.id === storedSessionId)
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
applyStoredSessionPreviewRuntimeInfo(stored)
if (stored) {
setCurrentUsage(current => ({
@@ -762,7 +844,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
const removed = $sessions.get().find(s => s.id === storedSessionId)
const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const wasSelected = selectedStoredSessionId === storedSessionId
const closingRuntimeId = wasSelected ? activeSessionId : null
const previousMessages = $messages.get()
@@ -771,7 +853,7 @@ export function useSessionActions({
// live tip after compression. Drop both so the pin can't linger.
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
@@ -806,7 +888,7 @@ export function useSessionActions({
setFreshDraftReady(false)
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
const stored = $sessions.get().find(session => session.id === storedSessionId)
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (stored) {
setCurrentUsage(current => ({
@@ -845,7 +927,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
const archived = $sessions.get().find(s => s.id === storedSessionId)
const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const wasSelected = selectedStoredSessionId === storedSessionId
const previousPinned = $pinnedSessionIds.get()
// Pins are keyed on the durable lineage-root id; the stored id may be the
@@ -853,7 +935,7 @@ export function useSessionActions({
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
@@ -870,12 +952,12 @@ export function useSessionActions({
// in flight and briefly reinsert the still-unarchived backend row. Win
// that race after the mutation succeeds so right-click → Archive does
// not appear to do nothing until the next full refresh.
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
setSessionsTotal(prev => prev + 1)
}
@@ -2,7 +2,20 @@ import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentServiceTier,
$turnStartedAt,
setCurrentFastMode,
setCurrentModel,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setTurnStartedAt
} from '@/store/session'
import { useSessionStateCache } from './use-session-state-cache'
@@ -46,12 +59,22 @@ describe('useSessionStateCache — per-session turn timer', () => {
return null as unknown as number
})
setTurnStartedAt(null)
setCurrentModel('')
setCurrentProvider('')
setCurrentReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
setTurnStartedAt(null)
setCurrentModel('')
setCurrentProvider('')
setCurrentReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
})
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
@@ -115,4 +138,78 @@ describe('useSessionStateCache — per-session turn timer', () => {
})
expect($turnStartedAt.get()).toBeNull()
})
it('mirrors the focused session model metadata when switching from a cached session', () => {
let cache!: Cache
const { rerender } = render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
act(() => {
cache.updateSessionState(
'bg-runtime',
state => ({
...state,
fast: true,
model: 'anthropic/claude-opus-4.8',
provider: 'anthropic',
reasoningEffort: 'high',
serviceTier: 'priority'
}),
'bg-stored'
)
})
// Background metadata is cached but must not bleed into the visible statusbar.
expect($currentModel.get()).toBe('')
expect($currentReasoningEffort.get()).toBe('')
expect($currentFastMode.get()).toBe(false)
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
expect(bgState).toBeTruthy()
act(() => {
cache.syncSessionStateToView('bg-runtime', bgState!)
})
expect($currentModel.get()).toBe('anthropic/claude-opus-4.8')
expect($currentProvider.get()).toBe('anthropic')
expect($currentReasoningEffort.get()).toBe('high')
expect($currentServiceTier.get()).toBe('priority')
expect($currentFastMode.get()).toBe(true)
})
it('clears stale model metadata when the newly focused session has no cached value', () => {
setCurrentModel('previous-model')
setCurrentProvider('previous-provider')
setCurrentReasoningEffort('high')
setCurrentServiceTier('priority')
setCurrentFastMode(true)
let cache!: Cache
const { rerender } = render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
act(() => {
cache.updateSessionState('bg-runtime', state => ({ ...state }), 'bg-stored')
})
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
expect(bgState).toBeTruthy()
act(() => {
cache.syncSessionStateToView('bg-runtime', bgState!)
})
expect($currentModel.get()).toBe('')
expect($currentProvider.get()).toBe('')
expect($currentReasoningEffort.get()).toBe('')
expect($currentServiceTier.get()).toBe('')
expect($currentFastMode.get()).toBe(false)
})
})
@@ -11,6 +11,7 @@ import {
noteSessionActivity,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
@@ -53,6 +54,16 @@ interface SessionStateCacheOptions {
setMessages: (messages: ChatMessage[]) => void
}
function syncRuntimeMetadataToView(state: ClientSessionState) {
setCurrentModel(state.model ?? '')
setCurrentProvider(state.provider ?? '')
setCurrentReasoningEffort(state.reasoningEffort ?? '')
setCurrentServiceTier(state.serviceTier ?? '')
setCurrentFastMode(state.fast ?? false)
setYoloActive(state.yolo ?? false)
setCurrentPersonality(state.personality ?? '')
}
export function useSessionStateCache({
activeSessionId,
busyRef,
@@ -137,12 +148,7 @@ export function useSessionStateCache({
setMessages(nextMessages)
}
setCurrentModel(pending.state.model)
setCurrentProvider(pending.state.provider)
setCurrentReasoningEffort(pending.state.reasoningEffort)
setCurrentServiceTier(pending.state.serviceTier)
setCurrentFastMode(pending.state.fast)
setYoloActive(pending.state.yolo)
syncRuntimeMetadataToView(pending.state)
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)
@@ -167,6 +173,7 @@ export function useSessionStateCache({
return
}
syncRuntimeMetadataToView(state)
pendingViewStateRef.current = { sessionId, state }
// Terminal / attention transitions (turn finished, error, or the agent is
@@ -15,7 +15,7 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { startManualProviderOAuth } from '@/store/onboarding'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import { CONTROL_TEXT } from './constants'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
@@ -224,10 +224,23 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}, [apiKeyDraft, selectedProviderRow])
// OAuth / external providers can't be activated with a pasted key — hand off
// to the shared onboarding flow scoped to this provider's real sign-in.
// to the shared onboarding flow scoped to this provider's real sign-in. The
// custom / local endpoint is NOT an OAuth provider, so it gets the dedicated
// local-endpoint form (URL + optional API key) instead of being dead-ended
// on the OAuth picker (the original "booted back to the first screen" loop).
const startProviderSetup = useCallback(() => {
if (selectedProviderRow?.slug) {
startManualProviderOAuth(selectedProviderRow.slug)
const slug = selectedProviderRow?.slug
if (!slug) {
return
}
const lower = slug.toLowerCase()
if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
startManualLocalEndpoint()
} else {
startManualProviderOAuth(slug)
}
}, [selectedProviderRow])
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
@@ -43,14 +43,14 @@ export function SessionsSettings() {
setLoading(true)
try {
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
setLocalSessions(result.sessions)
} catch (err) {
notifyError(err, s.failedLoad)
} finally {
setLoading(false)
}
}, [])
}, [s.failedLoad])
useEffect(() => {
void load()
+1
View File
@@ -129,6 +129,7 @@ export interface ClientSessionState {
serviceTier: string
fast: boolean
yolo: boolean
personality: string
busy: boolean
awaitingResponse: boolean
streamId: string | null
@@ -0,0 +1,80 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MessageRenderBoundary } from './message-render-boundary'
afterEach(cleanup)
function Boom({ error }: { error: Error | null }): null {
if (error) {
throw error
}
return null
}
const lookupError = new Error('tapClientLookup: Index 2 out of bounds (length: 2)')
describe('MessageRenderBoundary', () => {
it('renders children when nothing throws', () => {
render(
<MessageRenderBoundary resetKey="a">
<div>content</div>
</MessageRenderBoundary>
)
expect(screen.getByText('content')).toBeTruthy()
})
it('swallows the transient tapClientLookup out-of-bounds store race', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { container } = render(
<MessageRenderBoundary resetKey="a">
<Boom error={lookupError} />
</MessageRenderBoundary>
)
expect(container.innerHTML).toBe('')
spy.mockRestore()
})
it('recovers on the next consistent snapshot when resetKey changes', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { rerender } = render(
<MessageRenderBoundary resetKey="a">
<Boom error={lookupError} />
</MessageRenderBoundary>
)
rerender(
<MessageRenderBoundary resetKey="b">
<Boom error={null} />
</MessageRenderBoundary>
)
rerender(
<MessageRenderBoundary resetKey="b">
<div>recovered</div>
</MessageRenderBoundary>
)
expect(screen.getByText('recovered')).toBeTruthy()
spy.mockRestore()
})
it('re-throws unrelated errors so real bugs still surface', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
render(
<MessageRenderBoundary resetKey="a">
<Boom error={new Error('genuine render bug')} />
</MessageRenderBoundary>
)
).toThrow('genuine render bug')
spy.mockRestore()
})
})
@@ -0,0 +1,48 @@
import { Component, type ReactNode } from 'react'
// `@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
// throws — rather than returning undefined — when a subscriber reads an index
// that the message/parts list no longer has. This races during high-frequency
// store replacement (session switch 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 is transient and self-heals on the next consistent snapshot, but
// without a local boundary it unwinds to the root and blanks the whole app.
// Upstream-tracked: assistant-ui/assistant-ui#4051, #3652.
const isTransientLookupError = (error: unknown): boolean =>
error instanceof Error && /tapClient(Lookup|Resource).*out of bounds/.test(error.message)
interface Props {
// Changes whenever the message list mutates; remounting clears the caught
// error so the next consistent render recovers silently.
resetKey: string
children: ReactNode
}
export class MessageRenderBoundary extends Component<Props, { error: Error | null }> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidUpdate(prev: Props) {
if (this.state.error && prev.resetKey !== this.props.resetKey) {
this.setState({ error: null })
}
}
render() {
if (this.state.error) {
// Only swallow the transient store race; re-throw anything else so real
// bugs still reach the root error boundary.
if (!isTransientLookupError(this.state.error)) {
throw this.state.error
}
return null
}
return this.props.children
}
}
@@ -16,6 +16,8 @@ import { setMutableRef } from '@/lib/mutable-ref'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
@@ -180,18 +182,20 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
key={virtualItem.key}
ref={virtualizer.measureElement}
>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
)
})}
@@ -1528,6 +1528,8 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
>
<div
aria-label={copy.editMessage}
autoCapitalize="off"
autoCorrect="off"
autoFocus
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
@@ -1549,9 +1551,26 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
onPaste={handlePaste}
ref={editorRef}
role="textbox"
spellCheck={false}
suppressContentEditableWarning
/>
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
<ComposerPrimitive.Input
asChild
className="sr-only"
submitMode="ctrlEnter"
tabIndex={-1}
unstable_focusOnScrollToBottom={false}
>
<textarea
aria-hidden
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="sr-only"
spellCheck={false}
tabIndex={-1}
/>
</ComposerPrimitive.Input>
{staging && (
<span
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
@@ -9,13 +9,30 @@ import { $activeSessionId } from '@/store/session'
import { PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
// Radix's DropdownMenu touches pointer-capture + scrollIntoView, which jsdom
// doesn't implement; stub them so the menu can open in tests.
beforeAll(() => {
const proto = window.HTMLElement.prototype as unknown as Record<string, () => unknown>
const stubs: Record<string, () => unknown> = {
hasPointerCapture: () => false,
releasePointerCapture: () => undefined,
scrollIntoView: () => undefined,
setPointerCapture: () => undefined
}
for (const [name, fn] of Object.entries(stubs)) {
proto[name] ??= fn
}
})
function part(toolName: string): ToolPart {
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
}
function setRequest(command = 'rm -rf /tmp/x') {
function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) {
$activeSessionId.set('sess-1')
setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })
setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' })
}
function mockGateway() {
@@ -78,4 +95,26 @@ describe('PendingToolApproval', () => {
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
})
})
it('offers "Always allow" in the options menu by default', async () => {
setRequest('chmod -R 777 /tmp/x')
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
expect(await screen.findByRole('menuitem', { name: /Always allow/ })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
})
it('hides "Always allow" when the backend disallows a permanent allow', async () => {
// tirith content-security warning present → allowPermanent=false.
setRequest('curl https://bit.ly/abc | bash', false)
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
// The session + reject options still render, but never the permanent allow.
expect(await screen.findByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull()
})
})
@@ -61,6 +61,8 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
// it goes through a confirm step rather than firing straight from the menu.
const [confirmAlways, setConfirmAlways] = useState(false)
const busy = submitting !== null
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
const allowPermanent = request.allowPermanent !== false
const respond = useCallback(
async (choice: ApprovalChoice) => {
@@ -144,16 +146,18 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
@@ -279,11 +279,14 @@ function ToolEntry({ part }: ToolEntryProps) {
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
// The header trailing slot only carries the live duration timer while the
// tool is running. The copy control used to live here too, but an
// `opacity-0` (yet still clickable) button straddling the caret/duration made
// the disclosure caret hard to hit. Copy now lives in the expanded body's
// top-right, where it can't fight the caret for the right edge.
const trailing =
isPending && !embedded ? (
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
) : !isPending && copyAction.text ? (
<CopyButton appearance="tool-row" label={copyAction.label} stopPropagation text={copyAction.text} />
) : undefined
return (
@@ -322,7 +325,18 @@ function ToolEntry({ part }: ToolEntryProps) {
</div>
{isPending && <PendingToolApproval part={part} />}
{open && (
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
<div className="relative grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{copyAction.text && (
<CopyButton
appearance="inline"
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-60 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
iconClassName="size-3"
label={copyAction.label}
showLabel={false}
stopPropagation
text={copyAction.text}
/>
)}
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
<PreviewAttachment source="tool-result" target={view.previewTarget} />
)}
@@ -127,7 +127,9 @@ const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
const nodes = useMemo(() => splitInlineCode(text), [text])
return (
<span className="wrap-anywhere block whitespace-pre-line">
// styles.css bidi hook (#44150); whitespace-pre-line makes each line its own
// UAX#9 paragraph so it resolves direction independently.
<span className="wrap-anywhere block whitespace-pre-line" data-slot="aui_user-inline-text">
{nodes.map((node, nodeIndex) =>
node.kind === 'inline-code' ? (
<code
@@ -26,7 +26,8 @@ function setProviders(providers: OAuthProvider[]) {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
} satisfies DesktopOnboardingState)
}
@@ -49,7 +50,8 @@ afterEach(() => {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
})
})
@@ -430,19 +430,24 @@ const persistShowAll = (value: boolean) => {
export function Picker({ ctx }: { ctx: OnboardingContext }) {
const { t } = useI18n()
const { manual, mode, providers } = useStore($desktopOnboarding)
const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
const apiKeyOptions = useApiKeyCatalog()
if (mode === 'apikey' || !hasOauth) {
// localEndpoint forces the key form regardless of `mode` (which a manual
// provider refresh may flip back to 'oauth'); it preselects the local option
// and hides the "back to sign in" link since the user came specifically to
// configure a custom endpoint.
if (localEndpoint || mode === 'apikey' || !hasOauth) {
return (
<div className="grid gap-3">
<ApiKeyForm
canGoBack={hasOauth}
canGoBack={hasOauth && !localEndpoint}
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : undefined}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)}
options={apiKeyOptions}
/>
{manual ? null : (
@@ -630,6 +635,7 @@ export function ProviderRow({
// surfaces render the identical form.
export function ApiKeyForm({
canGoBack,
initialEnvKey,
isSet,
onBack,
onClear,
@@ -638,16 +644,31 @@ export function ApiKeyForm({
redactedValue
}: {
canGoBack: boolean
/** Preselect a specific option by env key (e.g. 'OPENAI_BASE_URL' to land on
* the local / custom endpoint form). Falls back to the first option. */
initialEnvKey?: string
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
onSave: (
envKey: string,
value: string,
name: string,
apiKey?: string
) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const { t } = useI18n()
const [option, setOption] = useState<ApiKeyOption>(options[0])
const [option, setOption] = useState<ApiKeyOption>(
() => options.find(o => o.envKey === initialEnvKey) ?? options[0]
)
const [value, setValue] = useState('')
// Optional endpoint API key, only used by the local / custom endpoint option
// (whose `value` is the base URL). Cleared whenever the option changes.
const [localKey, setLocalKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// `options` can change at runtime when callers filter the catalog (e.g. the
@@ -657,6 +678,7 @@ export function ApiKeyForm({
if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
setOption(options[0])
setValue('')
setLocalKey('')
setError(null)
}
}, [option.envKey, options])
@@ -668,6 +690,7 @@ export function ApiKeyForm({
const pick = (o: ApiKeyOption) => {
setOption(o)
setValue('')
setLocalKey('')
setError(null)
requestAnimationFrame(() => {
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -693,10 +716,11 @@ export function ApiKeyForm({
setSaving(true)
setError(null)
const result = await onSave(option.envKey, value, option.name)
const result = await onSave(option.envKey, value, option.name, isLocal ? localKey : undefined)
if (result.ok) {
setValue('')
setLocalKey('')
} else {
setError(result.message ?? t.onboarding.couldNotSave)
}
@@ -759,6 +783,17 @@ export function ApiKeyForm({
type={isLocal ? 'text' : 'password'}
value={value}
/>
{isLocal ? (
<Input
autoComplete="off"
className="font-mono"
onChange={e => setLocalKey(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={t.onboarding.localApiKeyPlaceholder}
type="password"
value={localKey}
/>
) : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
@@ -41,7 +41,8 @@ function resetStores() {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
})
}
@@ -14,6 +14,8 @@ import {
$visibleModels,
collapseModelFamilies,
effectiveVisibleKeys,
emptyProviderSentinelKey,
isProviderSentinel,
modelVisibilityKey,
setVisibleModels
} from '@/store/model-visibility'
@@ -61,10 +63,21 @@ export function ModelVisibilityDialog({
const toggle = (provider: ModelOptionProvider, model: string) => {
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
const key = modelVisibilityKey(provider.slug, model)
const sentinel = emptyProviderSentinelKey(provider.slug)
if (next.has(key)) {
next.delete(key)
// Check if this was the last real model for this provider.
const remainingForProvider = [...next].some(
k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k)
)
if (!remainingForProvider) {
next.add(sentinel)
}
} else {
next.delete(sentinel)
next.add(key)
}
@@ -3,7 +3,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import { useEffect, useMemo, useState } from 'react'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { listSessions } from '@/hermes'
import { listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { Check, MessageCircle } from '@/lib/icons'
@@ -35,7 +35,7 @@ export function SessionPickerDialog({
const sessionsQuery = useQuery({
enabled: open,
queryFn: () => listSessions(200, 1, 'exclude'),
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
queryKey: ['session-picker', 'sessions']
})
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -75,6 +75,10 @@ declare global {
}
onClosePreviewRequested?: (callback: () => void) => () => void
onOpenUpdatesRequested?: (callback: () => void) => () => void
onDeepLink?: (
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void,
) => () => void
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
+12 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listAllProfileSessions, listSessions } from './hermes'
import { getSessionMessages, listAllProfileSessions, listSessions } from './hermes'
const emptySessionsResponse = {
limit: 0,
@@ -46,4 +46,15 @@ describe('Hermes REST session helpers', () => {
})
)
})
it('tags cross-profile message reads for Electron routing and backend lookup', async () => {
api.mockResolvedValue({ messages: [], session_id: 'session-1' })
await getSessionMessages('session-1', 'xiaoxuxu')
expect(api).toHaveBeenCalledWith({
path: '/api/sessions/session-1/messages?profile=xiaoxuxu',
profile: 'xiaoxuxu'
})
})
})
+5 -3
View File
@@ -54,10 +54,10 @@ export type {
AnalyticsSkillEntry,
AnalyticsSkillsSummary,
AnalyticsTotals,
BackendUpdateCheckResponse,
AudioSpeakResponse,
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ConfigFieldSchema,
ConfigSchemaResponse,
CronJob,
@@ -218,6 +218,7 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api<SessionMessagesResponse>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
})
}
@@ -343,13 +344,14 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }>
export function validateProviderCredential(
key: string,
value: string
value: string,
apiKey?: string
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
...profileScoped(),
path: '/api/providers/validate',
method: 'POST',
body: { key, value }
body: { key, value, api_key: apiKey ?? '' }
})
}
+4
View File
@@ -1372,6 +1372,7 @@ export const en: Translations = {
getKey: 'Get a key',
replaceCurrent: 'Replace current value',
pasteApiKey: 'Paste API key',
localApiKeyPlaceholder: 'API key (optional — only if your endpoint requires one)',
couldNotSave: 'Could not save credential.',
connecting: 'Connecting',
update: 'Update',
@@ -1532,6 +1533,9 @@ export const en: Translations = {
terminal: 'Terminal',
noFolderSelected: 'No folder selected',
changeCwdTitle: 'Change working directory',
remotePickerTitle: 'Choose remote folder',
remotePickerDescription: 'Browse folders on the connected backend.',
remotePickerSelect: 'Select folder',
folderTip: cwd => `${cwd} — click to change folder`,
openFolder: 'Open folder',
refreshTree: 'Refresh tree',
+3
View File
@@ -1665,6 +1665,9 @@ export const ja = defineLocale({
terminal: 'ターミナル',
noFolderSelected: 'フォルダーが選択されていません',
changeCwdTitle: '作業ディレクトリを変更',
remotePickerTitle: 'リモートフォルダーを選択',
remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。',
remotePickerSelect: 'フォルダーを選択',
folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`,
openFolder: 'フォルダーを開く',
refreshTree: 'ツリーを更新',
+4
View File
@@ -1041,6 +1041,7 @@ export interface Translations {
getKey: string
replaceCurrent: string
pasteApiKey: string
localApiKeyPlaceholder: string
couldNotSave: string
connecting: string
update: string
@@ -1194,6 +1195,9 @@ export interface Translations {
terminal: string
noFolderSelected: string
changeCwdTitle: string
remotePickerTitle: string
remotePickerDescription: string
remotePickerSelect: string
folderTip: (cwd: string) => string
openFolder: string
refreshTree: string
+3
View File
@@ -1626,6 +1626,9 @@ export const zhHant = defineLocale({
terminal: '終端機',
noFolderSelected: '未選擇資料夾',
changeCwdTitle: '變更工作目錄',
remotePickerTitle: '選擇遠端資料夾',
remotePickerDescription: '瀏覽已連線後端上的資料夾。',
remotePickerSelect: '選擇資料夾',
folderTip: cwd => `${cwd} — 點擊以變更資料夾`,
openFolder: '開啟資料夾',
refreshTree: '重新整理檔案樹',
+4
View File
@@ -1554,6 +1554,7 @@ export const zh: Translations = {
getKey: '获取密钥',
replaceCurrent: '替换当前值',
pasteApiKey: '粘贴 API 密钥',
localApiKeyPlaceholder: 'API 密钥(可选 — 仅当端点需要时填写)',
couldNotSave: '无法保存凭据。',
connecting: '连接中',
update: '更新',
@@ -1712,6 +1713,9 @@ export const zh: Translations = {
terminal: '终端',
noFolderSelected: '未选择文件夹',
changeCwdTitle: '更改工作目录',
remotePickerTitle: '选择远程文件夹',
remotePickerDescription: '浏览已连接后端上的文件夹。',
remotePickerSelect: '选择文件夹',
folderTip: cwd => `${cwd} — 点击更改文件夹`,
openFolder: '打开文件夹',
refreshTree: '刷新文件树',
+2
View File
@@ -58,6 +58,8 @@ export type GatewayEventPayload = {
// approval.request (dangerous command / execute_code) — session-keyed
command?: string
description?: string
// False when a tirith content-security warning forbids a permanent allow.
allow_permanent?: boolean
// secret.request (skill credential capture)
env_var?: string
prompt?: string
+1
View File
@@ -46,6 +46,7 @@ export function createClientSessionState(
serviceTier: '',
fast: false,
yolo: false,
personality: '',
busy: false,
awaitingResponse: false,
streamId: null,
+116
View File
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import {
desktopDefaultCwd,
desktopGitRoot,
readDesktopDir,
readDesktopFileDataUrl,
readDesktopFileText,
selectDesktopPaths,
setDesktopFsRemotePicker
} from './desktop-fs'
const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }))
const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 }))
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')
const gitRoot = vi.fn(async () => '/local')
const selectPaths = vi.fn(async () => ['/local'])
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' }
if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' }
throw new Error(`unexpected path ${path}`)
})
function stubBridge() {
vi.stubGlobal('window', {
hermesDesktop: {
api,
gitRoot,
readDir,
readFileDataUrl,
readFileText,
selectPaths
}
})
}
describe('desktop filesystem facade', () => {
beforeEach(() => {
stubBridge()
$connection.set(null)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
$connection.set(null)
setDesktopFsRemotePicker(null)
})
it('uses local Electron filesystem methods in local mode', async () => {
$connection.set({ mode: 'local' } as never)
await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })
await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })
await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')
await expect(desktopGitRoot('/work')).resolves.toBe('/local')
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local'])
expect(readDir).toHaveBeenCalledWith('/work')
expect(readFileText).toHaveBeenCalledWith('/work/file.txt')
expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')
expect(gitRoot).toHaveBeenCalledWith('/work')
expect(selectPaths).toHaveBeenCalledWith({ directories: true })
expect(api).not.toHaveBeenCalled()
})
it('routes filesystem reads through authenticated backend REST in remote mode', async () => {
$connection.set({ mode: 'remote' } as never)
await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] })
await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' })
await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')
await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote')
await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' })
expect(readDir).not.toHaveBeenCalled()
expect(readFileText).not.toHaveBeenCalled()
expect(readFileDataUrl).not.toHaveBeenCalled()
expect(gitRoot).not.toHaveBeenCalled()
})
it('uses the registered in-app directory picker in remote mode', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([
'/remote/project'
])
expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false })
expect(selectPaths).not.toHaveBeenCalled()
})
it('does not treat the remote directory picker as a general file picker', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])
await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([])
expect(remoteSelect).not.toHaveBeenCalled()
expect(selectPaths).not.toHaveBeenCalled()
})
})
+95
View File
@@ -0,0 +1,95 @@
import { $connection } from '@/store/session'
import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
export interface DesktopFsRemotePicker {
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
}
let remotePicker: DesktopFsRemotePicker | null = null
export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) {
remotePicker = next
}
function connectionCacheKey(connection: HermesConnection | null) {
if (!connection) {
return 'local:'
}
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
}
export function desktopFsCacheKey() {
return connectionCacheKey($connection.get())
}
export function isDesktopFsRemoteMode() {
return $connection.get()?.mode === 'remote'
}
function fsPath(endpoint: string, filePath: string) {
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
}
function bridge() {
const desktop = window.hermesDesktop
if (!desktop) {
throw new Error('Hermes Desktop bridge is unavailable')
}
return desktop
}
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readDir(path)
}
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
}
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileText(path)
}
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
}
export async function readDesktopFileDataUrl(path: string): Promise<string> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileDataUrl(path)
}
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
return typeof result === 'string' ? result : result.dataUrl || ''
}
export async function desktopGitRoot(path: string): Promise<string | null> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.gitRoot ? desktop.gitRoot(path) : null
}
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
return result.root
}
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
if (!isDesktopFsRemoteMode()) {
return null
}
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
}
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.selectPaths(options)
}
if (!options?.directories || options.multiple !== false) {
return []
}
return remotePicker ? remotePicker.selectPaths(options) : []
}
+23 -2
View File
@@ -1,3 +1,4 @@
import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs'
import type { PreviewTarget } from '@/store/preview'
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
@@ -107,6 +108,26 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
}
}
async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> {
if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') {
return target
}
try {
const result = await readDesktopFileText(target.path || target.source)
return {
...target,
binary: result.binary,
byteSize: result.byteSize,
language: result.language || target.language,
large: (result.byteSize ?? 0) > 512 * 1024,
mimeType: result.mimeType
}
} catch {
return target
}
}
export async function normalizeOrLocalPreviewTarget(
rawTarget: string,
cwd?: string | null
@@ -115,12 +136,12 @@ export async function normalizeOrLocalPreviewTarget(
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
if (normalized) {
return normalized
return enrichPreviewTarget(normalized)
}
} catch {
// Running Electron may still have the old HTML-only preview IPC. Fall
// through to renderer-side local classification so text/images still open.
}
return localPreviewTarget(rawTarget, cwd)
return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd))
}
+3 -1
View File
@@ -5,6 +5,7 @@ import { notify, notifyError } from '@/store/notifications'
interface ExportSessionParams {
sessionId: string
profile?: string | null
title?: string | null
session?: SessionInfo
}
@@ -31,7 +32,8 @@ export async function exportSession(sessionId: string, params: Omit<ExportSessio
}
try {
const { messages } = await getSessionMessages(sessionId)
const profile = params.profile ?? params.session?.profile
const { messages } = await getSessionMessages(sessionId, profile)
const payload = {
exported_at: new Date().toISOString(),
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest'
import type { ModelOptionProvider } from '@/types/hermes'
import { effectiveVisibleKeys, modelVisibilityKey } from './model-visibility'
import {
effectiveVisibleKeys,
emptyProviderSentinelKey,
isProviderSentinel,
modelVisibilityKey
} from './model-visibility'
const provider = (slug: string, models: string[]): ModelOptionProvider => ({
models,
@@ -34,4 +39,48 @@ describe('model visibility', () => {
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false)
})
it('preserves hidden-provider sentinel without re-adding defaults', () => {
// User explicitly hid all models for "nous" — sentinel marks this choice.
const stored = new Set([emptyProviderSentinelKey('nous')])
const visible = effectiveVisibleKeys(stored, [
provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']),
provider('ollama', ['qwen3:latest'])
])
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(false)
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
// Sentinel itself is stripped from the result.
expect(visible.has(emptyProviderSentinelKey('nous'))).toBe(false)
// Other providers still get defaults.
expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true)
})
it('restores model when toggling on after hiding all', () => {
// Simulates: user hid all "nous" models, then toggles one back on.
const stored = new Set([
emptyProviderSentinelKey('nous'),
modelVisibilityKey('ollama', 'qwen3:latest')
])
// After toggle: sentinel removed, one model added.
const afterToggle = new Set(stored)
afterToggle.delete(emptyProviderSentinelKey('nous'))
afterToggle.add(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))
const visible = effectiveVisibleKeys(afterToggle, [
provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']),
provider('ollama', ['qwen3:latest'])
])
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(true)
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
})
it('sentinel key helper produces correct format', () => {
expect(emptyProviderSentinelKey('openai')).toBe('openai::')
expect(isProviderSentinel('openai::')).toBe(true)
expect(isProviderSentinel('openai::gpt-4o')).toBe(false)
})
})
+25 -2
View File
@@ -13,6 +13,19 @@ export const DEFAULT_VISIBLE_PER_PROVIDER = 50
* that contain a single colon, e.g. `model:tag`). */
export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
/** Sentinel key suffix stored when the user explicitly hides ALL models for a
* provider. Distinguishes "user hid everything" from "never customized" so
* `effectiveVisibleKeys` does not re-add defaults for that provider. */
export const EMPTY_PROVIDER_SENTINEL = ''
/** Build the sentinel key for a provider whose last model was toggled off. */
export const emptyProviderSentinelKey = (provider: string): string =>
modelVisibilityKey(provider, EMPTY_PROVIDER_SENTINEL)
/** Check whether a stored key is a provider-hidden sentinel. */
export const isProviderSentinel = (key: string): boolean =>
key.endsWith('::')
/** A model and its optional `-fast` sibling, collapsed into one logical row.
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
export interface ModelFamily {
@@ -116,9 +129,12 @@ export function effectiveVisibleKeys(
for (const provider of providers) {
const providerPrefix = `${provider.slug}::`
const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix))
const hasStoredProvider = [...stored].some(
key => key.startsWith(providerPrefix) && !isProviderSentinel(key)
)
const hasSentinel = stored.has(emptyProviderSentinelKey(provider.slug))
if (hasStoredProvider) {
if (hasStoredProvider || hasSentinel) {
continue
}
@@ -129,5 +145,12 @@ export function effectiveVisibleKeys(
}
}
// Strip sentinel keys — they are bookkeeping, not real visibility entries.
for (const key of [...next]) {
if (isProviderSentinel(key)) {
next.delete(key)
}
}
return next
}
+46 -3
View File
@@ -33,6 +33,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false,
...overrides
}
}
@@ -233,10 +234,12 @@ describe('OAuth onboarding', () => {
const state = $desktopOnboarding.get()
expect(state.reason).toBeNull()
expect(state.flow.status).toBe('confirming_model')
if (state.flow.status === 'confirming_model') {
expect(state.flow.label).toBe('Nous Portal')
expect(state.flow.currentModel).toBe(model)
}
expect(calls.some(c => c.path === '/api/model/set')).toBe(true)
})
})
@@ -283,7 +286,7 @@ describe('saveOnboardingLocalEndpoint', () => {
throw new Error(`unexpected api path: ${path}`)
})
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
requestGateway: readyGateway()
})
@@ -313,7 +316,7 @@ describe('saveOnboardingLocalEndpoint', () => {
installApiMock(api)
const onCompleted = vi.fn()
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
onCompleted,
requestGateway: readyGateway()
})
@@ -332,6 +335,46 @@ describe('saveOnboardingLocalEndpoint', () => {
expect($desktopOnboarding.get().configured).toBe(true)
})
it('forwards the API key to the probe and persists it for auth-gated endpoints', async () => {
const calls: { body?: unknown; path: string }[] = []
const api = vi.fn(async ({ body, path }: { body?: unknown; path: string }) => {
calls.push({ body, path })
if (path === '/api/providers/validate') {
return { ok: true, reachable: true, message: '', models: ['gpt-oss-120b'] }
}
if (path === '/api/model/set') {
return { ok: true, provider: 'custom', model: 'gpt-oss-120b', base_url: 'https://text.example.com/v1' }
}
throw new Error(`unexpected api path: ${path}`)
})
installApiMock(api)
const result = await saveOnboardingLocalEndpoint('https://text.example.com/v1', 'sk-secret', {
requestGateway: readyGateway()
})
expect(result.ok).toBe(true)
// The probe must receive the key so an auth-gated /v1/models enumerates.
const probe = calls.find(c => c.path === '/api/providers/validate')
expect(probe?.body).toMatchObject({ key: 'OPENAI_BASE_URL', value: 'https://text.example.com/v1', api_key: 'sk-secret' })
// And the key must be persisted alongside the endpoint for runtime auth.
const assign = calls.find(c => c.path === '/api/model/set')
expect(assign?.body).toMatchObject({
scope: 'main',
provider: 'custom',
model: 'gpt-oss-120b',
base_url: 'https://text.example.com/v1',
api_key: 'sk-secret'
})
})
it('reports the runtime reason when resolution still fails after saving', async () => {
installApiMock(async ({ path }: { path: string }) => {
if (path === '/api/providers/validate') {
@@ -361,7 +404,7 @@ describe('saveOnboardingLocalEndpoint', () => {
throw new Error(`unexpected gateway method: ${method}`)
}
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
requestGateway: failingGateway
})
+52 -13
View File
@@ -72,6 +72,11 @@ export interface DesktopOnboardingState {
* picker's "Add provider" button). Forces the overlay to show the picker
* even when configured === true, and adds a close affordance. */
manual: boolean
/** True when the overlay was opened specifically to configure a local /
* custom OpenAI-compatible endpoint (e.g. from Settings Model's "Set up
* custom endpoint"). Forces the API-key form with the local option
* preselected instead of the OAuth picker. */
localEndpoint: boolean
}
export interface OnboardingContext {
@@ -150,7 +155,8 @@ const INITIAL: DesktopOnboardingState = {
reason: null,
requested: false,
firstRunSkipped: readCachedSkipped(),
manual: false
manual: false,
localEndpoint: false
}
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
@@ -392,6 +398,7 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
patch({
manual: true,
requested: true,
localEndpoint: false,
// `null` opts out of the prompt banner entirely (e.g. when the user already
// picked a specific provider and we auto-start its sign-in).
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
@@ -400,6 +407,24 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
void refreshProviders()
}
// Open the onboarding overlay directly on the local / custom endpoint form
// (URL + optional API key), bypassing the OAuth picker. Used by Settings →
// Model's "Set up custom endpoint" so it lands on a form that can actually
// configure the endpoint instead of dead-ending on the OAuth provider list
// (`custom` is not an OAuth provider, so the generic manual flow would just
// re-show the picker — the original "booted back to the first screen" loop).
export function startManualLocalEndpoint(reason: null | string = null) {
pendingProviderOAuthId = null
patch({
manual: true,
requested: true,
localEndpoint: true,
mode: 'apikey',
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
flow: { status: 'idle' }
})
}
// One-shot hand-off used when the dedicated Providers settings page launches a
// specific provider's sign-in: we open the manual onboarding overlay AND
// remember which provider to start, so the overlay drives that exact OAuth
@@ -431,7 +456,7 @@ export function clearPendingProviderOAuth() {
export function closeManualOnboarding() {
pendingProviderOAuthId = null
patch({ manual: false, requested: false, flow: { status: 'idle' } })
patch({ manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } })
}
export function completeDesktopOnboarding() {
@@ -448,7 +473,8 @@ export function completeDesktopOnboarding() {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
})
}
@@ -461,7 +487,7 @@ export function completeDesktopOnboarding() {
export function dismissFirstRunOnboarding() {
clearPoll()
writeCachedSkipped(true)
patch({ firstRunSkipped: true, requested: false, manual: false, flow: { status: 'idle' } })
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } })
}
export function setOnboardingMode(mode: OnboardingMode) {
@@ -701,18 +727,28 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
)
}
export async function saveOnboardingApiKey(envKey: string, value: string, label: string, ctx: OnboardingContext) {
export async function saveOnboardingApiKey(
envKey: string,
value: string,
label: string,
ctx: OnboardingContext,
// Optional endpoint key — only meaningful for the "Local / custom endpoint"
// option, whose primary `value` is the base URL. Ignored for plain API-key
// providers (their key IS `value`).
endpointApiKey?: string
) {
const trimmed = value.trim()
if (!trimmed) {
return { ok: false, message: 'Enter a value first.' }
}
// The "Local / custom endpoint" option carries a base URL, not an API key.
// It must be wired into config (provider=custom + base_url + model), not
// dropped into .env — runtime resolution ignores OPENAI_BASE_URL.
// The "Local / custom endpoint" option carries a base URL (in `value`) plus
// an optional API key. It must be wired into config (provider=custom +
// base_url + model + api_key), not dropped into .env — runtime resolution
// ignores OPENAI_BASE_URL.
if (envKey === 'OPENAI_BASE_URL') {
return saveOnboardingLocalEndpoint(trimmed, ctx)
return saveOnboardingLocalEndpoint(trimmed, endpointApiKey?.trim() ?? '', ctx)
}
// No key validation here on purpose: we previously live-probed the key and
@@ -748,14 +784,17 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
// env var that resolution never consults.
//
// The model is auto-discovered from the endpoint's /v1/models (surfaced by the
// validate probe) so the user only has to paste a URL — no extra UI field.
// validate probe). The optional API key is forwarded to the probe (so hosted
// endpoints that gate /v1/models behind auth still enumerate models) and
// persisted to model.api_key so the runtime can authenticate.
//
// We deliberately don't route through completeWithModelConfirm: that path
// re-assigns the model from /api/model/options WITHOUT a base_url, which would
// wipe the base_url we just wrote. We have a concrete model already, so we
// verify the runtime directly and finish.
export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: OnboardingContext) {
export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: string, ctx: OnboardingContext) {
const url = baseUrl.trim()
const key = apiKey.trim()
if (!url) {
return { ok: false, message: 'Enter the endpoint URL first.' }
@@ -767,7 +806,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
let model = ''
try {
const probe = await validateProviderCredential('OPENAI_BASE_URL', url)
const probe = await validateProviderCredential('OPENAI_BASE_URL', url, key)
if (!probe.ok && probe.reachable) {
return { ok: false, message: probe.message || 'Could not reach that endpoint.' }
@@ -790,7 +829,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
}
try {
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url })
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url, api_key: key })
await ctx.requestGateway('reload.env').catch(() => undefined)
const runtime = await checkRuntime(ctx)
+6
View File
@@ -53,6 +53,12 @@ describe('approval prompt store', () => {
expect($approvalRequest.get()).toBeNull()
})
it('carries allowPermanent so the bar can hide "Always allow"', () => {
setApprovalRequest({ allowPermanent: false, command: 'curl x | bash', description: 'content-security', sessionId: 's1' })
expect($approvalRequest.get()?.allowPermanent).toBe(false)
})
})
describe('sudo prompt store', () => {
+2
View File
@@ -68,6 +68,8 @@ function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> {
// resolved via approval.respond {choice, session_id}). It carries no request_id,
// unlike sudo/secret which are _block()-style request/response.
export interface ApprovalRequest extends KeyedPrompt {
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
allowPermanent?: boolean
command: string
description: string
}
+25
View File
@@ -5,12 +5,14 @@ import type { SessionInfo } from '@/types/hermes'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$workingSessionIds,
applyConfiguredDefaultProjectDir,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
setCurrentCwd,
setSessionAttention,
setSessionWorking,
workspaceCwdForNewSession
@@ -184,9 +186,12 @@ describe('mergeSessionPage', () => {
describe('workspaceCwdForNewSession', () => {
afterEach(() => {
applyConfiguredDefaultProjectDir(null)
$connection.set(null)
$currentCwd.set('')
$activeSessionId.set(null)
window.localStorage.removeItem('hermes.desktop.workspace-cwd')
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default')
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default')
})
it('prefers the configured default over the sticky remembered workspace', () => {
@@ -216,6 +221,26 @@ describe('workspaceCwdForNewSession', () => {
expect($currentCwd.get()).toBe('/live/session/path')
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
})
it('keeps remote workspace memory separate from local and other remotes', () => {
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project')
$currentCwd.set('/live/session/path')
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
expect(workspaceCwdForNewSession()).toBe('')
setCurrentCwd('/backend/project-a')
expect(workspaceCwdForNewSession()).toBe('/backend/project-a')
$connection.set({ baseUrl: 'http://backend-b', mode: 'remote' } as never)
expect(workspaceCwdForNewSession()).toBe('')
setCurrentCwd('/backend/project-b')
expect(workspaceCwdForNewSession()).toBe('/backend/project-b')
$connection.set(null)
expect(workspaceCwdForNewSession()).toBe('/local/project')
})
})
describe('getRecentlySettledSessionIds', () => {
+30 -14
View File
@@ -10,13 +10,19 @@ type Updater<T> = T | ((current: T) => T)
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
// Cached copy of Settings → Sessions → Default project directory. The main
// process persists this in project-dir.json, but the renderer must also honor it
// when seeding $currentCwd — otherwise PR #37586's sticky localStorage home dir
// wins and new sessions ignore the user's explicit picker choice.
let configuredDefaultProjectDir = ''
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
if (connection?.mode !== 'remote') {
return WORKSPACE_CWD_KEY
}
const base = encodeURIComponent(connection.baseUrl || 'remote')
const profile = encodeURIComponent(connection.profile || 'default')
return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}`
}
export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || ''
export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir
@@ -54,6 +60,13 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
}
}
const remembered = getRememberedWorkspaceCwd()
if ($connection.get()?.mode === 'remote') {
seedLiveCwd(remembered)
return
}
if (configured) {
const { cwd } = await sanitize(configured)
seedLiveCwd(cwd)
@@ -61,8 +74,10 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
return
}
const { cwd } = await sanitize(getRememberedWorkspaceCwd())
seedLiveCwd(cwd)
if (remembered) {
const { cwd } = await sanitize(remembered)
seedLiveCwd(cwd)
}
}
export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void {
@@ -238,15 +253,16 @@ export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive,
export const setCurrentCwd = (next: Updater<string>) => {
updateAtom($currentCwd, next)
// Keep localStorage in sync with the atom: a real folder is remembered, an
// empty cwd clears the key (|| null → removeItem).
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)
}
/** Workspace for a brand-new chat. Explicit Settings override wins; otherwise
* fall back to the sticky last-used folder, then whatever is already live. */
export const workspaceCwdForNewSession = (): string =>
getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
export const workspaceCwdForNewSession = (): string => {
if ($connection.get()?.mode === 'remote') {
return getRememberedWorkspaceCwd()
}
return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
}
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
+55
View File
@@ -17,6 +17,30 @@
src: url('../../../node_modules/@nous-research/ui/dist/fonts/Collapse-Bold.woff2') format('woff2');
}
/* JetBrains Mono bundled terminal font (Apache-2.0) so bold/italic share the
regular face's metrics instead of squeezing against a system fallback. */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('./fonts/JetBrainsMono-Italic.woff2') format('woff2');
}
@theme inline {
--color-background: var(--dt-background);
--color-foreground: var(--dt-foreground);
@@ -823,6 +847,37 @@ canvas {
content's --message-text-indent). No extra prose indent a single gutter
reads cleaner than a ragged tool-vs-reply column. */
/* RTL/bidi chat text (#44150): each block resolves its own base direction from
its first strong char (UAX#9 plaintext). text-align:start makes that resolved
direction drive alignment too load-bearing, since the user bubble pins
text-left. direction is never set, so chrome/layout/list-indent stay LTR (the
issue asks not to flip the whole UI). Covers assistant prose, user lines, and
both composers (main + edit share composer-rich-input). */
[data-slot='aui_assistant-message-content'] .aui-md :where(p, h1, h2, h3, h4, h5, h6, li, blockquote),
[data-slot='aui_user-inline-text'],
[data-slot='composer-rich-input'] {
unicode-bidi: plaintext;
text-align: start;
}
/* Inline code/KaTeX don't vote on direction and keep their own order: isolate
makes bidi treat each as one neutral, so a block that *starts* with `./run.sh`
then Arabic still resolves RTL, and the command's neutrals (dots/slashes)
aren't reordered by the surrounding RTL run. */
[data-slot='aui_assistant-message-content'] .aui-md :where(:not(pre) > code),
[data-slot='aui_user-inline-code'],
[data-slot='aui_assistant-message-content'] .aui-md .katex {
direction: ltr;
unicode-bidi: isolate;
}
/* Fenced code stays LTR even inside an RTL list item/blockquote — never mirrors. */
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'],
[data-slot='aui_user-fence'] {
direction: ltr;
text-align: left;
}
[data-slot='aui_user-message-root'] {
top: var(--sticky-human-top);
}
+4
View File
@@ -638,6 +638,10 @@ export interface AuxiliaryModelsResponse {
}
export interface ModelAssignmentRequest {
/** Optional API key for a custom/local endpoint. Persisted to model.api_key
* (where the runtime reads it) for self-hosted endpoints that require auth.
* Only honored for custom/local providers on the main slot. */
api_key?: string
/** OpenAI-compatible endpoint URL. Only honored for custom/local providers
* on the main slot wires a self-hosted endpoint into runtime resolution. */
base_url?: string
+200 -3
View File
@@ -456,6 +456,9 @@ def load_cli_config() -> Dict[str, Any]:
"busy_input_mode": "interrupt",
"persistent_output": True,
"persistent_output_max_lines": 200,
# Print a one-line summary of resolved modal prompts (approval /
# clarify) into scrollback so the decision survives the repaint.
"persist_prompts": True,
"skin": "default",
},
@@ -3185,6 +3188,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
# show_reasoning: display model thinking/reasoning before the response
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
# Live web_extract summarization box (reasoning-style). Registered
# process-wide; no-op for runs that never summarize a web page.
try:
from tools.summary_display import set_summary_stream_callback
set_summary_stream_callback(self._on_summary_stream)
except Exception:
logger.debug("Failed to register summary stream callback", exc_info=True)
_configure_output_history(
enabled=CLI_CONFIG["display"].get("persistent_output", True),
max_lines=CLI_CONFIG["display"].get("persistent_output_max_lines", 200),
@@ -3504,6 +3514,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# the next submitted input, whether it's the selection or anything
# else). See #34584.
self._pending_resume_sessions = None
# One-shot agent seed set by a slash handler (e.g. /blueprint <name>)
# that wants its output run as the next agent turn. Consumed and cleared
# by the interactive loop immediately after process_command() returns.
self._pending_agent_seed = None
self._secret_state = None
self._secret_deadline = 0
self._spinner_text: str = "" # thinking spinner text for TUI
@@ -4656,6 +4670,55 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._deferred_content = ""
self._emit_stream_text(deferred)
# ── Web-extract summary streaming (reasoning-style box) ─────────────
def _on_summary_stream(self, event: str, **kwargs) -> None:
"""Render web_extract summarization tokens in a dim live box.
Mirrors the reasoning-box UX: opens a dim 'Summarizing' box when the
summarizer LLM starts streaming, streams its tokens line-by-line,
and closes the box with a char count when done. Registered with
tools.summary_display; called from the summarizer's event loop
thread, so output goes through _cprint (patch_stdout-safe).
"""
try:
if event == "start":
url = kwargs.get("url", "")
w = self._scrollback_box_width()
label = " Summarizing "
if url:
short = url if len(url) <= w - 20 else url[: w - 23] + "..."
label = f" Summarizing · {short} "
fill = w - 2 - len(label)
_cprint(f"\n{_DIM}┌─{label}{'' * max(fill - 1, 0)}{_RST}")
self._summary_box_opened = True
self._summary_buf = ""
elif event == "delta":
if not getattr(self, "_summary_box_opened", False):
return
self._summary_buf = getattr(self, "_summary_buf", "") + kwargs.get("text", "")
while "\n" in self._summary_buf:
line, self._summary_buf = self._summary_buf.split("\n", 1)
_cprint(f"{_DIM}{line}{_RST}")
if len(self._summary_buf) > 80:
_cprint(f"{_DIM}{self._summary_buf}{_RST}")
self._summary_buf = ""
elif event == "end":
if not getattr(self, "_summary_box_opened", False):
return
buf = getattr(self, "_summary_buf", "")
if buf:
_cprint(f"{_DIM}{buf}{_RST}")
self._summary_buf = ""
w = self._scrollback_box_width()
chars = kwargs.get("char_count", 0)
tail = f" {chars:,} chars " if kwargs.get("ok") else " summarization fell back "
fill = w - 2 - len(tail)
_cprint(f"{_DIM}{'' * max(fill - 1, 0)}{tail}{_RST}")
self._summary_box_opened = False
except Exception:
logger.debug("Summary stream render failed", exc_info=True)
def _stream_delta(self, text) -> None:
"""Line-buffered streaming callback for real-time token rendering.
@@ -7409,6 +7472,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self.save_conversation()
elif canonical == "cron":
self._handle_cron_command(cmd_original)
elif canonical == "suggestions":
self._handle_suggestions_command(cmd_original)
elif canonical == "blueprint":
self._handle_blueprint_command(cmd_original)
elif canonical == "curator":
self._handle_curator_command(cmd_original)
elif canonical == "kanban":
@@ -7440,6 +7507,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._manual_compress(cmd_original)
elif canonical == "usage":
self._show_usage()
elif canonical == "credits":
self._show_credits()
elif canonical == "insights":
self._show_insights(cmd_original)
elif canonical == "copy":
@@ -8341,6 +8410,86 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
print(f" {line}")
return True
def _show_credits(self):
"""`/credits` — focused Nous credit balance + top-up handoff.
Interactive CLI: balance block + identity line + a 3-button panel
(Open top-up / Copy link / Cancel). Non-interactive contexts the TUI
slash-worker subprocess and any place without a live prompt_toolkit app
(``self._app is None``) render a text variant (balance + tappable
top-up URL), because the modal would try to read the RPC stdin and crash
the worker. The terminal never confirms or polls payment (billing phase
2a). Fail-open: a portal hiccup or logged-out account degrades to a clear
message, never a crash.
"""
from agent.account_usage import build_credits_view
view = build_credits_view()
if not view.logged_in:
print()
print(f" 💳 {_DIM}Not logged into Nous Portal.{_RST}")
print(" Run `hermes portal` to log in, then /credits.")
return
print()
print(" 💳 Nous credits")
print(f" {'' * 41}")
for line in view.balance_lines:
# Drop the helper's own "📈 Nous credits" header — we print our own.
if line.lstrip().startswith("📈"):
continue
print(f" {line}")
print(f" {'' * 41}")
if view.identity_line:
print(f" {view.identity_line}")
if not view.topup_url:
return
# Non-interactive (TUI slash-worker, piped, no live app): the
# prompt_toolkit modal can't run here — it would read the worker's
# JSON-RPC stdin and crash the command. Render the text variant: the
# tappable URL IS the affordance, same as the messaging surfaces.
if not getattr(self, "_app", None):
print()
print(f" Top up: {view.topup_url}")
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
return
choices = [
("open", "Open top-up in browser", "launch the portal billing page"),
("copy", "Copy link", "copy the top-up URL to your clipboard"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Add credits?",
detail=f"Top-up page:\n{view.topup_url}",
choices=choices,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice == "open":
opened = False
try:
import webbrowser
opened = webbrowser.open(view.topup_url)
except Exception:
opened = False
if not opened:
print(f" Open this URL to top up: {view.topup_url}")
print()
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
elif choice == "copy":
try:
self._write_osc52_clipboard(view.topup_url)
print(f" 📋 Copied: {view.topup_url}")
except Exception:
print(f" Top-up URL: {view.topup_url}")
else:
print(" 🟡 Cancelled. No credits added.")
def _show_insights(self, command: str = "/insights"):
"""Show usage insights and analytics from session history."""
# Parse optional --days flag
@@ -9353,6 +9502,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
for line in reqs["details"].split("\n"):
_cprint(f" {line}")
def _persist_prompt_summary(self, icon: str, label: str, detail: str, outcome: str) -> None:
"""Print a one-line scrollback summary of a resolved modal prompt.
Modal panels (approval / clarify) live in the prompt_toolkit layout and
vanish on the next repaint, so the question and the decision leave no
trace in the terminal scrollback. When display.persist_prompts is on
(default), emit a dim single line after the prompt resolves so the
decision survives in chat history.
"""
if not CLI_CONFIG.get("display", {}).get("persist_prompts", True):
return
detail = " ".join(detail.split())
if len(detail) > 120:
detail = detail[:119] + ""
outcome = " ".join(outcome.split())
if len(outcome) > 120:
outcome = outcome[:119] + ""
_cprint(f"\n{_DIM}{icon} {label}: {detail}{outcome}{_RST}")
def _clarify_callback(self, question, choices):
"""
Platform callback for the clarify tool. Called from the agent thread.
@@ -9392,6 +9560,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
try:
result = response_queue.get(timeout=1)
self._clarify_deadline = 0
self._persist_prompt_summary("?", "Clarify", question, str(result))
return result
except queue.Empty:
remaining = self._clarify_deadline - _time.monotonic()
@@ -9505,6 +9674,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._approval_state = None
self._approval_deadline = 0
self._paint_now()
_outcome_labels = {
"once": "allowed once",
"session": "allowed for session",
"always": "added to allowlist",
"deny": "denied",
}
self._persist_prompt_summary(
"", "Approval", command,
_outcome_labels.get(result, str(result)),
)
return result
except queue.Empty:
remaining = self._approval_deadline - _time.monotonic()
@@ -9621,7 +9800,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
show_full = state.get("show_full", False)
title = "⚠️ Dangerous Command"
cmd_display = command if show_full or len(command) <= 70 else command[:70] + '...'
cmd_display = command
choice_labels = {
"once": "Allow once",
"session": "Allow for this session",
@@ -9645,6 +9824,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Pre-wrap the mandatory content — command + choices must always render.
cmd_wrapped = _wrap_panel_text(cmd_display, inner_text_width)
if not show_full and "view" in choices and len(cmd_wrapped) > 4:
cmd_wrapped = cmd_wrapped[:3] + _wrap_panel_text(
"… (choose Show full command)",
inner_text_width,
)
# (choice_index, wrapped_line) so we can re-apply selected styling below
choice_wrapped: list[tuple[int, str]] = []
@@ -9694,7 +9878,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
max_cmd_rows = max(1, available - chrome_rows - len(choice_wrapped))
if len(cmd_wrapped) > max_cmd_rows:
keep = max(1, max_cmd_rows - 1) if max_cmd_rows > 1 else 1
cmd_wrapped = cmd_wrapped[:keep] + ["… (command truncated — use /logs or /debug for full text)"]
cmd_wrapped = cmd_wrapped[:keep] + _wrap_panel_text(
"… (command truncated — use /logs or /debug for full text)",
inner_text_width,
)
# Allocate any remaining rows to description. The extra -1 in full mode
# accounts for the blank separator between choices and description.
@@ -12827,7 +13014,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# session. Without this guard a KeyboardInterrupt unwinds
# to the outer prompt_toolkit loop and the session dies.
_cprint("\n[dim]Command interrupted.[/dim]")
continue
continue
# A slash handler may set a one-shot pending seed (e.g.
# /blueprint <name>) to be run as the next agent turn.
# If present, fall through to the chat path with the seed
# as the user message instead of looping back to idle.
_seed = getattr(self, "_pending_agent_seed", None)
if _seed:
self._pending_agent_seed = None
user_input = _seed
else:
continue
# Expand paste references back to full content
_paste_ref_re = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
+713
View File
@@ -0,0 +1,713 @@
"""Automation Blueprints — parameterized automation blueprints with typed slots.
A *blueprint* is a one-place definition of an automation that every surface
renders natively:
* Dashboard / GUI app -> a form (one field per slot)
* CLI / TUI / messenger -> a pre-filled ``/blueprint`` slash command
* Agent -> a seed prompt; it asks for any blank/ambiguous slot
* Docs catalog -> a copy-paste command + a ``hermes://`` deep-link
The single source of truth is the slot schema below. ``blueprint_form_schema``
emits what a form renderer needs; ``blueprint_slash_command`` emits the flattened
one-line command; ``fill_blueprint`` validates user-supplied values and turns a
blueprint into a ``cron.jobs.create_job`` kwargs dict (so there is no second job
engine). The form-where-there's-a-screen / agent-fills-where-there's-a-chat
split both consume this same module.
Design choice: users never type raw cron. A blueprint carries a fixed recurrence
in ``schedule_template`` and parameterizes only the human-friendly parts
(time-of-day, weekday set). Blueprints needing full flexibility expose a ``text``
slot named ``schedule`` that passes through verbatim.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
__all__ = [
"BlueprintSlot",
"AutomationBlueprint",
"CATALOG",
"get_blueprint",
"blueprint_form_schema",
"blueprint_slash_command",
"blueprint_deeplink",
"blueprint_catalog_entry",
"fill_blueprint",
"BlueprintFillError",
"WEEKDAY_PRESETS",
]
class BlueprintFillError(ValueError):
"""Raised when supplied slot values fail validation."""
# Slot types the renderers understand.
_SLOT_TYPES = frozenset({"time", "enum", "text", "weekdays"})
# Named weekday recurrences -> cron day-of-week field.
WEEKDAY_PRESETS: Dict[str, str] = {
"everyday": "*",
"weekdays": "1-5",
"weekends": "0,6",
}
@dataclass(frozen=True)
class BlueprintSlot:
"""A single fillable field on a blueprint."""
name: str
type: str
label: str
default: Any = None
options: tuple = () # for type="enum": allowed values
optional: bool = False
help: str = ""
# When False, ``options`` are suggestions rather than a closed set —
# any value is accepted (e.g. the deliver slot, where the real set of
# valid platforms depends on the user's configured gateways and is
# validated downstream by the cron scheduler).
strict: bool = True
def __post_init__(self) -> None:
if self.type not in _SLOT_TYPES:
raise ValueError(f"unknown slot type {self.type!r} (slot {self.name})")
@dataclass(frozen=True)
class AutomationBlueprint:
"""A parameterized automation blueprint."""
key: str
title: str
description: str
category: str
# Cron expression with ``{slot}`` placeholders, e.g. "{minute} {hour} * * {dow}".
# Placeholders are filled from resolved slot values (time -> minute/hour,
# weekdays -> dow). A literal cron string with no placeholders = fixed schedule.
schedule_template: str
# Seed instruction for the agent / the cron job prompt; may contain {slot}s.
prompt_template: str
slots: List[BlueprintSlot] = field(default_factory=list)
deliver_default: str = "origin"
skills: tuple = () # skills the job loads before running
tags: tuple = ()
# ---------------------------------------------------------------------------
# Curated in-repo catalog
# ---------------------------------------------------------------------------
_TIME = lambda default="08:00": BlueprintSlot( # noqa: E731 - concise factory
name="time", type="time", label="What time?", default=default,
help="24h local time, e.g. 08:00",
)
_DELIVER = BlueprintSlot(
name="deliver", type="enum", label="Where to deliver?",
default="origin", options=("origin", "local", "telegram", "discord", "email"),
optional=False, strict=False,
help="origin = the chat you set this up from (or your configured home "
"channel when created from the dashboard); local = save only, no message; "
"or any connected platform name",
)
CATALOG: List[AutomationBlueprint] = [
AutomationBlueprint(
key="morning-brief",
title="Morning briefing",
description="A short daily briefing: today's calendar, weather, and "
"anything urgent waiting on you.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Produce a concise morning briefing for the user: today's calendar "
"events, the local weather, and any urgent items. Keep it short and "
"scannable. If no data sources are connected, give a brief "
"good-morning with the date and offer to connect calendar/email."
),
slots=[_TIME("08:00"), _DELIVER],
tags=("daily", "briefing"),
),
AutomationBlueprint(
key="important-mail",
title="Important-mail monitor",
description="Check your inbox periodically and ping you ONLY about mail "
"that actually needs attention.",
category="email",
schedule_template="*/{interval_min} * * * *",
prompt_template=(
"Check the user's inbox for new messages since the last run. Surface "
"ONLY mail matching: {criteria}. Score candidates with the urgency "
"classifier and deliver only what clears the bar; if nothing does, "
"respond with [SILENT]. Requires a connected mail source; if none is "
"configured, explain how to connect one and stop."
),
slots=[
BlueprintSlot(
name="interval_min", type="enum", label="How often?",
default="30", options=("15", "30", "60"),
help="minutes between checks",
),
BlueprintSlot(
name="criteria", type="text",
label="Only notify me if the mail…",
default="needs a reply today, is from my manager or family, "
"or mentions a deadline",
),
_DELIVER,
],
tags=("email", "monitor"),
),
AutomationBlueprint(
key="weekly-review",
title="Weekly review",
description="A weekly recap: what got done, what's still open, and "
"what's coming up.",
category="weekly",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Produce a weekly review for the user: what was accomplished this "
"week, still-open items, and next week's calendar. Pull from "
"connected sources. Keep it tight."
),
slots=[
_TIME("18:00"),
BlueprintSlot(
name="day", type="enum", label="Which day?",
default="sunday",
options=("sunday", "monday", "friday", "saturday"),
),
_DELIVER,
],
tags=("weekly", "review"),
),
AutomationBlueprint(
key="workday-start",
title="Workday start reminder",
description="A weekday nudge with your agenda and top priorities.",
category="daily",
schedule_template="{minute} {hour} * * 1-5",
prompt_template=(
"Give the user a brief weekday start-of-day nudge: today's calendar "
"and the 1-3 highest-priority things to focus on, inferred from "
"recent context and any task tools. Encouraging, short, one message."
),
slots=[_TIME("09:00"), _DELIVER],
tags=("daily", "focus"),
),
AutomationBlueprint(
key="custom-reminder",
title="Custom reminder",
description="A recurring reminder in your own words, on your schedule.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template="Remind the user: {what}",
slots=[
BlueprintSlot(name="what", type="text", label="Remind me to…",
default="take a break and stretch"),
_TIME("14:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("reminder",),
),
AutomationBlueprint(
key="evening-winddown",
title="Evening wind-down",
description="An end-of-day check-in: tomorrow's calendar at a glance "
"and anything you should prep tonight.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Give the user a short evening wind-down: tomorrow's calendar, any "
"early commitments to prep for, and one gentle nudge to wrap up "
"loose ends from today. Keep it calm and brief — one message. If no "
"calendar is connected, just offer a friendly sign-off and the "
"weather for tomorrow."
),
slots=[_TIME("21:00"), _DELIVER],
tags=("daily", "evening"),
),
AutomationBlueprint(
key="news-digest",
title="Topic news digest",
description="A recurring digest on a topic you care about — deduped "
"against what was already sent, so only genuinely new items land.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Search the web for new and noteworthy items about: {topic}. "
"Dedupe against what you sent in previous runs — only include "
"genuinely new developments. Deliver a tight digest of at most "
"{count} bullets, each one line with a link. If nothing new since "
"last run, respond with [SILENT]."
),
slots=[
BlueprintSlot(
name="topic", type="text", label="What topic?",
default="AI and technology",
help="a subject, product, person, or search phrase",
),
_TIME("18:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()),
),
BlueprintSlot(
name="count", type="enum", label="How many bullets?",
default="5", options=("3", "5", "8"),
),
_DELIVER,
],
tags=("digest", "research"),
),
AutomationBlueprint(
key="bill-renewal-watch",
title="Bills & renewals reminder",
description="A heads-up before a recurring payment, subscription "
"renewal, or due date — so nothing auto-charges by surprise.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Remind the user about an upcoming payment or renewal: {what}. "
"Phrase it as an actionable heads-up (e.g. 'review or cancel before "
"it renews'), not just a notification. One short message."
),
slots=[
BlueprintSlot(
name="what", type="text", label="What's due?",
default="my streaming subscription renews soon",
),
_TIME("10:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("reminder", "finance"),
),
AutomationBlueprint(
key="habit-checkin",
title="Habit check-in",
description="A recurring nudge to keep a habit on track and reflect "
"on whether you did it.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Nudge the user about their habit: {habit}. Ask whether they did it "
"today, keep it warm and non-judgmental, and offer a one-line word "
"of encouragement. One short message."
),
slots=[
BlueprintSlot(
name="habit", type="text", label="Which habit?",
default="20 minutes of reading",
),
_TIME("20:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("habit", "wellbeing"),
),
AutomationBlueprint(
key="hydration-move",
title="Hydration & movement nudge",
description="A periodic nudge during the day to drink water, stand up, "
"and stretch.",
category="general",
# NOTE: cron minute-field steps (*/90) wrap per hour — */90 and */120
# both degrade to hourly. Use an hour-field step instead so the chosen
# cadence is what actually fires.
schedule_template="0 {start_hour}-{end_hour}/{interval_hours} * * 1-5",
prompt_template=(
"Send the user a brief, friendly nudge to drink some water, stand "
"up, and stretch for a moment. Vary the wording each time so it "
"doesn't feel robotic. One short line."
),
slots=[
BlueprintSlot(
name="interval_hours", type="enum", label="How often?",
default="1", options=("1", "2", "3"),
help="hours between nudges",
),
BlueprintSlot(
name="start_hour", type="enum", label="Start hour",
default="9", options=("7", "8", "9", "10"),
help="first hour of the active window (24h)",
),
BlueprintSlot(
name="end_hour", type="enum", label="End hour",
default="17", options=("16", "17", "18", "19"),
help="last hour of the active window (24h)",
),
_DELIVER,
],
tags=("wellbeing", "focus"),
),
AutomationBlueprint(
key="meal-plan",
title="Weekly meal plan",
description="A weekly meal plan plus a consolidated grocery list, "
"tuned to your diet and how much time you have to cook.",
category="weekly",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Build the user a meal plan for the coming week: {meals} per day, "
"suited to a {diet} diet and roughly {effort} cooking effort. "
"Include a consolidated grocery list grouped by aisle. Keep blueprints "
"simple and skimmable."
),
slots=[
BlueprintSlot(
name="diet", type="enum", label="Diet?",
default="no restrictions",
options=("no restrictions", "vegetarian", "vegan",
"high-protein", "low-carb"),
),
BlueprintSlot(
name="meals", type="enum", label="Meals per day?",
default="dinner only",
options=("dinner only", "lunch and dinner", "all three"),
),
BlueprintSlot(
name="effort", type="enum", label="Cooking effort?",
default="quick", options=("quick", "medium", "ambitious"),
),
_TIME("17:00"),
BlueprintSlot(
name="day", type="enum", label="Which day?",
default="sunday",
options=("sunday", "monday", "friday", "saturday"),
),
_DELIVER,
],
tags=("weekly", "food"),
),
AutomationBlueprint(
key="learn-daily",
title="Daily learning drip",
description="One bite-sized lesson a day on a topic you want to learn, "
"building progressively over time.",
category="daily",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Teach the user one bite-sized lesson about: {topic}. Build on "
"earlier lessons so it progresses rather than repeating. Keep it to "
"a couple of short paragraphs with one concrete example, and end "
"with a single question to check understanding."
),
slots=[
BlueprintSlot(
name="topic", type="text", label="Learn about…",
default="Spanish vocabulary",
),
_TIME("08:30"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("learning", "daily"),
),
AutomationBlueprint(
key="gratitude-journal",
title="Gratitude & reflection prompt",
description="A gentle evening prompt to reflect on the day and note "
"what went well.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Send the user a short, warm reflection prompt for the end of the "
"day — invite them to note one thing that went well, one thing they "
"are grateful for, and one small win. If they reply, acknowledge it "
"kindly. One message."
),
slots=[
_TIME("21:30"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("wellbeing", "reflection"),
),
AutomationBlueprint(
key="on-this-day",
title="On-this-day discovery",
description="A daily dose of curiosity: a notable historical event, "
"fact, or word for the day.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Give the user one interesting '{flavor}' item for today — keep it "
"short, surprising, and genuinely interesting. One or two sentences, "
"no filler."
),
slots=[
BlueprintSlot(
name="flavor", type="enum", label="What kind?",
default="on this day in history",
options=("on this day in history", "word of the day",
"science fact", "quote of the day"),
),
_TIME("07:30"),
_DELIVER,
],
tags=("daily", "curiosity"),
),
]
_CATALOG_BY_KEY = {r.key: r for r in CATALOG}
def get_blueprint(key: str) -> Optional[AutomationBlueprint]:
return _CATALOG_BY_KEY.get(key)
# ---------------------------------------------------------------------------
# Renderers
# ---------------------------------------------------------------------------
def blueprint_form_schema(blueprint: AutomationBlueprint) -> Dict[str, Any]:
"""Emit the JSON a form renderer (dashboard / GUI) needs for this blueprint."""
return {
"key": blueprint.key,
"title": blueprint.title,
"description": blueprint.description,
"category": blueprint.category,
"tags": list(blueprint.tags),
"fields": [
{
"name": s.name,
"type": s.type,
"label": s.label,
"default": s.default,
"options": list(s.options),
"optional": s.optional,
"strict": s.strict,
"help": s.help,
}
for s in blueprint.slots
],
}
def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the flattened ``/blueprint <key> slot=val …`` command string.
Uses each slot's default when ``values`` is omitted, so the docs/dashboard
can show a ready-to-paste command. Free-text slots are quoted.
"""
values = values or {}
parts = [f"/blueprint {blueprint.key}"]
for s in blueprint.slots:
val = values.get(s.name, s.default)
if val is None or val == "":
if s.optional:
continue
val = ""
sval = str(val)
if s.type == "text" or " " in sval:
sval = '"' + sval.replace('"', '\\"') + '"'
parts.append(f"{s.name}={sval}")
return " ".join(parts)
def blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the ``hermes://blueprint/<key>?slot=val`` deep-link URL."""
from urllib.parse import quote, urlencode
values = values or {}
query = {}
for s in blueprint.slots:
val = values.get(s.name, s.default)
if val not in (None, ""):
query[s.name] = str(val)
qs = ("?" + urlencode(query)) if query else ""
return f"hermes://blueprint/{quote(blueprint.key)}{qs}"
def _humanize_schedule(blueprint: AutomationBlueprint) -> str:
"""A short human-readable description of when a blueprint runs (defaults)."""
sched = blueprint.schedule_template
if sched.startswith("*/"):
iv = next((s for s in blueprint.slots if s.name == "interval_min"), None)
every = (iv.default if iv else None) or sched.split("/")[1].split()[0]
return f"every {every} minutes"
if "{interval_hours}" in sched:
iv = next((s for s in blueprint.slots if s.name == "interval_hours"), None)
every = str((iv.default if iv else None) or "1")
scope = "weekdays, " if "* * 1-5" in sched else ""
return f"{scope}every hour" if every == "1" else f"{scope}every {every} hours"
time_slot = next((s for s in blueprint.slots if s.type == "time"), None)
when = time_slot.default if time_slot else None
if "* * 1-5" in sched:
return f"weekdays at {when}" if when else "every weekday"
if "{dow}" in sched:
day_slot = next((s for s in blueprint.slots if s.name in ("day", "recurrence")), None)
scope = (day_slot.default if day_slot else "") or ""
if scope and when:
return f"{scope} at {when}"
return f"at {when}" if when else "on a schedule"
if when:
return f"daily at {when}"
return "on a schedule"
def blueprint_catalog_entry(blueprint: AutomationBlueprint) -> Dict[str, Any]:
"""Unified serializable shape for a blueprint — used by the docs generator
and the dashboard API. Combines the form schema, the ready-to-paste slash
command, the deep-link URL, and a human-readable schedule.
"""
return {
**blueprint_form_schema(blueprint),
"schedule": blueprint.schedule_template,
"scheduleHuman": _humanize_schedule(blueprint),
"command": blueprint_slash_command(blueprint),
"appUrl": blueprint_deeplink(blueprint),
}
# ---------------------------------------------------------------------------
# Fill + validate + translate to a create_job spec
# ---------------------------------------------------------------------------
_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
_DAY_TO_DOW = {
"sunday": "0", "monday": "1", "tuesday": "2", "wednesday": "3",
"thursday": "4", "friday": "5", "saturday": "6",
}
def _resolve_schedule(blueprint: AutomationBlueprint, values: Dict[str, Any]) -> str:
"""Fill the schedule_template placeholders from resolved slot values."""
sched = blueprint.schedule_template
# A free-text `schedule` slot passes through verbatim (full flexibility).
if "schedule" in values and values["schedule"]:
return str(values["schedule"])
repl: Dict[str, str] = {}
# time -> minute/hour
time_val = values.get("time")
if "{minute}" in sched or "{hour}" in sched:
if not time_val:
raise BlueprintFillError("a time is required")
m = _TIME_RE.match(str(time_val).strip())
if not m:
raise BlueprintFillError(f"invalid time {time_val!r} — use HH:MM (24h)")
repl["hour"] = str(int(m.group(1)))
repl["minute"] = str(int(m.group(2)))
# weekday set -> dow
if "{dow}" in sched:
if "recurrence" in values:
preset = str(values.get("recurrence", "everyday")).lower()
if preset not in WEEKDAY_PRESETS:
raise BlueprintFillError(
f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}"
)
repl["dow"] = WEEKDAY_PRESETS[preset]
elif "day" in values:
day = str(values.get("day", "")).lower()
if day not in _DAY_TO_DOW:
raise BlueprintFillError(f"unknown day {day!r}")
repl["dow"] = _DAY_TO_DOW[day]
else:
repl["dow"] = "*"
# interval (minutes) for */N schedules
if "{interval_min}" in sched:
iv = str(values.get("interval_min", "")).strip()
if not iv.isdigit() or int(iv) <= 0:
raise BlueprintFillError(f"invalid interval {iv!r} — minutes as a positive integer")
repl["interval_min"] = iv
# Any remaining {slot} placeholders are filled verbatim from validated
# enum/text slot values (e.g. an hour-range window). Enum options have
# already been checked in fill_blueprint, so these are safe to interpolate.
for name in re.findall(r"\{(\w+)\}", sched):
if name not in repl and name in values:
repl[name] = str(values[name])
try:
return sched.format(**repl)
except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error
raise BlueprintFillError(f"schedule template missing value for {e}") from e
def fill_blueprint(
blueprint: AutomationBlueprint,
values: Dict[str, Any],
*,
origin: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Validate ``values`` and return ``cron.jobs.create_job`` kwargs.
Missing required (non-optional) slots raise BlueprintFillError naming the
slot, so a form can show field errors and the agent knows what to ask.
Unknown slot names are rejected (a typo'd ``tiem=07:15`` must not silently
create a job with the default time). Enum values are checked against their
options. The result is passed straight to ``create_job`` no second schema.
"""
known = {s.name for s in blueprint.slots}
unknown = sorted(set(values) - known)
if unknown:
raise BlueprintFillError(
f"unknown slot{'s' if len(unknown) > 1 else ''}: "
f"{', '.join(unknown)} — valid: {', '.join(s.name for s in blueprint.slots)}"
)
resolved: Dict[str, Any] = {}
for s in blueprint.slots:
raw = values.get(s.name, s.default)
if raw in (None, ""):
if s.optional:
continue
raise BlueprintFillError(f"missing required value: {s.name} ({s.label})")
if s.type == "enum" and s.strict and s.options and str(raw) not in {str(o) for o in s.options}:
raise BlueprintFillError(
f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}"
)
resolved[s.name] = raw
schedule = _resolve_schedule(blueprint, resolved)
# Render the prompt with whatever slots it references.
try:
prompt = blueprint.prompt_template.format(**resolved)
except KeyError as e:
raise BlueprintFillError(f"blueprint prompt missing value for {e}") from e
spec: Dict[str, Any] = {
"prompt": prompt,
"schedule": schedule,
"name": blueprint.title,
"deliver": resolved.get("deliver", blueprint.deliver_default),
}
if blueprint.skills:
spec["skills"] = list(blueprint.skills)
if origin is not None:
spec["origin"] = origin
return spec
+1
View File
@@ -138,6 +138,7 @@ _HOME_TARGET_ENV_VARS = {
"bluebubbles": "BLUEBUBBLES_HOME_CHANNEL",
"qqbot": "QQBOT_HOME_CHANNEL",
"whatsapp": "WHATSAPP_HOME_CHANNEL",
"whatsapp_cloud": "WHATSAPP_CLOUD_HOME_CHANNEL",
}
# Legacy env var names kept for back-compat. Each entry is the current
+1
View File
@@ -0,0 +1 @@
"""Scripts shipped with the cron subsystem (runnable via ``python3 -m cron.scripts.<name>``)."""
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Classify candidate items by urgency/importance and emit only the urgent ones.
The proactive-monitor pattern: a fetch step (a watcher script, an inbox dump, a
feed) produces a list of candidate items; this script scores each with a cheap
LLM and prints ONLY the items at or above a threshold. Below-threshold runs
print nothing, so a cron job wrapping this stays silent unless something
actually matters -- the classic urgency-monitor pattern (fetch -> classify
urgency -> surface only what's above the bar).
Design choices:
* Uses Hermes' auxiliary client with task="monitor", so the classifier model
is configured once in config.yaml (auxiliary.monitor.{provider,model}) and
can be a cheap fast model independent of the main chat model.
* Reads items as JSON (a list of objects) from stdin or --input-file.
* One LLM call scores the whole batch (cheap, single round-trip) and returns
structured scores; we filter locally.
* Empty result -> empty stdout -> the cron job's [SILENT]/empty-stdout path
suppresses delivery. No spam on quiet intervals.
Usage (standalone):
cat items.json | python classify_items.py --threshold 7 \
--criteria "Urgent if it needs a reply today or is from my manager/family"
Usage (wired to a watcher via cron, agent mode):
Ask the agent: "Every 10 minutes, run watch_http_json.py for my inbox feed,
pipe its JSON into classify_items.py with my urgency criteria, and deliver
whatever it prints. Stay silent if it prints nothing."
Item schema (flexible): each item is an object; the classifier sees the whole
object. A "title"/"subject"/"summary"/"text" field helps it judge. An "id"
field (any of id/guid/message_id/url) is echoed back so duplicates can be
deduped upstream.
"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Any, Dict, List, Optional
def _eprint(*args: Any) -> None:
print(*args, file=sys.stderr)
def _load_items(input_file: Optional[str]) -> List[Dict[str, Any]]:
raw = ""
if input_file:
with open(input_file, encoding="utf-8") as f:
raw = f.read()
else:
raw = sys.stdin.read()
raw = raw.strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
_eprint(f"classify_items: input is not valid JSON: {e}")
sys.exit(2)
if isinstance(data, dict):
# Allow {"items": [...]} or a single object.
if isinstance(data.get("items"), list):
return data["items"]
return [data]
if isinstance(data, list):
return [x for x in data if isinstance(x, dict)]
_eprint("classify_items: expected a JSON list or {items: [...]}")
sys.exit(2)
def _item_id(item: Dict[str, Any], index: int) -> str:
for key in ("id", "guid", "message_id", "url", "link"):
val = item.get(key)
if val:
return str(val)
return f"item-{index}"
_CLASSIFY_INSTRUCTIONS = (
"You are an urgency classifier for a proactive assistant. You will be given "
"a numbered list of items and the user's importance criteria. Score EACH "
"item from 0 (ignore entirely) to 10 (interrupt the user now). Return ONLY a "
"JSON array, one object per item, in the same order: "
'[{"index": <int>, "score": <int 0-10>, "reason": "<short>"}]. '
"No prose, no markdown fences. Be conservative: most items should score low. "
"Only score high when the item clearly meets the user's criteria."
)
def _build_prompt(items: List[Dict[str, Any]], criteria: str) -> str:
lines = [f"USER IMPORTANCE CRITERIA:\n{criteria}\n", "ITEMS:"]
for i, item in enumerate(items):
# Show a compact view; the model sees the salient fields.
view = {
k: item[k]
for k in ("title", "subject", "summary", "text", "body", "from", "sender", "url")
if k in item
}
if not view:
view = item # fall back to the whole object
lines.append(f"[{i}] {json.dumps(view, ensure_ascii=False)[:1200]}")
lines.append(
"\nReturn the JSON array of scores now (one object per item, same order)."
)
return "\n".join(lines)
def _parse_scores(content: str, n_items: int) -> Dict[int, Dict[str, Any]]:
text = (content or "").strip()
# Tolerate accidental markdown fences.
if text.startswith("```"):
text = text.strip("`")
if "\n" in text:
text = text.split("\n", 1)[1]
try:
arr = json.loads(text)
except json.JSONDecodeError:
# Last-ditch: find the first [...] block.
start = text.find("[")
end = text.rfind("]")
if start >= 0 and end > start:
try:
arr = json.loads(text[start : end + 1])
except json.JSONDecodeError:
_eprint("classify_items: could not parse classifier output")
return {}
else:
_eprint("classify_items: classifier returned no JSON array")
return {}
out: Dict[int, Dict[str, Any]] = {}
if isinstance(arr, list):
for obj in arr:
if not isinstance(obj, dict):
continue
idx = obj.get("index")
if isinstance(idx, int) and 0 <= idx < n_items:
out[idx] = obj
return out
def main() -> int:
parser = argparse.ArgumentParser(description="Classify items by urgency; emit only urgent ones.")
parser.add_argument("--criteria", required=True, help="Plain-language importance criteria.")
parser.add_argument("--threshold", type=int, default=7, help="Minimum score (0-10) to surface. Default 7.")
parser.add_argument("--input-file", default=None, help="Read items JSON from this file instead of stdin.")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format for surfaced items.")
args = parser.parse_args()
items = _load_items(args.input_file)
if not items:
# Nothing to classify -> silent. This is the common quiet-interval case.
return 0
# Import here so --help works without the package importable.
try:
from agent.auxiliary_client import call_llm
except Exception as e: # pragma: no cover - import guard
_eprint(f"classify_items: cannot import auxiliary client: {e}")
return 3
prompt = _build_prompt(items, args.criteria)
try:
resp = call_llm(
task="monitor",
messages=[{"role": "user", "content": prompt}],
max_tokens=1024,
temperature=0,
)
content = resp.choices[0].message.content
if not isinstance(content, str):
content = str(content) if content else ""
except Exception as e:
# Classification failure is NOT silent -- surface it so a broken monitor
# doesn't quietly swallow important items. Non-zero exit -> cron alerts.
_eprint(f"classify_items: classifier call failed: {e}")
return 4
scores = _parse_scores(content, len(items))
surfaced = []
for i, item in enumerate(items):
s = scores.get(i)
score = s.get("score") if isinstance(s, dict) else None
if isinstance(score, int) and score >= args.threshold:
surfaced.append((i, item, s))
if not surfaced:
# Below threshold -> silent. Empty stdout; cron suppresses delivery.
return 0
if args.format == "json":
out = [
{
"id": _item_id(item, i),
"score": s.get("score"),
"reason": s.get("reason", ""),
"item": item,
}
for (i, item, s) in surfaced
]
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
blocks = []
for (i, item, s) in surfaced:
title = (
item.get("title")
or item.get("subject")
or item.get("summary")
or _item_id(item, i)
)
url = item.get("url") or item.get("link") or ""
reason = s.get("reason", "")
block = f"## [{s.get('score')}/10] {title}"
if url:
block += f"\n{url}"
if reason:
block += f"\n_{reason}_"
blocks.append(block)
print("\n\n".join(blocks))
return 0
if __name__ == "__main__":
sys.exit(main())
+154
View File
@@ -0,0 +1,154 @@
"""Curated catalog of starter cron-job suggestions.
These are the built-in automations Hermes can offer a new user out of the box
the ``catalog`` source of the unified suggestion surface. Each entry is a
ready-to-run ``cron.jobs.create_job`` spec wrapped as a suggestion; the user
accepts via ``/suggestions``. Nothing here auto-schedules.
The "important-mail monitor" entry is where the old proactive-monitor engine
lives now: its ``classify_items.py`` (poll a source -> LLM-score urgency ->
surface only above-threshold) is ONE catalog automation, not a standalone
feature.
Adding a catalog entry: append a CatalogEntry. Keep prompts self-contained
(cron jobs run with no chat context) and schedules sensible. The ``job_spec``
is passed verbatim to ``create_job`` on accept.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
__all__ = ["CatalogEntry", "CATALOG", "seed_catalog_suggestions", "classify_items_script_path"]
def classify_items_script_path() -> str:
"""Absolute path to the urgency classifier script shipped with cron/."""
return str((Path(__file__).resolve().parent / "scripts" / "classify_items.py"))
@dataclass(frozen=True)
class CatalogEntry:
"""A curated starter automation offered as a suggestion."""
key: str # stable dedup key (never re-offered once dismissed)
title: str
description: str
job_spec: Dict[str, Any] # kwargs for cron.jobs.create_job
# The curated set. Schedules use the cron/interval syntax create_job accepts.
CATALOG: List[CatalogEntry] = [
CatalogEntry(
key="catalog:daily-briefing",
title="Daily briefing",
description="Every morning at 8am, a short briefing: today's calendar, "
"weather, and anything urgent waiting on you.",
job_spec={
"prompt": (
"Produce a concise morning briefing for the user: today's "
"calendar events, the local weather, and any urgent items "
"(unread important email, due tasks). Keep it short and "
"scannable. If you have no connected data sources, give a brief "
"general good-morning with the date and offer to connect "
"calendar/email."
),
"schedule": "0 8 * * *",
"name": "Daily briefing",
"deliver": "origin",
},
),
CatalogEntry(
key="catalog:important-mail-monitor",
title="Important-mail monitor",
description="Check your inbox periodically and ping you ONLY about mail "
"that actually needs attention — never the newsletters.",
job_spec={
"prompt": (
"Check the user's inbox for new messages since the last run. "
"For each candidate, judge urgency against this rule: surface "
"only mail that needs a reply today, is from a manager/family "
"member, or mentions a deadline. Pipe candidates through the "
"urgency classifier (run `python3 -m cron.scripts.classify_items "
"--threshold 7 --criteria ...` from the hermes-agent install — "
"resolve the script path at run time, do not assume a fixed "
"location) and deliver ONLY what it returns. If nothing "
"clears the bar, respond with [SILENT] so the user is not "
"pinged. Requires a connected mail source; if none is "
"configured, explain how to connect one and then stop."
),
"schedule": "every 30m",
"name": "Important-mail monitor",
"deliver": "origin",
},
),
CatalogEntry(
key="catalog:weekly-review",
title="Weekly review",
description="Every Sunday evening, a recap of the week: what got done, "
"what's still open, and what's coming up next week.",
job_spec={
"prompt": (
"Produce a weekly review for the user: summarize what was "
"accomplished this week, list still-open items, and preview "
"next week's calendar. Pull from whatever sources are connected "
"(calendar, task tools, recent conversations). Keep it tight."
),
"schedule": "0 18 * * 0",
"name": "Weekly review",
"deliver": "origin",
},
),
CatalogEntry(
key="catalog:standup-reminder",
title="Workday start reminder",
description="A weekday nudge at 9am with your day's agenda and top "
"priorities, so you start focused.",
job_spec={
"prompt": (
"Give the user a brief weekday start-of-day nudge: their "
"calendar for today and the 1-3 highest-priority things to "
"focus on, inferred from recent context and any task tools. "
"Encouraging, short, one message."
),
"schedule": "0 9 * * 1-5",
"name": "Workday start reminder",
"deliver": "origin",
},
),
]
def seed_catalog_suggestions(
*,
add_fn: Optional[Callable[..., Optional[Dict[str, Any]]]] = None,
keys: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Register catalog entries as pending suggestions.
``add_fn`` defaults to ``cron.suggestions.add_suggestion`` (injectable for
tests). ``keys`` restricts to specific catalog entries; omit to seed all.
Entries already dismissed/accepted (by dedup key) or beyond the pending cap
are skipped by the store, so re-seeding is safe and idempotent. Returns the
list of suggestion records actually created.
"""
if add_fn is None:
from cron.suggestions import add_suggestion as add_fn # type: ignore[assignment]
wanted = set(keys) if keys else None
created: List[Dict[str, Any]] = []
for entry in CATALOG:
if wanted is not None and entry.key not in wanted:
continue
rec = add_fn(
title=entry.title,
description=entry.description,
source="catalog",
job_spec=dict(entry.job_spec),
dedup_key=entry.key,
)
if rec is not None:
created.append(rec)
return created
+257
View File
@@ -0,0 +1,257 @@
"""Suggested cron jobs — proposed automations the user accepts with one tap.
A *suggestion* is a ready-to-run cron job spec that Hermes surfaces to the
user, who accepts it (creates the real cron job) or dismisses it (latched so
it is never re-offered). This is the single surface every automation proposal
flows through, regardless of where it came from:
* ``catalog`` a curated starter automation (daily briefing, important-mail
monitor, weekly digest, ...).
* ``blueprint`` the user installed a skill that carries a ``blueprint:`` block
(see ``tools/blueprints.py``); installing it registers a
suggestion instead of auto-scheduling.
* ``usage`` the background self-improvement review noticed a recurring
ask that a scheduled job would serve.
* ``integration`` the user connected an account (Gmail, GitHub, ...) and
the obvious automations for that surface are offered.
Accepting a suggestion just calls the existing ``cron.jobs.create_job`` with
the stored ``job_spec`` there is NO second job engine. Suggestions never
auto-create jobs; acceptance is always explicit (consent-first). Dismissed
suggestions latch by a stable ``dedup_key`` so the same proposal is not
re-offered after the user says no.
Storage mirrors ``cron/jobs.py``: ``~/.hermes/cron/suggestions.json``, atomic
writes, an in-process lock, and 0600 perms.
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
import threading
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_constants import get_hermes_home
from hermes_time import now as _hermes_now
from utils import atomic_replace
logger = logging.getLogger(__name__)
CRON_DIR = get_hermes_home().resolve() / "cron"
SUGGESTIONS_FILE = CRON_DIR / "suggestions.json"
# In-process lock protecting load->modify->save cycles (the background review
# fork and the main agent can both write).
_suggestions_lock = threading.Lock()
# Cap pending suggestions so the list never becomes a nag wall. When full,
# new suggestions are dropped (the user should clear the backlog first).
MAX_PENDING = 5
VALID_SOURCES = frozenset({"catalog", "blueprint", "usage", "integration"})
_STATUS_PENDING = "pending"
_STATUS_ACCEPTED = "accepted"
_STATUS_DISMISSED = "dismissed"
def _secure_file(path: Path) -> None:
try:
os.chmod(path, 0o600)
except OSError:
pass
def _ensure_dir() -> None:
CRON_DIR.mkdir(parents=True, exist_ok=True)
def _load_raw() -> Dict[str, Any]:
if not SUGGESTIONS_FILE.exists():
return {"suggestions": []}
try:
with open(SUGGESTIONS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning("suggestions.json unreadable (%s); starting empty", e)
return {"suggestions": []}
if isinstance(data, dict) and isinstance(data.get("suggestions"), list):
return data
if isinstance(data, list):
return {"suggestions": data}
logger.warning("suggestions.json malformed; starting empty")
return {"suggestions": []}
def _save_raw(suggestions: List[Dict[str, Any]]) -> None:
_ensure_dir()
fd, tmp_path = tempfile.mkstemp(dir=str(SUGGESTIONS_FILE.parent), suffix=".tmp", prefix=".sugg_")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(
{"suggestions": suggestions, "updated_at": _hermes_now().isoformat()},
f,
indent=2,
)
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, SUGGESTIONS_FILE)
_secure_file(SUGGESTIONS_FILE)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def load_suggestions() -> List[Dict[str, Any]]:
"""Return all suggestion records (any status)."""
return _load_raw().get("suggestions", [])
def list_pending() -> List[Dict[str, Any]]:
"""Return pending suggestions in creation order (oldest first)."""
return [s for s in load_suggestions() if s.get("status") == _STATUS_PENDING]
def add_suggestion(
*,
title: str,
description: str,
source: str,
job_spec: Dict[str, Any],
dedup_key: str,
) -> Optional[Dict[str, Any]]:
"""Register a pending suggestion. Returns the record, or None if skipped.
Skipped when: the source is unknown, the same ``dedup_key`` was already
dismissed or accepted (never re-offer), an identical pending suggestion
exists, or the pending list is full (``MAX_PENDING``).
``job_spec`` is a dict of kwargs for ``cron.jobs.create_job`` accepting
the suggestion passes it straight through, so there is no second schema to
keep in sync.
"""
if source not in VALID_SOURCES:
raise ValueError(f"unknown suggestion source: {source!r}")
if not title.strip() or not dedup_key.strip():
raise ValueError("title and dedup_key are required")
with _suggestions_lock:
suggestions = _load_raw().get("suggestions", [])
# Never re-offer something the user already saw and decided on, and
# never duplicate a still-pending proposal.
for existing in suggestions:
if existing.get("dedup_key") == dedup_key:
if existing.get("status") in (_STATUS_DISMISSED, _STATUS_ACCEPTED):
return None
if existing.get("status") == _STATUS_PENDING:
return None
pending_count = sum(1 for s in suggestions if s.get("status") == _STATUS_PENDING)
if pending_count >= MAX_PENDING:
logger.info("Suggestion backlog full (%d); dropping %r", MAX_PENDING, title)
return None
record = {
"id": uuid.uuid4().hex[:12],
"title": title.strip(),
"description": description.strip(),
"source": source,
"job_spec": job_spec,
"dedup_key": dedup_key.strip(),
"status": _STATUS_PENDING,
"created_at": _hermes_now().isoformat(),
}
suggestions.append(record)
_save_raw(suggestions)
return record
def get_suggestion(ref: str) -> Optional[Dict[str, Any]]:
"""Resolve a suggestion by id, 1-based pending index, or title (exact)."""
suggestions = load_suggestions()
# By id.
for s in suggestions:
if s.get("id") == ref:
return s
# By 1-based pending index.
if ref.isdigit():
pending = [s for s in suggestions if s.get("status") == _STATUS_PENDING]
idx = int(ref) - 1
if 0 <= idx < len(pending):
return pending[idx]
# By exact title (case-insensitive).
for s in suggestions:
if s.get("title", "").lower() == ref.lower():
return s
return None
def _set_status(suggestion_id: str, status: str) -> bool:
with _suggestions_lock:
suggestions = _load_raw().get("suggestions", [])
changed = False
for s in suggestions:
if s.get("id") == suggestion_id:
s["status"] = status
s["resolved_at"] = _hermes_now().isoformat()
changed = True
break
if changed:
_save_raw(suggestions)
return changed
def dismiss_suggestion(ref: str) -> bool:
"""Dismiss a suggestion (latched — never re-offered for its dedup_key)."""
s = get_suggestion(ref)
if not s:
return False
return _set_status(s["id"], _STATUS_DISMISSED)
def accept_suggestion(ref: str, *, origin: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Accept a suggestion: create the real cron job from its ``job_spec``.
Returns the created cron job dict, or None if the suggestion isn't found /
not pending. The job_spec is passed straight to ``cron.jobs.create_job``;
an ``origin`` (platform/chat) is merged so "origin" delivery routes back to
the chat where the user accepted.
"""
s = get_suggestion(ref)
if not s or s.get("status") != _STATUS_PENDING:
return None
from cron.jobs import create_job
spec = dict(s.get("job_spec") or {})
if origin is not None and "origin" not in spec:
spec["origin"] = origin
job = create_job(**spec)
_set_status(s["id"], _STATUS_ACCEPTED)
return job
def clear_resolved() -> int:
"""Drop accepted/dismissed records from disk. Returns the count removed.
Pending suggestions and the dedup memory of dismissed ones are the only
things that matter long-term, but dismissed records must be RETAINED for
their dedup_key (so they aren't re-offered). This only prunes ACCEPTED
records, which have served their purpose once the job exists.
"""
with _suggestions_lock:
suggestions = _load_raw().get("suggestions", [])
kept = [s for s in suggestions if s.get("status") != _STATUS_ACCEPTED]
removed = len(suggestions) - len(kept)
if removed:
_save_raw(kept)
return removed

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