Compare commits

..
Author SHA1 Message Date
Teknium 3645ee221c docs(agent): verify edit-format steering against current Codex CLI + Claude Code source
Checked ~/agent-codebases (Codex @ 2026-05-30, Claude Code decompiled src):
- Codex: apply_patch is the only file-edit tool; apply_patch.lark is V4A;
  GPT-5.1/5.2(-codex) prompts mandate it; gated per-model via
  ModelInfo.apply_patch_tool_type. No str_replace editor in the repo.
- Claude Code: FileEditTool is old_string/new_string exact replacement with
  unique-match semantics — current Claude models train against str_replace
  in their first-party harness, not just the 2025 API text-editor tool.
2026-06-11 09:16:45 -07:00
Teknium 8d2e0127b3 fix(agent): gate skill-index pruning behind focus mode; drop redundant context-files line; cite edit-format sources
Follow-up to #43316 (coding-context posture):

- Skill-category pruning from the prompt's skill index now only fires under
  the opt-in 'focus' mode, matching the toolset-collapse gating. The default
  'auto' posture must never silently hide skills — a hidden skill is
  effectively never loaded proactively, footer note or not.
- Drop the 'Context files: AGENTS.md ...' line from the workspace snapshot:
  those files' full contents are already injected into the system prompt as
  the Project Context block, so naming them again is redundant.
- Replace the unsourced edit-format steering rationale with citations:
  OpenAI GPT-4.1 prompting guide (apply_patch/V4A), Anthropic text-editor
  tool docs (str_replace schema trained into the model), and the
  str_replace-style editors in SWE-agent/OpenHands/Qwen Code/Gemini CLI.
2026-06-11 09:16:45 -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
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
Teknium a09343cc96 feat(dashboard): SKILL.md editor on Skills page + attach-skill selector in cron modals (#44231)
Headless/VPS users (dashboard-over-Tailscale, no comfortable SSH) could
list/toggle/install skills and create/edit cron jobs, but not author a
custom skill or link one to a cron job — the UI set WHEN a job runs, but
not WHICH skill it uses.

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(matrix): stabilize CI requirement checks

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three fixes in the same stale-bridge class:

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

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

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

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

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

Omitted profile keeps legacy behavior everywhere.

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

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

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

Review findings (dev review on PR #44007):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #42973

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    thinking_1(signed) tool_use_1 thinking_2(signed) tool_use_2

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* desktop: keep slash popover live while typing args

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

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

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

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

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

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

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

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

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

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

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

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

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

* desktop: keep backend meta on slash arg completions

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

* cli: list real personalities in /personality completion

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

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

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

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

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

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

* desktop: unblock handoff retries and exact resume ids

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 01:49:24 +00:00
xxxigm 615ad97928 fix(streaming): stop socket read timeout from preempting stale-stream detector (#43570)
* fix(streaming): stop socket read timeout from preempting stale-stream detector

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

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

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

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

* chore: drop unrelated package-lock.json changes

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

(cherry picked from commit f72ee87d99ee38cb7b5badeb9a8af869bb92073a)

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

(cherry picked from commit d91942ebd4671ff857b5c8526dbf133f04782ecb)

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

(cherry picked from commit 32b3793418257617b8da57e26151f079c2620d00)

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

(cherry picked from commit c58467779436dcef44a80ad55b52664752dc0837)

* fix(desktop): persist resumed endpoint runtime

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

change(tooling): use new node everywhere
2026-06-10 11:59:34 -04:00
291 changed files with 22216 additions and 3046 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
node-version: 22
cache: npm
cache-dependency-path: website/package-lock.json
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
node-version: 22
cache: npm
cache-dependency-path: website/package-lock.json
+25
View File
@@ -0,0 +1,25 @@
# .github/workflows/typecheck.yml
name: Typecheck
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
typecheck:
runs-on: ubuntu-latest
strategy:
matrix:
package:
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
fail-fast: false # report all failures, not just the first one
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run --prefix ${{ matrix.package }} typecheck
+1 -1
View File
@@ -459,7 +459,7 @@ npm install # first time
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
npm start # production
npm run build # full build (hermes-ink + tsc)
npm run type-check # typecheck only (tsc --noEmit)
npm run typecheck # typecheck only (tsc --noEmit)
npm run lint # eslint
npm run fmt # prettier
npm test # vitest
+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
+110
View File
@@ -1571,6 +1571,15 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
if ptype == "input_text":
block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
elif ptype == "text":
# A stored Anthropic text block. Rebuild from whitelisted fields only —
# SDK response text blocks carry output-only siblings (parsed_output,
# citations=None) that the Messages INPUT schema rejects with HTTP 400
# "Extra inputs are not permitted". Do NOT dict(part) it verbatim.
block = {"type": "text", "text": part.get("text", "")}
cits = part.get("citations")
if isinstance(cits, list) and cits:
block["citations"] = cits
elif ptype in {"image_url", "input_image"}:
image_value = part.get("image_url", {})
url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
@@ -1685,6 +1694,58 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
return out
def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Strip output-only fields from a stored Anthropic content block so it is
valid as REQUEST input on replay.
The SDK response objects carry output-only attributes that the Messages
*input* schema forbids ("Extra inputs are not permitted"): text blocks get
``parsed_output``/``citations`` (when null), tool_use blocks get ``caller``,
etc. ``normalize_response`` captured blocks verbatim via ``_to_plain_data``,
so these leak back as input on the next turn → HTTP 400.
Whitelist per type (NOT a blacklist) so future SDK output-only fields can't
reintroduce the bug. Returns a clean block, or None to drop it.
"""
if not isinstance(b, dict):
return None
btype = b.get("type")
if btype == "text":
out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")}
# citations is input-valid ONLY when it's a non-empty list; the SDK
# emits citations=None on responses, which the input schema rejects.
cits = b.get("citations")
if isinstance(cits, list) and cits:
out["citations"] = cits
if isinstance(b.get("cache_control"), dict):
out["cache_control"] = b["cache_control"]
return out
if btype == "thinking":
out = {"type": "thinking", "thinking": b.get("thinking", "")}
if b.get("signature"):
out["signature"] = b["signature"]
return out
if btype == "redacted_thinking":
# Only valid with its data payload; drop if missing.
return {"type": "redacted_thinking", "data": b["data"]} if b.get("data") else None
if btype == "tool_use":
out = {
"type": "tool_use",
"id": _sanitize_tool_id(b.get("id", "")),
"name": b.get("name", ""),
"input": b.get("input", {}),
}
if isinstance(b.get("cache_control"), dict):
out["cache_control"] = b["cache_control"]
return out
if btype == "image":
src = b.get("source")
return {"type": "image", "source": src} if isinstance(src, dict) else None
# Unknown/unsupported block type on the input path — drop rather than risk
# another "Extra inputs are not permitted".
return None
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
"""Convert an assistant message to Anthropic content blocks.
@@ -1692,6 +1753,55 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
reasoning_content injection for Kimi/DeepSeek endpoints.
"""
content = m.get("content", "")
# Anthropic interleaved-thinking fast path: when this turn carries a
# verbatim, order-preserving block list (set by normalize_response only
# for turns that interleave SIGNED thinking with tool_use), replay it.
# Each block is run through _sanitize_replay_block to strip output-only
# SDK fields (parsed_output, caller, citations=None, …) that the Messages
# INPUT schema forbids — replaying them verbatim caused HTTP 400 "Extra
# inputs are not permitted" (text.parsed_output). Block ORDER is preserved
# (the reason this channel exists); only forbidden sibling fields are
# dropped, leaving thinking signatures and tool_use id/name/input intact.
ordered_blocks = m.get("anthropic_content_blocks")
if isinstance(ordered_blocks, list) and ordered_blocks:
# Re-source each tool_use input from the stored tool_calls map rather
# than the captured block. The ordered-blocks list captures tool_use
# input from the RAW API response (normalize_response), which is NOT
# credential-redacted; tool_calls[].function.arguments IS redacted at
# storage time (build_assistant_message, #19798). Replaying the raw
# block input would resurrect a secret the model inlined into a tool
# call (e.g. terminal(command="curl -H 'Authorization: Bearer sk-...'")
# onto the wire, even though the same value is redacted everywhere else
# in history. Keying by sanitized tool id preserves interleave order
# (the reason this channel exists) while swapping in the redacted
# input. Adapted from #36071 (replay-time tool-input re-sourcing).
redacted_input_by_id: Dict[str, Any] = {}
for tc in m.get("tool_calls", []) or []:
if not isinstance(tc, dict):
continue
fn = tc.get("function", {}) or {}
raw_args = fn.get("arguments", "{}")
try:
parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
except (json.JSONDecodeError, ValueError):
parsed_args = {}
redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args
replayed: List[Dict[str, Any]] = []
for b in ordered_blocks:
clean = _sanitize_replay_block(b)
if clean is None:
continue
if clean.get("type") == "tool_use":
# Override raw (un-redacted) input with the redacted copy when
# we have one for this id; fall back to the sanitized block
# input only if the tool_call is missing (shape mismatch).
redacted = redacted_input_by_id.get(clean.get("id", ""))
if redacted is not None:
clean["input"] = redacted
replayed.append(clean)
if replayed:
return {"role": "assistant", "content": replayed}
blocks = _extract_preserved_thinking_blocks(m)
if content:
if isinstance(content, list):
+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, "
+91 -1
View File
@@ -952,6 +952,18 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
if preserved:
msg["reasoning_details"] = preserved
# Anthropic interleaved-thinking replay: when a turn interleaves signed
# thinking blocks with tool_use, the parallel reasoning_details +
# tool_calls fields lose the cross-type ordering, and reconstruction
# front-loads thinking — reordering signed blocks and triggering HTTP 400
# ("thinking ... blocks in the latest assistant message cannot be
# modified"). Carry the verbatim ordered block list so the adapter can
# replay the latest assistant message unchanged. See
# agent/transports/anthropic.py and agent/anthropic_adapter.py.
ordered_blocks = getattr(assistant_message, "anthropic_content_blocks", None)
if ordered_blocks:
msg["anthropic_content_blocks"] = ordered_blocks
# Codex Responses API: preserve encrypted reasoning items for
# multi-turn continuity. These get replayed as input on the next turn.
codex_items = getattr(assistant_message, "codex_reasoning_items", None)
@@ -1603,6 +1615,8 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
is_streaming_access_denied_error,
normalize_converse_response,
stream_converse_with_callbacks,
)
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
@@ -1611,6 +1625,29 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
try:
raw_response = client.converse_stream(**api_kwargs)
except Exception as _bedrock_exc:
# IAM policies scoped to bedrock:InvokeModel only (no
# InvokeModelWithResponseStream) reject converse_stream()
# with AccessDeniedException. That denial is permanent for
# the session — fall back to the non-streaming converse()
# inline (it maps to bedrock:InvokeModel) and disable
# streaming for subsequent calls so we don't re-fail every
# turn.
if is_streaming_access_denied_error(_bedrock_exc):
agent._disable_streaming = True
agent._safe_print(
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
"falling back to non-streaming InvokeModel.\n"
" Grant that action to restore streaming output.\n"
)
logger.info(
"bedrock: converse_stream denied by IAM (%s) — "
"using non-streaming converse() for this session.",
type(_bedrock_exc).__name__,
)
result["response"] = normalize_converse_response(
client.converse(**api_kwargs)
)
return
# Evict the cached client on stale-connection failures
# so the outer retry loop builds a fresh client/pool.
if is_stale_connection_error(_bedrock_exc):
@@ -1698,6 +1735,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# poll loop uses this to detect stale connections that keep receiving
# SSE keep-alive pings but no actual data.
last_chunk_time = {"t": time.time()}
# Stale-stream patience, shared between the httpx socket read timeout
# (built in ``_call_chat_completions`` below) and the stale-stream detector
# (computed further down, before the worker thread starts). Initialized
# here so the read-timeout builder can floor itself at the stale value and
# never fire before the detector. ``None`` until the detector value is
# resolved, so the builder degrades to its plain default if it ever runs
# first.
_stream_stale_timeout = None
def _fire_first_delta():
if not first_delta_fired["done"] and on_first_delta:
@@ -1734,6 +1779,26 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"Local provider detected (%s) — stream read timeout raised to %.0fs",
agent.base_url, _stream_read_timeout,
)
elif (
_stream_read_timeout == 120.0
and _stream_stale_timeout is not None
and _stream_stale_timeout != float("inf")
and _stream_stale_timeout > _stream_read_timeout
):
# Cloud reasoning models (e.g. Opus) routinely pause mid-stream
# for minutes during extended thinking. The stale-stream
# detector is deliberately scaled up to tolerate this (180300s,
# see the stale-timeout block below), but the raw httpx socket
# read timeout defaulted to a flat 120s and fired *first* —
# tearing down a healthy reasoning stream before the stale
# detector (which owns retry + diagnostics) could act. Keep the
# socket read timeout in step with the detector so it no longer
# preempts it.
_stream_read_timeout = _stream_stale_timeout
logger.debug(
"Cloud reasoning stream — read timeout raised to %.0fs to "
"match stale-stream detector", _stream_read_timeout,
)
# Cap connect/pool at 60s even when provider timeout is higher.
# connect/pool cover TCP handshake, not model inference.
_conn_cap = min(_base_timeout, 60.0) if _provider_timeout_cfg is not None else 30.0
@@ -2384,9 +2449,34 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"stream" in _err_lower
and "not supported" in _err_lower
)
if _is_stream_unsupported:
# AWS Bedrock (AnthropicBedrock SDK path): IAM policies
# with bedrock:InvokeModel but not
# InvokeModelWithResponseStream reject messages.stream()
# with a permission error naming the streaming action.
# Permanent for the session — flip to non-streaming
# (messages.create() maps to bedrock:InvokeModel).
_is_bedrock_stream_denied = False
if (
not _is_stream_unsupported
and "invokemodelwithresponsestream" in _err_lower
):
# Cheap message pre-check before importing the
# adapter — bedrock_adapter triggers a lazy boto3
# install at import time, which must not run for
# unrelated providers' stream errors.
from agent.bedrock_adapter import (
is_streaming_access_denied_error,
)
_is_bedrock_stream_denied = (
is_streaming_access_denied_error(e)
)
if _is_stream_unsupported or _is_bedrock_stream_denied:
agent._disable_streaming = True
agent._safe_print(
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream. "
"Switching to non-streaming.\n"
" Grant that action to restore streaming output.\n"
if _is_bedrock_stream_denied else
"\n⚠ Streaming is not supported for this "
"model/provider. Switching to non-streaming.\n"
" To avoid this delay, set display.streaming: false "
+731
View File
@@ -0,0 +1,731 @@
"""Coding-context awareness — base Hermes, every interactive surface.
When the user runs Hermes inside a code workspace (CLI, TUI, desktop app, or an
editor over ACP), Hermes shifts into a **coding posture**. This module is the
single place that decides whether we're in that posture and what it implies,
so the rest of the codebase never re-derives "are we coding?" on its own.
Architecture one seam, many consumers
----------------------------------------
The posture is modelled as a frozen :class:`RuntimeMode` selected from a small
:class:`ContextProfile` registry (today: ``coding`` and ``general``). A profile
is *data* it declares the toolset to collapse to, the operating brief to
inject, and hints for other domains (model routing, memory, subagents). Every
domain reads the same resolved object instead of probing git/config itself:
* **System prompt** ``RuntimeMode.system_blocks()`` the operating brief +
a live git/workspace snapshot (``agent/system_prompt.py``).
* **Toolset** ``RuntimeMode.toolset_selection()`` the ``coding`` toolset
plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Only
under the opt-in ``focus`` mode: the default posture is prompt-only and
never touches the user's configured toolsets (toolsets like messaging /
smart-home / music are off-by-default anyway, and someone who explicitly
enabled image-gen or Spotify shouldn't lose it for being in a git repo).
* **Delegation** subagents inherit the parent's toolset and run through the
same prompt builder, so the coding posture propagates to children for free.
* **Model / memory / compression** declared on the profile
(``model_hint``, ``memory_policy``) as the extension seam; consumers read
``mode.profile`` rather than re-deciding.
Cache safety
------------
The mode is resolved **once** and is immutable. The workspace snapshot is built
once at prompt-build time and baked into the *stable* system-prompt tier never
re-probed per turn (that would shatter the prompt cache). Branch and dirty state
drift mid-session, so the brief tells the model to re-check with ``git`` before
acting on the snapshot. A ``/coding`` flip therefore only takes effect next
session (deferred), the same contract as ``/skills install`` vs ``--now``.
Activation (config ``agent.coding_context``):
* ``auto`` (default) posture (brief + snapshot + names-only demotion of
non-coding skill categories) on an interactive coding surface sitting in
a code workspace (git repo or recognised project root). Prompt-only;
toolsets untouched, no skill is ever hidden.
* ``focus`` like ``auto``, but additionally collapses the toolset to the
``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema.
* ``on`` force the posture anywhere (incl. non-workspaces). Prompt-only.
* ``off`` disable entirely.
"""
from __future__ import annotations
import json
import logging
import os
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger("hermes.coding_context")
CODING_TOOLSET = "coding"
# Surfaces where a coding posture makes sense under ``auto``. Messaging
# platforms (telegram, discord, slack, …) are intentionally absent — a chat bot
# in a group is not pair-programming.
INTERACTIVE_CODING_PLATFORMS = {"cli", "tui", "acp", "desktop", ""}
# Project-root signals that mark a directory as a code workspace even when it
# isn't (yet) a git repo. Cheap filename checks — no parsing.
_PROJECT_MARKERS = (
"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt",
"package.json", "tsconfig.json", "deno.json",
"Cargo.toml", "go.mod", "pom.xml", "build.gradle", "build.gradle.kts",
"Gemfile", "composer.json", "mix.exs", "pubspec.yaml",
"CMakeLists.txt", "Makefile", "Dockerfile",
"AGENTS.md", "CLAUDE.md", ".cursorrules",
)
# Agent-instruction files surfaced separately from manifests in the snapshot.
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
# Lockfile → package manager, checked in priority order.
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
_JS_LOCKFILES = (
("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"),
("yarn.lock", "yarn"), ("package-lock.json", "npm"),
)
# package.json scripts / Makefile targets worth surfacing as verify commands.
_VERIFY_TARGETS = ("test", "tests", "lint", "typecheck", "check", "build", "fmt", "format")
_MAX_VERIFY_COMMANDS = 8
_MAX_FACT_FILE_BYTES = 256 * 1024
_GIT_TIMEOUT = 2.5
# Per-model edit-format steering. Matching the edit tool format to how a model
# was trained reduces mistakes and wasted reasoning. Documented sources,
# verified against current first-party agent source (MayJun 2026):
# - GPT/Codex → V4A patch: Codex CLI's ONLY file-edit tool is apply_patch and
# its grammar (codex-rs/core/src/tools/handlers/apply_patch.lark) is exactly
# the V4A format; the GPT-5.1/5.2(-codex) prompts instruct "Use the
# `apply_patch` tool to edit files", and OpenAI gates the tool per model via
# ModelInfo.apply_patch_tool_type. No str_replace editor exists in Codex.
# (Earlier doc: the GPT-4.1 prompting guide ships apply_patch/V4A —
# https://developers.openai.com/cookbook/examples/gpt4-1_prompting_guide)
# - Claude → str_replace: Claude Code's FileEditTool is exact string
# replacement (old_string/new_string/replace_all, unique-match semantics) —
# current Claude models are RL'd against str_replace editing in their own
# first-party harness. Also Anthropic's API text-editor tool
# (`str_replace_based_edit_tool`) is schema-less: the schema is built into
# the model.
# https://platform.claude.com/docs/en/agents-and-tools/tool-use/text-editor-tool
# - Open-weight coding models → str_replace: the dominant open RL/agentic
# scaffolds (SWE-agent, OpenHands ACI) use str_replace-style editors, and
# Qwen Code / Gemini CLI ship old_string/new_string `replace` tools.
# Our `patch` tool exposes both: mode="patch" (V4A
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
# its native format. Unknown families get nothing (the brief's neutral wording
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
_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.",
),
"replace": (
("claude", "sonnet", "opus", "haiku",
"gemini", "gemma", "deepseek", "qwen", "kimi", "glm", "grok",
"hermes", "llama", "mistral", "devstral", "minimax"),
"- Edit format: author new files with `write_file`; for edits to "
"existing code prefer `patch` in `mode='replace'` — match a unique "
"snippet and swap it. Reach for `mode='patch'` (V4A) only when an edit "
"genuinely spans several files at once.",
),
}
def _model_family(model: Optional[str]) -> Optional[str]:
"""Classify a model id into an edit-format family key, or ``None``.
Used to steer the coding posture toward the edit tool format a model was
trained on. Family-agnostic by design: an unrecognised model gets ``None``
and the operating brief's neutral edit wording applies.
"""
if not model:
return None
lowered = model.lower()
for family, (needles, _line) in _EDIT_FORMAT_GUIDANCE.items():
if any(n in lowered for n in needles):
return family
return None
def _edit_format_line(model: Optional[str]) -> str:
"""The edit-format guidance line for this model's family (``""`` if none)."""
family = _model_family(model)
if family is None:
return ""
return _EDIT_FORMAT_GUIDANCE[family][1]
# Operating brief for the coding posture. Tool names referenced here (read_file,
# search_files, patch, write_file, terminal, todo) are in the coding toolset and
# in _HERMES_CORE_TOOLS, so they're present on every surface this fires on.
CODING_AGENT_GUIDANCE = (
"You are a coding agent pairing with the user inside their codebase. "
"Operate like a careful senior engineer.\n"
"\n"
"Gather context first:\n"
"- Read the relevant files with `read_file` and locate code with "
"`search_files` before changing anything. Trace a symbol to its definition "
"and usages rather than guessing its shape.\n"
"- Batch independent lookups: when several reads/searches don't depend on "
"each other, issue them together in one turn instead of one at a time.\n"
"- Never invent files, symbols, APIs, or imports. If you haven't seen it in "
"the repo, go look. Don't assume a library is available — check the project "
"manifest (pyproject.toml / package.json / Cargo.toml / go.mod) and how "
"neighbouring files import it.\n"
"\n"
"Make changes through the tools, not the chat:\n"
"- Edit with `patch`/`write_file`. Do NOT print code blocks to the user as "
"a substitute for editing — apply the change, then summarise it. Only show "
"code when the user explicitly asks to see it.\n"
"- Match the project's existing style and conventions; AGENTS.md / "
"CLAUDE.md / .cursorrules already in context win over your defaults. Touch "
"only what the task needs — no drive-by refactors, renames, or reformatting "
"— and add any imports/dependencies your code requires.\n"
"- If an edit fails to apply, re-read the file to get the current exact "
"contents before retrying — don't repeat a stale patch. If the same region "
"fails twice, rewrite the enclosing function or file with `write_file` "
"instead of attempting a third patch.\n"
"\n"
"Verify, and know when to stop:\n"
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
"tests/linter/build and confirm they pass before claiming the work is done.\n"
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
"paths for the same flaw and fix the class, not just the reported site.\n"
"- When fixing linter/type errors on a file, stop after about three "
"attempts on the same file and ask the user rather than looping.\n"
"- Track multi-step work with `todo`. Reference code as `path:line` instead "
"of pasting whole files.\n"
"\n"
"Respect the user's repo: don't commit, push, or rewrite history unless "
"asked, and never read, print, or commit secrets — leave `.env` and "
"credential files alone unless the user explicitly asks. The Workspace "
"block below is a snapshot from session start — re-run `git status`/"
"`git branch` before relying on it. Be concise: lead with the change or "
"answer, not a preamble."
)
# ── Context profiles (declarative posture definitions) ──────────────────────
@dataclass(frozen=True)
class ContextProfile:
"""A named operating posture. Pure data — consumers read these fields.
``toolset`` collapse to this toolset (+ enabled MCP) when no explicit
selection is pinned; ``None`` keeps the platform default.
``guidance`` operating brief injected into the stable system prompt;
``""`` injects nothing.
``model_hint`` routing preference key for smart model routing
(extension seam; not yet consumed by the router).
``memory_policy`` memory namespace/weighting hint (extension seam).
``compact_skill_categories`` skill categories DEMOTED to names-only in
the system-prompt skill index while this posture is
active. Never hidden: every skill name stays visible
(so memory-anchored recall keeps working) only the
descriptions are dropped to cut index noise. Deny-list
semantics so unknown/custom categories keep full
entries.
"""
name: str
toolset: Optional[str] = None
guidance: str = ""
model_hint: Optional[str] = None
memory_policy: str = "default"
compact_skill_categories: tuple[str, ...] = ()
# Skill categories that are clearly not part of a coding workflow. Demoted to
# names-only in the prompt's skill index while the coding posture is active
# (deny-list — anything not listed here, incl. custom user categories, keeps
# full entries). Coding-adjacent categories (devops, github, mcp,
# data-science, diagramming, research, security, …) are intentionally absent.
_NON_CODING_SKILL_CATEGORIES = (
"apple", "communication", "cooking", "creative", "email", "finance",
"gaming", "gifs", "health", "media", "music", "note-taking",
"productivity", "shopping", "smart-home", "social-media", "travel",
"yuanbao",
)
GENERAL_PROFILE = ContextProfile(name="general")
CODING_PROFILE = ContextProfile(
name="coding",
toolset=CODING_TOOLSET,
guidance=CODING_AGENT_GUIDANCE,
model_hint="coding",
memory_policy="project",
compact_skill_categories=_NON_CODING_SKILL_CATEGORIES,
)
_PROFILES: dict[str, ContextProfile] = {
GENERAL_PROFILE.name: GENERAL_PROFILE,
CODING_PROFILE.name: CODING_PROFILE,
}
def get_profile(name: str) -> ContextProfile:
"""Return a registered profile, falling back to ``general``."""
return _PROFILES.get(name, GENERAL_PROFILE)
# ── Helpers ─────────────────────────────────────────────────────────────────
def _coding_mode(config: Optional[dict[str, Any]]) -> str:
"""Return the normalized ``agent.coding_context`` mode (auto/focus/on/off)."""
if config is None:
try:
from hermes_cli.config import load_config
config = load_config()
except Exception:
config = {}
raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto")
mode = str(raw).strip().lower()
if mode in {"focus", "strict", "lean"}:
return "focus"
if mode in {"on", "true", "yes", "1", "always"}:
return "on"
if mode in {"off", "false", "no", "0", "never"}:
return "off"
return "auto"
def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
if cwd:
return Path(cwd).expanduser()
try:
from agent.runtime_cwd import resolve_agent_cwd
return resolve_agent_cwd()
except Exception:
return Path(os.getcwd())
def _git_root(cwd: Path) -> Optional[Path]:
current = cwd.resolve()
for parent in [current, *current.parents]:
if (parent / ".git").exists():
return parent
return None
def _home() -> Optional[Path]:
try:
return Path.home().resolve()
except (OSError, RuntimeError):
return None
def _marker_root(cwd: Path) -> Optional[Path]:
"""Nearest ancestor that looks like a project root, or ``None``.
Walks up at most a few levels so a manifest in the workspace root counts
even when the user is in a subdirectory. ``$HOME`` itself is skipped a
Makefile or AGENTS.md sitting in the home directory is global user config,
not a project-root signal.
"""
current = cwd.resolve()
home = _home()
for depth, parent in enumerate([current, *current.parents]):
if depth > 6:
break
if parent == home:
continue
for marker in _PROJECT_MARKERS:
if (parent / marker).exists():
return parent
return None
def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
"""Resolve which profile applies.
``auto``/``focus``: coding when the surface is interactive AND the cwd is a
code workspace (a git repo or a recognised project root). ``on``: always
coding. ``off``: always general.
A git repo rooted at ``$HOME`` (the dotfiles pattern) is NOT a workspace
signal without the guard, every session anywhere under a dotfiles-managed
home directory would silently flip to the coding posture.
Detection is intentionally not memoized: it's a handful of ``stat`` calls,
and callers resolve the mode once per session anyway. Caching here would
risk a stale posture if a long-lived process (gateway/TUI) serves sessions
from different working directories.
"""
if mode == "off":
return GENERAL_PROFILE.name
if mode == "on":
return CODING_PROFILE.name
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
return GENERAL_PROFILE.name
cwd = Path(cwd_str)
git_root = _git_root(cwd)
if git_root is not None and git_root == _home():
git_root = None # dotfiles repo at $HOME — not a code workspace
if git_root is not None or _marker_root(cwd) is not None:
return CODING_PROFILE.name
return GENERAL_PROFILE.name
# ── RuntimeMode (the seam) ──────────────────────────────────────────────────
@dataclass(frozen=True)
class RuntimeMode:
"""The resolved operating posture for a session. Immutable by construction.
Built once via :func:`resolve_runtime_mode` and consumed by every domain
that cares about the coding/general distinction. Never mutate or re-resolve
mid-session that would break the prompt cache.
"""
profile: ContextProfile
surface: str
cwd: Path
# The normalized ``agent.coding_context`` mode this posture was resolved
# under (auto/focus/on/off). Toolset collapse is gated on ``focus``.
config_mode: str = "auto"
# The model id this session runs (e.g. "anthropic/claude-opus-4.8"). Used
# only to steer edit-format guidance toward the model's family — see
# ``_edit_format_line``. Fixed for the session, so cache-safe.
model: Optional[str] = None
@property
def kind(self) -> str:
return self.profile.name
@property
def is_coding(self) -> bool:
return self.profile.name == CODING_PROFILE.name
def toolset_selection(self, config: Optional[dict[str, Any]] = None) -> Optional[list[str]]:
"""Toolset list for this posture, or ``None`` to keep the platform default.
Non-``None`` only under the opt-in ``focus`` mode. The default posture
is prompt-only: most strippable toolsets are off-by-default anyway, and
a user who explicitly enabled one (image-gen for frontend/game assets,
messaging for build notifications, ) keeps it while coding.
Callers apply this only when the user hasn't pinned an explicit
selection (``--toolsets``, ``HERMES_TUI_TOOLSETS``, ); they never
override a pin. Returns the profile's toolset plus enabled MCP servers.
"""
if self.config_mode != "focus":
return None
if self.profile.toolset is None:
return None
return [self.profile.toolset, *_enabled_mcp_servers(config)]
def system_blocks(self) -> list[str]:
"""Stable system-prompt blocks for this posture (brief + workspace).
The operating brief carries a model-family edit-format nudge appended
to it (one cached string, not a separate block) so the model is steered
toward the `patch` mode it handles best see ``_edit_format_line``.
"""
if not self.is_coding:
return []
blocks: list[str] = []
if self.profile.guidance:
brief = self.profile.guidance
edit_line = _edit_format_line(self.model)
if edit_line:
brief = f"{brief}\n{edit_line}"
blocks.append(brief)
workspace = build_coding_workspace_block(self.cwd)
if workspace:
blocks.append(workspace)
return blocks
def compact_skill_categories(self) -> frozenset[str]:
"""Skill categories to demote to names-only in the prompt's skill index.
Demoted never hidden. 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 from the index.
"""
if not self.is_coding:
return frozenset()
return frozenset(self.profile.compact_skill_categories)
def resolve_runtime_mode(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
model: Optional[str] = None,
) -> RuntimeMode:
"""Resolve the operating posture once. Cheap — a handful of ``stat`` calls.
This is the single entry point every domain should call. The returned
object is immutable and safe to cache for the session. Detection itself is
intentionally *not* memoized (see ``_detect_profile_name``) so a long-lived
process can't pin a stale posture; callers resolve once per session and
hold the result. ``model`` is recorded only to steer edit-format guidance;
it never affects detection.
"""
resolved_cwd = _resolve_cwd(cwd)
mode = _coding_mode(config)
name = _detect_profile_name(
mode, (platform or "").strip().lower(), str(resolved_cwd)
)
return RuntimeMode(
profile=get_profile(name),
surface=platform or "",
cwd=resolved_cwd,
config_mode=mode,
model=model,
)
# ── Back-compat surface (thin wrappers over RuntimeMode) ────────────────────
def is_coding_context(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
) -> bool:
"""Whether Hermes should operate in its coding posture right now."""
return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).is_coding
def coding_selection(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
) -> Optional[list[str]]:
"""Toolset selection for the coding posture.
``None`` unless the user opted into ``focus`` mode AND the posture is
active the default coding posture never overrides configured toolsets.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config
).toolset_selection(config)
def coding_system_blocks(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
model: Optional[str] = None,
) -> list[str]:
"""Stable system-prompt blocks for the current posture (empty when general).
``model`` steers the brief's edit-format nudge toward the model's family.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config, model=model
).system_blocks()
def coding_compact_skill_categories(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
) -> frozenset[str]:
"""Skill categories the active posture demotes to names-only in the index.
Empty outside the coding posture. Demoted never hidden: every skill
name stays in the index and remains loadable via ``skill_view`` /
``skills_list``; only descriptions are dropped.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config
).compact_skill_categories()
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
"""Names of MCP servers the user has enabled — kept in the coding posture.
MCP servers (figma, browser, tophat, ) are explicitly configured and part
of the coding workflow, not noise to strip.
"""
try:
from hermes_cli.config import read_raw_config
from hermes_cli.tools_config import _parse_enabled_flag
servers = read_raw_config().get("mcp_servers") or {}
return [
str(name)
for name, cfg in servers.items()
if isinstance(cfg, dict)
and _parse_enabled_flag(cfg.get("enabled", True), default=True)
]
except Exception:
return []
# ── git/workspace probe ─────────────────────────────────────────────────────
def _git(cwd: Path, *args: str) -> str:
try:
out = subprocess.run(
["git", "-C", str(cwd), *args],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT,
)
except (OSError, subprocess.SubprocessError):
return ""
return out.stdout.strip() if out.returncode == 0 else ""
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
"""Parse ``git status --porcelain=2 --branch`` into branch + counts."""
branch: dict[str, str] = {}
counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicts": 0}
for line in porcelain.splitlines():
if line.startswith("# branch.head"):
branch["head"] = line.split(maxsplit=2)[-1]
elif line.startswith("# branch.upstream"):
branch["upstream"] = line.split(maxsplit=2)[-1]
elif line.startswith("# branch.ab"):
parts = line.split()
branch["ahead"], branch["behind"] = parts[2].lstrip("+"), parts[3].lstrip("-")
elif line.startswith(("1 ", "2 ")):
xy = line.split(maxsplit=2)[1]
if xy[0] != ".":
counts["staged"] += 1
if xy[1] != ".":
counts["modified"] += 1
elif line.startswith("u "):
counts["conflicts"] += 1
elif line.startswith("? "):
counts["untracked"] += 1
return branch, counts
def _read_small(path: Path) -> str:
"""Read a small text file, or ``""`` — never raises, never reads huge files."""
try:
if not path.is_file() or path.stat().st_size > _MAX_FACT_FILE_BYTES:
return ""
return path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
def _project_facts(root: Path) -> list[str]:
"""Detected project facts for the workspace snapshot.
The point is to hand the model its *verify loop* up front which manifest,
which package manager, and the exact test/lint/build commands instead of
making it rediscover them every session. Cheap: stat calls plus reads of a
couple of small files; built once at prompt-build time (cache-safe).
"""
facts: list[str] = []
manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
package_managers = [
pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
]
if manifests:
line = f"- Project: {', '.join(manifests[:6])}"
if package_managers:
line += f" ({'/'.join(dict.fromkeys(package_managers))})"
facts.append(line)
verify: list[str] = []
if (root / "scripts" / "run_tests.sh").is_file():
verify.append("scripts/run_tests.sh")
if (root / "package.json").is_file():
try:
scripts = json.loads(_read_small(root / "package.json") or "{}").get("scripts") or {}
except (json.JSONDecodeError, AttributeError):
scripts = {}
js_pm = next((pm for lock, pm in _JS_LOCKFILES if (root / lock).is_file()), "npm")
verify.extend(f"{js_pm} run {name}" for name in _VERIFY_TARGETS if name in scripts)
if (root / "pytest.ini").is_file() or "[tool.pytest" in _read_small(root / "pyproject.toml"):
verify.append("pytest")
makefile = _read_small(root / "Makefile")
if makefile:
verify.extend(
f"make {name}" for name in _VERIFY_TARGETS
if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
)
if verify:
deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
facts.append(f"- Verify: {'; '.join(deduped)}")
# Note: context files (AGENTS.md / CLAUDE.md / .cursorrules) are NOT listed
# here — their full contents are already injected into the system prompt as
# the Project Context block, so naming them again is redundant.
return facts
def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
"""Workspace snapshot for the system prompt (empty outside a workspace).
Git state (branch/status/commits) when the cwd is in a repo, plus detected
project facts (manifest, package manager, verify commands, context files)
so marker-only (non-git) projects still get a snapshot.
"""
resolved = _resolve_cwd(cwd)
git_root = _git_root(resolved)
root = git_root or _marker_root(resolved)
if root is None:
return ""
lines = ["Workspace (snapshot at session start — re-check with `git` before acting on it):"]
lines.append(f"- Root: {root}")
if git_root is not None:
branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch"))
head = branch.get("head", "")
if head and head != "(detached)":
line = f"- Branch: {head}"
if branch.get("upstream"):
line += f" \u2192 {branch['upstream']}"
ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0")
if ahead != "0" or behind != "0":
line += f" (ahead {ahead}, behind {behind})"
lines.append(line)
elif head == "(detached)":
lines.append("- Branch: (detached HEAD)")
# Linked worktree: the per-worktree git dir differs from the shared common dir.
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})")
dirty = [f"{n} {label}" for label, n in (
("staged", counts["staged"]), ("modified", counts["modified"]),
("untracked", counts["untracked"]), ("conflicts", counts["conflicts"]),
) if n]
lines.append(f"- Status: {', '.join(dirty) if dirty else 'clean'}")
recent = _git(root, "log", "-3", "--pretty=%h %s")
if recent:
lines.append("- Recent commits:")
lines.extend(f" {c}" for c in recent.splitlines())
lines.extend(_project_facts(root))
return "\n".join(lines)
+35 -11
View File
@@ -2221,30 +2221,54 @@ def run_conversation(
print(f"{agent.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"")
print(f"{agent.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"")
# ── Thinking block signature recovery ─────────────────
# Thinking block signature recovery.
#
# Anthropic signs thinking blocks against the full turn
# content. Any upstream mutation (context compression,
# content. Any upstream mutation (context compression,
# session truncation, message merging) invalidates the
# signature → HTTP 400. Recovery: strip reasoning_details
# from all messages so the next retry sends no thinking
# blocks at all. One-shot — don't retry infinitely.
# signature and the API replies HTTP 400 ("invalid
# signature" or "cannot be modified"). Recovery strips
# ``reasoning_details`` so the retry sends no thinking
# blocks at all. One-shot per outer loop.
#
# The strip targets ``api_messages``, which is the
# API-call-time list that ``_build_api_kwargs`` consumes
# on every retry. ``api_messages`` was populated once at
# the start of the turn from shallow copies of
# ``messages``, so mutating it does not touch the
# canonical store. The previous implementation popped
# ``reasoning_details`` from ``messages`` instead, which
# had two problems: ``api_messages`` carried its own
# reference to the field through the shallow copy, so the
# retry's wire payload still included thinking blocks and
# the recovery never reached the API; and the mutation
# persisted into ``state.db`` through any subsequent
# ``_persist_session`` call, permanently corrupting the
# conversation. Future turns would replay the stripped
# state, hit the same 400, and the agent would terminate
# with ``max_retries_exhausted``, often spawning
# cascading compaction-ended sessions chained off the
# corrupted parent.
if (
classified.reason == FailoverReason.thinking_signature
and not _retry.thinking_sig_retry_attempted
):
_retry.thinking_sig_retry_attempted = True
for _m in messages:
if isinstance(_m, dict):
_api_stripped = 0
for _m in api_messages:
if isinstance(_m, dict) and "reasoning_details" in _m:
_m.pop("reasoning_details", None)
_api_stripped += 1
agent._vprint(
f"{agent.log_prefix}⚠️ Thinking block signature invalid "
f"stripped all thinking blocks, retrying...",
f"{agent.log_prefix}⚠️ Thinking block signature invalid, "
f"stripped reasoning_details from api_messages for retry...",
force=True,
)
logger.warning(
"%sThinking block signature recovery: stripped "
"reasoning_details from %d messages",
agent.log_prefix, len(messages),
"reasoning_details from %d api_messages "
"(canonical messages unchanged)",
agent.log_prefix, _api_stripped,
)
continue
+73 -12
View File
@@ -194,17 +194,71 @@ class AgentNotice:
id: Optional[str] = None
# ── is_free_tier_model (local-data-only free-model check) ────────────────────
def is_free_tier_model(model: str, base_url: str = "") -> bool:
"""Return True when *model* is a Nous free-tier model, using ONLY local data.
Two signals, both zero-network:
1. The ``:free`` suffix the canonical Nous free SKU marker (e.g.
``nvidia/nemotron-3-ultra:free``). Free by construction on the API side
(spend is forced to 0 for ``:free`` ids).
2. A peek into the in-process pricing cache in ``hermes_cli.models``
(populated when the model picker fetched ``/v1/models`` pricing for
*base_url*). PEEK ONLY a cache miss never triggers a fetch. This is
CLI/TUI-session best-effort: gateway sessions never run the picker's
pricing fetch, so suppression there rests entirely on the ``:free``
suffix (which all Nous free SKUs carry).
Fail-open to False (the depleted notice still shows) on any error: wrongly
showing the warning is recoverable noise; wrongly hiding it on a paid model
would mask a real billing block.
"""
if not model:
return False
if model.endswith(":free"):
return True
if not base_url:
return False
try:
from hermes_cli.models import _is_model_free, _pricing_cache
# Mirror get_pricing_for_provider's key normalization: the agent's
# Nous base_url is /v1-suffixed (https://inference-api.nousresearch.com/v1)
# but the picker keys _pricing_cache on the pre-/v1 root.
key = base_url.rstrip("/")
if key.endswith("/v1"):
key = key[:-3].rstrip("/")
pricing = _pricing_cache.get(key)
if not pricing:
return False
return _is_model_free(model, pricing)
except Exception:
return False
# ── evaluate_credits_notices (pure reconciliation function) ──────────────────
def evaluate_credits_notices(
state: CreditsState,
latch: dict,
*,
model_is_free: bool = False,
) -> tuple[list[AgentNotice], list[str]]:
"""Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE.
latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}.
``model_is_free``: True when the session's active model is a Nous free-tier
model (see :func:`is_free_tier_model`). Suppresses the ``credits.depleted``
notice a depleted account on a free model can keep inferencing, so the
error banner is noise (and confuses free-tier users who never had credits).
Suppression does NOT emit the "restored" success notice; that fires only on
a genuine ``paid_access`` flip back to True.
Returns ``(to_show: list[AgentNotice], to_clear: list[str])``.
Caller emits to_clear FIRST, then to_show.
@@ -284,7 +338,11 @@ def evaluate_credits_notices(
active.discard("credits.grant_spent")
# ── depleted ─────────────────────────────────────────────────────────────
if depleted_cond and "credits.depleted" not in active:
# Suppressed while the active model is free: inference still works there,
# so the error banner would just alarm users (free-tier users especially,
# who never had paid credits to "lose").
show_depleted = depleted_cond and not model_is_free
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /usage for balance",
@@ -295,20 +353,23 @@ def evaluate_credits_notices(
)
)
active.add("credits.depleted")
elif "credits.depleted" in active and not depleted_cond:
elif "credits.depleted" in active and not show_depleted:
to_clear.append("credits.depleted")
active.discard("credits.depleted")
# Recovery: also emit the success notice
to_show.append(
AgentNotice(
text="✓ Credit access restored",
level="success",
kind="ttl",
ttl_ms=CREDITS_RESTORED_TTL_MS,
key="credits.restored",
id="credits.restored",
if not depleted_cond:
# Genuine recovery (paid_access flipped back True): also emit the
# success notice. A clear caused by switching to a free model while
# still depleted must NOT claim access was restored.
to_show.append(
AgentNotice(
text="✓ Credit access restored",
level="success",
kind="ttl",
ttl_ms=CREDITS_RESTORED_TTL_MS,
key="credits.restored",
id="credits.restored",
)
)
)
return (to_show, to_clear)
+19 -3
View File
@@ -858,6 +858,20 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
return False, ""
def _used_free_parallel(result: str | None) -> bool:
"""True when a web result came from Parallel's free Search MCP.
Only the keyless Parallel path tags its result with ``provider="parallel"``;
the paid REST path and every other provider omit it. Used to label the tool
line "Parallel search" / "Parallel fetch" exactly when the free MCP served
the call.
"""
if not isinstance(result, str) or '"provider"' not in result:
return False
data = safe_json_loads(result)
return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel"
def get_cute_tool_message(
tool_name: str, args: dict, duration: float, result: str | None = None,
) -> str:
@@ -895,15 +909,17 @@ def get_cute_tool_message(
return f"{line}{failure_suffix}"
if tool_name == "web_search":
return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}")
verb = "Parallel search" if _used_free_parallel(result) else "search"
return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}")
if tool_name == "web_extract":
verb = "Parallel fetch" if _used_free_parallel(result) else "fetch"
urls = args.get("urls", [])
if urls:
url = urls[0] if isinstance(urls, list) else str(urls)
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 fetch pages {dur}")
return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 {verb:<9} pages {dur}")
if tool_name == "terminal":
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
if tool_name == "process":
+21 -3
View File
@@ -549,14 +549,32 @@ def classify_api_error(
should_fallback=True,
)
# Anthropic thinking block signature invalid (400).
# Anthropic thinking block recovery (400). Two distinct failure modes,
# same recovery (strip all reasoning_details and retry without thinking
# blocks — see the thinking_signature handler in conversation_loop.py):
# 1. Signature mismatch: a thinking block is signed against the full
# turn content; any upstream mutation (context compression, session
# truncation, message merging) invalidates the signature.
# Pattern: "signature" + "thinking".
# 2. Frozen-block mutation: Anthropic rejects any change to the
# thinking/redacted_thinking blocks in the *latest* assistant
# message — "`thinking` or `redacted_thinking` blocks in the latest
# assistant message cannot be modified. These blocks must remain as
# they were in the original response." This carries no "signature"
# token, so the original pattern missed it and the turn hard-aborted
# as a non-retryable client error instead of self-healing.
# Pattern: "thinking" + ("cannot be modified" | "must remain as they were").
# Don't gate on provider — OpenRouter proxies Anthropic errors, so the
# provider may be "openrouter" even though the error is Anthropic-specific.
# The message pattern ("signature" + "thinking") is unique enough.
# The combined patterns are unique enough.
if (
status_code == 400
and "signature" in error_msg
and "thinking" in error_msg
and (
"signature" in error_msg
or "cannot be modified" in error_msg
or "must remain as they were" in error_msg
)
):
return _result(
FailoverReason.thinking_signature,
+38 -3
View File
@@ -1101,11 +1101,12 @@ def _skill_should_show(
def build_skills_system_prompt(
available_tools: "set[str] | None" = None,
available_toolsets: "set[str] | None" = None,
compact_categories: "frozenset[str] | None" = None,
) -> str:
"""Build a compact skill index for the system prompt.
Two-layer cache:
1. In-process LRU dict keyed by (skills_dir, tools, toolsets)
1. In-process LRU dict keyed by (skills_dir, tools, toolsets, hidden)
2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by
mtime/size manifest survives process restarts
@@ -1115,6 +1116,12 @@ def build_skills_system_prompt(
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
are read-only they appear in the index but new skills are always created
in the local dir. Local skills take precedence when names collide.
``compact_categories`` (e.g. from the coding posture see
agent/coding_context.py) demotes whole categories to a names-only line in
the rendered index. Nothing is ever hidden: every skill name stays
visible and loadable via ``skill_view`` / ``skills_list``; only the
descriptions are dropped, and a footer note explains the demotion.
"""
skills_dir = get_skills_dir()
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
@@ -1139,6 +1146,7 @@ def build_skills_system_prompt(
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
_platform_hint,
tuple(sorted(disabled)),
tuple(sorted(compact_categories or ())),
)
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
@@ -1272,18 +1280,44 @@ def build_skills_system_prompt(
except Exception as e:
logger.debug("Could not read external skill description %s: %s", desc_file, e)
# Posture-driven category demotion (e.g. non-coding skills while pairing
# on code). Demoted categories stay in the index as a single names-only
# line — descriptions are dropped to cut noise, but every skill name
# remains visible so memory-anchored recall ("load <name>") keeps working.
# NEVER remove entries entirely: agent-created skills are the model's
# project memory, and models don't reach for skills_list to rediscover
# what the index stops showing them. Match on the top-level category
# segment so nested categories ("social-media/twitter") are demoted with
# their parent.
demoted = frozenset(
cat for cat in skills_by_category
if cat.split("/", 1)[0] in (compact_categories or frozenset())
)
hidden_note = ""
if demoted:
hidden_note = (
"\n(Categories marked [names only] are outside the current coding "
"context, so their descriptions are omitted — the skills work "
"normally and load with skill_view(name) as usual.)"
)
if not skills_by_category:
result = ""
else:
index_lines = []
for category in sorted(skills_by_category.keys()):
# Deduplicate and sort skills within each category
seen = set()
if category in demoted:
names = sorted({name for name, _ in skills_by_category[category]})
index_lines.append(f" {category} [names only]: {', '.join(names)}")
continue
cat_desc = category_descriptions.get(category, "")
if cat_desc:
index_lines.append(f" {category}: {cat_desc}")
else:
index_lines.append(f" {category}:")
# Deduplicate and sort skills within each category
seen = set()
for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
if name in seen:
continue
@@ -1320,6 +1354,7 @@ def build_skills_system_prompt(
"</available_skills>\n"
"\n"
"Only proceed without loading a skill if genuinely none are relevant to the task."
+ hidden_note
)
# ── Store in LRU cache ────────────────────────────────────────────
+33
View File
@@ -191,9 +191,22 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
if toolset
}
# Coding posture 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 memory-anchored recall).
_compact_cats = frozenset()
try:
from agent.coding_context import coding_compact_skill_categories
_compact_cats = coding_compact_skill_categories(
platform=agent.platform, cwd=resolve_context_cwd()
)
except Exception:
_compact_cats = frozenset()
skills_prompt = _r.build_skills_system_prompt(
available_tools=agent.valid_tool_names,
available_toolsets=avail_toolsets,
compact_categories=_compact_cats or None,
)
else:
skills_prompt = ""
@@ -221,6 +234,26 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if _env_hints:
stable_parts.append(_env_hints)
# Coding posture (base Hermes, any interactive coding surface in a code
# workspace — see agent/coding_context.py). The operating brief + the live
# git/workspace snapshot are built once here and cached for the session;
# the snapshot is never re-probed per turn (that would break the prompt
# cache), so the brief tells the model to re-check git before relying on it.
if agent.valid_tool_names:
try:
from agent.coding_context import coding_system_blocks
stable_parts.extend(
coding_system_blocks(
platform=agent.platform,
cwd=resolve_context_cwd(),
model=agent.model,
)
)
except Exception:
# Coding-context probing must never block prompt build.
pass
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
# something is non-default so the model can pick the right install
# strategy without discovering by failure. Emits a single line; emits
+4 -4
View File
@@ -417,7 +417,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# ── Logging / callbacks ──────────────────────────────────────────
tool_names_str = ", ".join(name for _, name, _, _, _, _ in parsed_calls)
if not agent.quiet_mode:
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
args_str = json.dumps(args, ensure_ascii=False)
@@ -702,7 +702,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
if agent._should_emit_quiet_tool_messages():
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
agent._safe_print(f" {cute_msg}")
elif getattr(agent, "tool_progress_mode", "all") != "off":
elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
_preview_str = _multimodal_text_summary(function_result)
if agent.verbose_logging:
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
@@ -866,7 +866,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
elif function_name == "skill_manage":
agent._iters_since_skill = 0
if not agent.quiet_mode:
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
args_str = json.dumps(function_args, ensure_ascii=False)
if agent.verbose_logging:
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})")
@@ -1384,7 +1384,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# entire batch. The model sees it on the next API iteration.
agent._apply_pending_steer_to_tool_results(messages, 1)
if not agent.quiet_mode:
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
if agent.verbose_logging:
print(f" ✅ Tool {i} completed in {tool_duration:.2f}s")
print(agent._wrap_verbose("Result: ", function_result))
+48 -5
View File
@@ -84,7 +84,7 @@ class AnthropicTransport(ProviderTransport):
to OpenAI finish_reason, and collects reasoning_details in provider_data.
"""
import json
from agent.anthropic_adapter import _to_plain_data
from agent.anthropic_adapter import _to_plain_data, _sanitize_replay_block
from agent.transports.types import ToolCall
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
@@ -94,14 +94,40 @@ class AnthropicTransport(ProviderTransport):
reasoning_parts = []
reasoning_details = []
tool_calls = []
# Verbatim, order-preserving copy of every content block in the turn.
# Anthropic signs each thinking block against the turn content that
# PRECEDES it at its position; when a turn interleaves thinking and
# tool_use (adaptive/interleaved thinking, Claude 4.6+), the parallel
# reasoning_details + tool_calls lists below lose that cross-type
# ordering. Replaying the latest assistant message in the wrong order
# invalidates the signatures -> HTTP 400 "thinking ... blocks in the
# latest assistant message cannot be modified". Preserve the exact
# block sequence here so the adapter can replay it unchanged. See
# tests/agent/test_anthropic_thinking_block_order.py.
ordered_blocks = []
for block in response.content:
block_dict = _to_plain_data(block)
clean_block = None
if isinstance(block_dict, dict):
# Sanitize at capture so output-only SDK fields (parsed_output,
# caller, citations=None, …) never persist to state.db and leak
# back as request input on replay → HTTP 400 "Extra inputs are
# not permitted". Defence-in-depth with the replay-side sanitize.
clean_block = _sanitize_replay_block(block_dict)
if clean_block is not None:
ordered_blocks.append(clean_block)
if block.type == "text":
text_parts.append(block.text)
elif block.type == "thinking":
reasoning_parts.append(block.thinking)
block_dict = _to_plain_data(block)
if isinstance(block_dict, dict):
elif block.type in ("thinking", "redacted_thinking"):
if block.type == "thinking":
reasoning_parts.append(block.thinking)
# Use the sanitized block (clean_block) for reasoning_details too,
# since _extract_preserved_thinking_blocks replays these on the
# non-ordered path. Falls back to raw only if sanitize dropped it.
if isinstance(clean_block, dict):
reasoning_details.append(clean_block)
elif isinstance(block_dict, dict):
reasoning_details.append(block_dict)
elif block.type == "tool_use":
name = block.name
@@ -130,6 +156,23 @@ class AnthropicTransport(ProviderTransport):
provider_data = {}
if reasoning_details:
provider_data["reasoning_details"] = reasoning_details
# Only worth carrying the ordered-blocks channel when the turn
# actually interleaves signed thinking with tool_use — that's the
# only shape the parallel lists reconstruct incorrectly. A turn that
# is purely text, or thinking-then-tools with a single leading
# thinking block, replays correctly without it.
_has_signed_thinking = any(
isinstance(b, dict)
and b.get("type") in ("thinking", "redacted_thinking")
and (b.get("signature") or b.get("data"))
for b in ordered_blocks
)
_has_tool_use = any(
isinstance(b, dict) and b.get("type") == "tool_use"
for b in ordered_blocks
)
if _has_signed_thinking and _has_tool_use:
provider_data["anthropic_content_blocks"] = ordered_blocks
return NormalizedResponse(
content="\n".join(text_parts) if text_parts else None,
+12
View File
@@ -121,6 +121,18 @@ class NormalizedResponse:
pd = self.provider_data or {}
return pd.get("reasoning_details")
@property
def anthropic_content_blocks(self):
"""Verbatim, order-preserving Anthropic content blocks for a turn.
Present only when an Anthropic turn interleaves signed thinking with
tool_use the one shape the parallel reasoning_details + tool_calls
lists reconstruct in the wrong order, invalidating thinking-block
signatures on replay. See agent/transports/anthropic.py.
"""
pd = self.provider_data or {}
return pd.get("anthropic_content_blocks")
@property
def codex_reasoning_items(self):
pd = self.provider_data or {}
+3 -2
View File
@@ -11,7 +11,8 @@
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:build:debug": "tauri build --debug"
"tauri:build:debug": "tauri build --debug",
"typecheck": "tsc -p . --noEmit"
},
"dependencies": {
"@nous-research/ui": "0.16.0",
@@ -40,7 +41,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "~5.9.3",
"typescript": "^6.0.3",
"vite": "^7.3.1"
}
}
+1 -2
View File
@@ -16,9 +16,8 @@
"noUnusedParameters": true,
"esModuleInterop": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["./src/*"]
}
},
"include": ["src"],
+1 -1
View File
@@ -93,7 +93,7 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl
```bash
npm run fix
npm run type-check
npm run typecheck
npm run lint
npm run test:desktop:all
```
+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)
})
+71 -103
View File
@@ -22,7 +22,7 @@ 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')
@@ -31,6 +31,12 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const {
OFFICIAL_REPO_HTTPS_URL,
isOfficialSshRemote
} = require('./update-remote.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
@@ -61,6 +67,7 @@ const {
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret: encryptDesktopSecretStrict,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs
} = require('./hardening.cjs')
@@ -726,7 +733,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
}
@@ -1312,6 +1319,11 @@ function runGit(args, options = {}) {
const firstLine = text => (text || '').split('\n').find(Boolean) || ''
async function getOriginUrl(updateRoot) {
const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot })
return origin.code === 0 ? origin.stdout.trim() : ''
}
function emitUpdateProgress(payload) {
const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() }
rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`)
@@ -1331,7 +1343,9 @@ async function resolveHealedBranch(updateRoot, branch) {
return branch || 'main'
}
const probe = await runGit(['ls-remote', '--exit-code', '--heads', 'origin', branch], { cwd: updateRoot })
const originUrl = await getOriginUrl(updateRoot)
const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin'
const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot })
if (probe.code !== 2) {
return branch
}
@@ -1359,6 +1373,40 @@ async function checkUpdates() {
}
branch = await resolveHealedBranch(updateRoot, branch)
const originUrl = await getOriginUrl(updateRoot)
if (isOfficialSshRemote(originUrl)) {
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([
git(['rev-parse', 'HEAD']),
runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }),
git(['status', '--porcelain']),
git(['rev-parse', '--abbrev-ref', 'HEAD'])
])
const targetSha = firstLine(target.stdout).split(/\s+/)[0] || ''
if (target.code !== 0 || !targetSha) {
return {
supported: true,
branch,
error: 'fetch-failed',
message: firstLine(target.stderr) || 'git ls-remote failed.',
hermesRoot: updateRoot,
fetchedAt: Date.now()
}
}
return {
supported: true,
branch,
currentBranch,
behind: currentSha && currentSha === targetSha ? 0 : 1,
currentSha,
targetSha,
commits: [],
dirty: dirtyStr.length > 0,
hermesRoot: updateRoot,
fetchedAt: Date.now()
}
}
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
if (fetched.code !== 0) {
return {
@@ -2833,10 +2881,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)
@@ -2914,11 +2962,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')
@@ -2929,6 +2979,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)
@@ -2974,7 +3026,7 @@ function previewUrlTarget(rawTarget) {
}
}
function normalizePreviewTarget(rawTarget, baseDir) {
async function normalizePreviewTarget(rawTarget, baseDir) {
const raw = String(rawTarget || '').trim()
if (!raw) {
@@ -2986,20 +3038,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) {
@@ -3009,8 +3056,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')
@@ -5542,48 +5589,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
@@ -5766,46 +5771,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) {
+56
View File
@@ -0,0 +1,56 @@
/**
* Pure helpers for choosing a remote URL during passive update checks.
*
* A public install can end up with `origin=git@github.com:NousResearch/hermes-agent.git`.
* If the user's GitHub SSH key is FIDO2/passkey-backed, a background `git fetch
* origin` triggers an unexplained hardware-touch prompt. For passive checks
* against the official repo we substitute the public HTTPS `ls-remote` path,
* which needs no auth and cannot prompt. Active update/apply flows are left
* unchanged.
*
* Extracted from main.cjs so the security-critical remote detection is unit
* testable without booting Electron (main.cjs requires('electron') at load).
*/
const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git'
const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent'
// Normalize common GitHub remote URL forms to `host/owner/repo` (lowercased,
// no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo
// compare equal.
function canonicalGitHubRemote(url) {
if (!url) return ''
let value = String(url).trim()
if (value.startsWith('git@github.com:')) {
value = `github.com/${value.slice('git@github.com:'.length)}`
} else if (value.startsWith('ssh://git@github.com/')) {
value = `github.com/${value.slice('ssh://git@github.com/'.length)}`
} else {
try {
const parsed = new URL(value)
if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}`
} catch {
// Leave non-URL forms unchanged.
}
}
value = value.trim().replace(/\/+$/, '')
if (value.endsWith('.git')) value = value.slice(0, -4)
return value.toLowerCase()
}
function isSshRemote(url) {
const value = String(url || '').trim().toLowerCase()
return value.startsWith('git@') || value.startsWith('ssh://')
}
function isOfficialSshRemote(url) {
return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL
}
module.exports = {
OFFICIAL_REPO_HTTPS_URL,
OFFICIAL_REPO_CANONICAL,
canonicalGitHubRemote,
isSshRemote,
isOfficialSshRemote
}
@@ -0,0 +1,78 @@
/**
* Tests for electron/update-remote.cjs the remote-detection helpers that
* keep passive update checks off the SSH origin for official installs.
*
* Run with: node --test electron/update-remote.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Why this matters: a public install can carry
* origin=git@github.com:NousResearch/hermes-agent.git. A background
* `git fetch origin` then authenticates over SSH and, with a FIDO2/passkey
* key, triggers an unexplained hardware-touch prompt. isOfficialSshRemote
* must reliably recognize the official SSH remote (in every URL form,
* case-insensitively) so the caller can swap in the anonymous HTTPS path
* while NOT misclassifying forks, other hosts, or the HTTPS remote (which
* never prompts and should keep the normal fetch path).
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
OFFICIAL_REPO_HTTPS_URL,
OFFICIAL_REPO_CANONICAL,
canonicalGitHubRemote,
isSshRemote,
isOfficialSshRemote
} = require('./update-remote.cjs')
test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => {
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent'), OFFICIAL_REPO_CANONICAL)
assert.equal(canonicalGitHubRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
// Case-insensitive: an uppercased owner still canonicalizes to the same repo.
assert.equal(canonicalGitHubRemote('git@github.com:nousresearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
// Trailing slashes are stripped.
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent/'), OFFICIAL_REPO_CANONICAL)
})
test('canonicalGitHubRemote is empty for falsy input', () => {
assert.equal(canonicalGitHubRemote(''), '')
assert.equal(canonicalGitHubRemote(null), '')
assert.equal(canonicalGitHubRemote(undefined), '')
})
test('isSshRemote detects scp-like and ssh:// forms only', () => {
assert.equal(isSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
assert.equal(isSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
assert.equal(isSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
assert.equal(isSshRemote(''), false)
assert.equal(isSshRemote(null), false)
})
test('isOfficialSshRemote is true only for the official repo over SSH', () => {
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent'), true)
assert.equal(isOfficialSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
// Case-insensitive owner/repo match.
assert.equal(isOfficialSshRemote('git@github.com:nousresearch/hermes-agent.git'), true)
})
test('isOfficialSshRemote does NOT match forks, other hosts, or HTTPS', () => {
// A fork over SSH belongs to the user — fetching it is their own remote,
// not the official upstream, so the SSH-avoidance swap must not apply.
assert.equal(isOfficialSshRemote('git@github.com:someuser/hermes-agent.git'), false)
// Same repo name on a different host is not the official repo.
assert.equal(isOfficialSshRemote('git@gitlab.com:NousResearch/hermes-agent.git'), false)
// HTTPS to the official repo never prompts for SSH/FIDO2, so it keeps the
// normal fetch path — must not be flagged as an official SSH remote.
assert.equal(isOfficialSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
assert.equal(isOfficialSshRemote(''), false)
assert.equal(isOfficialSshRemote(null), false)
})
test('OFFICIAL_REPO_HTTPS_URL canonicalizes to OFFICIAL_REPO_CANONICAL', () => {
// Invariant: the URL we substitute in must be the same repo we detect.
assert.equal(canonicalGitHubRemote(OFFICIAL_REPO_HTTPS_URL), OFFICIAL_REPO_CANONICAL)
})
-3
View File
@@ -3,7 +3,6 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'
import perfectionist from 'eslint-plugin-perfectionist'
import reactPlugin from 'eslint-plugin-react'
import reactCompiler from 'eslint-plugin-react-compiler'
import hooksPlugin from 'eslint-plugin-react-hooks'
import unusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals'
@@ -47,7 +46,6 @@ export default [
'custom-rules': customRules,
perfectionist,
react: reactPlugin,
'react-compiler': reactCompiler,
'react-hooks': hooksPlugin,
'unused-imports': unusedImports
},
@@ -98,7 +96,6 @@ export default [
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
'react-compiler/react-compiler': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/rules-of-hooks': 'error',
'unused-imports/no-unused-imports': 'error'
+4 -5
View File
@@ -35,8 +35,8 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs",
"type-check": "tsc -b",
"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/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
@@ -103,20 +103,19 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
"@types/node": "^24.12.2",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"electron": "^40.9.3",
"electron-builder": "^26.8.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^16.5.0",
@@ -3,32 +3,25 @@ import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
export const COMPLETION_DRAWER_CLASS = [
'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
'w-60 max-w-[calc(100vw-2rem)]',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-lg border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-md',
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
'w-80 max-w-[calc(100vw-2rem)]',
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-xl border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-lg',
'backdrop-blur-md'
].join(' ')
export const COMPLETION_DRAWER_BELOW_CLASS = [
'absolute left-0 top-[calc(100%+0.25rem)] z-50',
'w-60 max-w-[calc(100vw-2rem)]',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-lg border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-md',
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
'w-80 max-w-[calc(100vw-2rem)]',
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-xl border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-lg',
'backdrop-blur-md'
].join(' ')
export const COMPLETION_DRAWER_ROW_CLASS = [
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
'hover:bg-(--ui-bg-tertiary)',
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
].join(' ')
export function ComposerCompletionDrawer({
adapter,
ariaLabel,
@@ -5,6 +5,13 @@ export interface CompletionEntry {
text: string
display?: unknown
meta?: unknown
/** Optional section label (e.g. "Commands", "Skills"). The popover renders a
* header whenever this changes between consecutive items, so the fetcher must
* emit entries already grouped contiguously. */
group?: string
/** Optional completion-action id. When set, picking the item runs that action
* (e.g. opening an overlay) instead of inserting a chip + waiting for submit. */
action?: string
}
export interface CompletionPayload {
@@ -2,12 +2,17 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
desktopSkinSlashCompletions,
desktopSlashDescription,
type DesktopThemeCommandOption,
filterDesktopCommandsCatalog,
isDesktopSlashExtensionCommand,
isDesktopSlashSuggestion
} from '@/lib/desktop-slash-commands'
import { $sessions } from '@/store/session'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
@@ -16,7 +21,10 @@ interface SlashItemMetadata extends Record<string, string> {
command: string
display: string
meta: string
group: string
rawText: string
/** Completion-action id; empty for ordinary insert-a-chip completions. */
action: string
}
function textValue(value: unknown, fallback = ''): string {
@@ -38,12 +46,21 @@ function commandText(value: string): string {
return value.startsWith('/') ? value : `/${value}`
}
/** How many recent sessions to surface inline before the "Browse all…" entry. */
const SESSION_INLINE_LIMIT = 7
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
export function useSlashCompletions(options: {
gateway: HermesGateway | null
/** Desktop theme list `/skin` is owned client-side, so its arg completions
* come from here, not the backend (whose skin list is CLI/TUI-only). */
skinThemes?: DesktopThemeCommandOption[]
activeSkin?: string
}): {
adapter: Unstable_TriggerAdapter
loading: boolean
} {
const { gateway } = options
const { gateway, skinThemes, activeSkin } = options
const enabled = Boolean(gateway)
const fetcher = useCallback(
@@ -54,34 +71,136 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
const text = `/${query}`
// The desktop owns /skin entirely (client-side theme context). Surface its
// theme list inside this single popover instead of a bespoke one, and skip
// the backend skin completions (which describe CLI/TUI skins that don't
// apply here). Matches once we're past `/skin ` into the arg stage.
const skinArg = /^\/skin\s+(.*)$/is.exec(text)
if (skinArg && skinThemes) {
const items = desktopSkinSlashCompletions(skinThemes, activeSkin ?? '', skinArg[1] ?? '').map(entry => ({
text: entry.text,
display: entry.display,
meta: entry.meta,
group: 'Themes'
}))
return { items, query }
}
// /resume (and its aliases) completes recent sessions inline — the same
// client-side list the picker overlay shows — instead of the backend
// (whose /resume opens an interactive TUI picker we can't render here).
const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
if (sessionArg) {
const needle = (sessionArg[1] ?? '').trim().toLowerCase()
const matches = (
needle
? $sessions.get().filter(
session =>
sessionTitle(session).toLowerCase().includes(needle) ||
(session.preview ?? '').toLowerCase().includes(needle) ||
session.id.toLowerCase().includes(needle)
)
: $sessions.get()
).slice(0, SESSION_INLINE_LIMIT)
const items: CompletionEntry[] = matches.map(session => ({
text: `/resume ${session.id}`,
display: sessionTitle(session),
meta: (session.preview ?? '').trim(),
group: 'Sessions'
}))
// Trailing "more" affordance (Cursor-style): picking it opens the full
// session picker overlay directly. `text` stays a bare `/resume` so that
// submitting it (Enter) still opens the overlay if the action is skipped.
items.push({
text: '/resume',
display: 'Browse all sessions…',
meta: '',
group: 'Sessions',
action: 'session-picker'
})
return { items, query }
}
try {
if (!query) {
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
const items = (catalog.pairs ?? []).map(([command, meta]) => ({
text: command,
display: command,
meta
}))
// Prefer the categorized layout so the popover renders section headers
// (Session, Tools & Skills, ...). Fall back to the flat list when the
// backend didn't categorize.
const sections = catalog.categories?.length
? catalog.categories
: [{ name: '', pairs: catalog.pairs ?? [] }]
const items = sections.flatMap(section =>
section.pairs.map(([command, meta]) => ({
text: command,
display: command,
group: section.name || undefined,
meta
}))
)
return { items, query }
}
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
'complete.slash',
{ text }
)
const items = (result.items ?? [])
.filter(item => isDesktopSlashSuggestion(item.text))
// Arg-completion items (replace_from > 1) carry just the arg stub —
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
// with replace_from = 14. Rewrite those entries so the popover inserts
// the full `/personality alice` token instead of stranding `/alice`.
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
const isArgCompletion = replaceFrom > 1
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
const decorated = (result.items ?? [])
.map(item => {
if (!isArgCompletion) {
return item
}
const argText = typeof item.text === 'string' ? item.text : ''
return { ...item, text: `${prefix}${argText}` }
})
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
.map(item => ({
...item,
meta: desktopSlashDescription(item.text, textValue(item.meta))
// Arg suggestions (e.g. `/handoff <platform>`) live under one
// header; otherwise split skills out from built-in commands.
group: isArgCompletion ? 'Options' : isDesktopSlashExtensionCommand(item.text) ? 'Skills' : 'Commands',
// Arg items carry their own meta (the personality/toolset/platform
// blurb). Only command rows get the registry description — looking
// one up for `/personality none` would clobber it with the parent
// command's text.
meta: isArgCompletion ? textValue(item.meta) : desktopSlashDescription(item.text, textValue(item.meta))
}))
// Keep each group contiguous so headers render once: Commands before
// Skills (stable within a group, preserving backend relevance order).
const groupOrder = ['Commands', 'Skills', 'Options']
const items = isArgCompletion
? decorated
: [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
return { items, query }
} catch {
return { items: [], query }
}
},
[gateway]
[gateway, skinThemes, activeSkin]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
@@ -93,6 +212,8 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
command,
display,
meta,
group: textValue(entry.group),
action: textValue(entry.action),
// Provide rawText so hermesDirectiveFormatter.serialize uses the
// direct-insertion path instead of the legacy @type:id fallback.
// Without this, the item.id (which includes a "|index" suffix for
+197 -25
View File
@@ -13,17 +13,25 @@ import {
useState
} from 'react'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import {
$composerAttachments,
clearComposerAttachments,
clearSessionDraft,
type ComposerAttachment,
stashSessionDraft,
takeSessionDraft
} from '@/store/composer'
import {
browseBackward,
browseForward,
@@ -40,8 +48,9 @@ import {
shouldAutoDrainOnSettle,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $gatewayState, $messages } from '@/store/session'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { useTheme } from '@/themes'
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
@@ -74,9 +83,9 @@ import {
placeCaretEnd,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT
RICH_INPUT_SLOT,
slashChipElement
} from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -95,6 +104,30 @@ const COMPOSER_FADE_BACKGROUND =
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
/** Completion items can carry an `action` (set in use-slash-completions) that
* runs a side effect on pick instead of inserting a chip e.g. the session
* picker's "Browse all…" entry opens the overlay. Table-driven so new action
* items are a registry row, not a composer branch. */
const COMPLETION_ACTIONS: Record<string, () => void> = {
'session-picker': () => setSessionPickerOpen(true)
}
/** Map a picked `/` completion to its pill accent. Driven by the completion
* group set in use-slash-completions (Skills / Themes / Commands|Options). */
function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
const group = (item.metadata as { group?: unknown } | undefined)?.group
if (group === 'Skills') {
return 'skill'
}
if (group === 'Themes') {
return 'theme'
}
return 'command'
}
interface QueueEditState {
attachments: ComposerAttachment[]
draft: string
@@ -104,6 +137,10 @@ interface QueueEditState {
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
// Quiet period after the last keystroke before persisting the draft;
// unmount/pagehide flushes bypass it.
const DRAFT_PERSIST_DEBOUNCE_MS = 400
export function ChatBar({
busy,
cwd,
@@ -145,6 +182,9 @@ export function ChatBar({
const editorRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
activeQueueSessionKeyRef.current = activeQueueSessionKey
const drainingQueueRef = useRef(false)
const urlInputRef = useRef<HTMLInputElement | null>(null)
@@ -156,14 +196,17 @@ export function ChatBar({
const [dragActive, setDragActive] = useState(false)
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
const [focusRequestId, setFocusRequestId] = useState(0)
const queueEditRef = useRef(queueEdit)
queueEditRef.current = queueEdit
const dragDepthRef = useRef(0)
const composingRef = useRef(false) // true during IME composition (CJK input)
const lastSpokenIdRef = useRef<string | null>(null)
const narrow = useMediaQuery('(max-width: 30rem)')
const { availableThemes, themeName } = useTheme()
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
const slash = useSlashCompletions({ gateway: gateway ?? null })
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
const stacked = expanded || narrow || tight
const trimmedDraft = draft.trim()
@@ -171,10 +214,12 @@ export function ChatBar({
const canSubmit = busy || hasComposerPayload
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
// into a tool result) and never for a slash command (those execute inline).
const canSteer =
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
const showHelpHint = draft === '?'
const { t } = useI18n()
@@ -462,12 +507,6 @@ export function ChatBar({
})
}, [])
const selectSkinSlashCommand = (command: string) => {
draftRef.current = command
aui.composer().setText(command)
requestMainFocus()
}
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
@@ -620,16 +659,50 @@ export function ChatBar({
return
}
// Action items (e.g. "Browse all sessions…") run a side effect instead of
// inserting a chip: strip the typed trigger token, then fire the action.
const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
if (runAction) {
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
renderComposerContents(editor, prefix)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
aui.composer().setText(draftRef.current)
closeTrigger()
runAction()
requestMainFocus()
return
}
const serialized = hermesDirectiveFormatter.serialize(item)
const starter = serialized.endsWith(':')
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
// it — expand to its options step so the popover shows the inline list, just
// as typing `/personality ` by hand would. A serialized value with a space is
// already an arg pick (`/personality alice`), so it commits normally.
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
const expandsToArgs =
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
// No pill while expanding — the bare command stays plain text until an arg
// is picked, at which point a single pill is emitted for the full command.
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
const keepTriggerOpen = starter || expandsToArgs
const finish = () => {
draftRef.current = composerPlainText(editor)
aui.composer().setText(draftRef.current)
requestMainFocus()
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
const sel = window.getSelection()
@@ -639,7 +712,20 @@ export function ChatBar({
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
const current = composerPlainText(editor)
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
if (slashKind) {
// Two-step arg picks (e.g. `/handoff` pill already inserted, now picking
// the platform) land here because the caret sits past a contenteditable
// chip. Rebuild the prefix and re-emit a single pill for the full command.
renderComposerContents(editor, prefix)
editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' '))
placeCaretEnd(editor)
return finish()
}
renderComposerContents(editor, `${prefix}${text}`)
placeCaretEnd(editor)
return finish()
@@ -650,8 +736,13 @@ export function ChatBar({
replaceRange.setEnd(node, offset)
replaceRange.deleteContents()
if (directive) {
const chip = refChipElement(directive[1], directive[2])
const chip = slashKind
? slashChipElement(serialized, slashKind)
: directive
? refChipElement(directive[1], directive[2])
: null
if (chip) {
const space = document.createTextNode(' ')
const fragment = document.createDocumentFragment()
fragment.append(chip, space)
@@ -1022,6 +1113,69 @@ export function ChatBar({
}
}
const stashAt = (
scope: string | null,
text = draftRef.current,
attachments = $composerAttachments.get()
) => stashSessionDraft(scope, text, attachments)
// Per-thread draft swap — the composer's only session coupling. Lifecycle
// never clears composer state; this effect alone stashes on leave, restores
// on enter. Keyed writes are idempotent, so no skip-sentinel.
useEffect(() => {
const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
loadIntoComposer(text, attachments)
return () => {
const editing = queueEditRef.current
if (editing?.sessionKey === activeQueueSessionKey) {
stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
} else if (!isBrowsingHistory(sessionId)) {
stashAt(activeQueueSessionKey)
}
}
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
// Debounced stash into the active scope. Skipped while browsing history or
// editing a queued prompt — recalled text must not clobber the real draft.
useEffect(() => {
if (isBrowsingHistory(sessionId) || queueEdit) {
return
}
pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
const handle = window.setTimeout(() => {
pendingDraftPersistRef.current = null
stashAt(activeQueueSessionKey, draft)
}, DRAFT_PERSIST_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
}, [activeQueueSessionKey, draft, queueEdit, sessionId])
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
// inside the debounce window would drop trailing keystrokes without this.
useEffect(() => {
const flushPendingDraftPersist = () => {
const pending = pendingDraftPersistRef.current
if (!pending) {
return
}
pendingDraftPersistRef.current = null
stashAt(pending.scope, pending.text)
}
window.addEventListener('pagehide', flushPendingDraftPersist)
return () => {
window.removeEventListener('pagehide', flushPendingDraftPersist)
flushPendingDraftPersist()
}
}, [])
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
if (!activeQueueSessionKey || queueEdit) {
return
@@ -1224,20 +1378,38 @@ export function ChatBar({
}
}, [busy, drainNextQueued, queuedPrompts.length])
// Clean up queue edit when its target disappears (session swap or external delete).
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
useEffect(() => {
if (!queueEdit) {
return
}
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
return
if (queueEdit.sessionKey === activeQueueSessionKey) {
if (editingQueuedPrompt) {
return
}
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
}
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
setQueueEdit(null)
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
const submittedScope = activeQueueSessionKeyRef.current
const submittedAttachments = attachments ?? []
const restore = () => {
loadIntoComposer(text, submittedAttachments)
stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
}
void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
.catch(restore)
}
const submitDraft = () => {
// Source the text from the DOM editor, not React state. The AUI composer
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
@@ -1248,8 +1420,10 @@ export function ChatBar({
// input event; refresh it from the editor once more to also cover an
// in-flight keystroke that hasn't fired its input event yet.
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
aui.composer().setText(domText)
@@ -1270,10 +1444,9 @@ export function ChatBar({
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
const submitted = text
triggerHaptic('submit')
clearDraft()
void onSubmit(submitted)
dispatchSubmit(text)
} else if (payloadPresent) {
queueCurrentDraft()
} else {
@@ -1285,12 +1458,12 @@ export function ChatBar({
} else if (!payloadPresent && queuedPrompts.length > 0) {
void drainNextQueued()
} else if (payloadPresent) {
const submitted = text
const submittedAttachments = cloneAttachments(attachments)
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
clearComposerAttachments()
void onSubmit(submitted, { attachments })
dispatchSubmit(text, submittedAttachments)
}
focusInput()
@@ -1515,7 +1688,6 @@ export function ChatBar({
onPick={replaceTriggerWithChip}
/>
)}
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
{activeQueueSessionKey && queuedPrompts.length > 0 && (
// Out of flow so the queue never inflates the composer's measured
// height (that drives thread bottom padding → chat resizes on
@@ -10,7 +10,10 @@ import {
DIRECTIVE_CHIP_CLASS,
directiveIconElement,
directiveIconSvg,
formatRefValue
formatRefValue,
slashChipClass,
type SlashChipKind,
slashIconElement
} from '@/components/assistant-ui/directive-text'
export const RICH_INPUT_SLOT = 'composer-rich-input'
@@ -77,6 +80,24 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
return chip
}
/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
* it back to the exact text that gets submitted. */
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
const chip = document.createElement('span')
const text = document.createElement('span')
chip.contentEditable = 'false'
chip.dataset.refText = command
chip.dataset.slashKind = kind
chip.className = slashChipClass(kind)
text.className = 'truncate'
text.textContent = label || command
chip.append(slashIconElement(kind), text)
return chip
}
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
const lines = text.split('\n')
@@ -1,61 +0,0 @@
import { useI18n } from '@/i18n'
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
interface SkinSlashPopoverProps {
draft: string
onSelect: (command: string) => void
}
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
const { t } = useI18n()
const c = t.composer
const { availableThemes, themeName } = useTheme()
const match = draft.match(/^\/skin\s+(\S*)$/i)
if (!match) {
return null
}
const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
return (
<div
aria-label={c.themeSuggestions}
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-skin-completion-drawer"
data-state="open"
role="listbox"
>
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title={c.noMatchingThemes}>
{c.themeTryPre}
<span className="font-mono text-foreground/80">/skin list</span>
{c.themeTryPost}
</CompletionDrawerEmpty>
) : (
items.map(item => (
<button
className={COMPLETION_DRAWER_ROW_CLASS}
key={item.text}
onClick={() => {
triggerHaptic('selection')
onSelect(item.text)
}}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
>
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span>
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span>
</button>
))
)}
</div>
</div>
)
}
@@ -22,6 +22,33 @@ describe('detectTrigger', () => {
it('returns null for plain text', () => {
expect(detectTrigger('hello there')).toBeNull()
})
it('keeps the slash trigger live while typing args', () => {
expect(detectTrigger('/personality ')).toEqual({
kind: '/',
query: 'personality ',
tokenLength: 13
})
expect(detectTrigger('/personality alic')).toEqual({
kind: '/',
query: 'personality alic',
tokenLength: 17
})
expect(detectTrigger('/tools enable foo')).toEqual({
kind: '/',
query: 'tools enable foo',
tokenLength: 17
})
})
it('does not treat file-style paths as slash triggers', () => {
expect(detectTrigger('src/foo/bar')).toBeNull()
expect(detectTrigger('/path/to/file')).toBeNull()
})
it('still anchors at-mention triggers strictly at the token edge', () => {
expect(detectTrigger('@file:path with space')).toBeNull()
})
})
describe('extractClipboardImageBlobs', () => {
@@ -6,7 +6,13 @@ export interface TriggerState {
tokenLength: number
}
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
// single tokens. `/` triggers keep going so the popover stays live while the
// user types args (`/personality alic` → arg completer suggests `alice`).
// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
// paths like `src/foo/bar`.
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string {
@@ -97,11 +103,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
}
export function detectTrigger(textBefore: string): TriggerState | null {
const match = TRIGGER_RE.exec(textBefore)
const slash = SLASH_TRIGGER_RE.exec(textBefore)
if (!match) {
return null
if (slash) {
return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
}
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
const at = AT_TRIGGER_RE.exec(textBefore)
if (at) {
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
}
return null
}
@@ -34,9 +34,17 @@ describe('ComposerTriggerPopover i18n', () => {
})
it('renders localized loading copy for slash commands', () => {
const { container } = renderPopover('/', true)
renderPopover('/', true)
// While loading the popover shows only the spinner + loading copy — the
// `/help` empty-state hint is reserved for the resolved (not-loading) state.
expect(screen.getByText('查找中…')).toBeTruthy()
})
it('renders the slash empty-state hint when not loading', () => {
const { container } = renderPopover('/')
expect(screen.getByText('没有匹配项。')).toBeTruthy()
expect(container.textContent).toContain('/help')
})
})
@@ -1,5 +1,7 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { Fragment } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
@@ -7,7 +9,6 @@ import { cn } from '@/lib/utils'
import {
COMPLETION_DRAWER_BELOW_CLASS,
COMPLETION_DRAWER_CLASS,
COMPLETION_DRAWER_ROW_CLASS,
CompletionDrawerEmpty
} from './completion-drawer'
@@ -23,11 +24,7 @@ const AT_ICON_BY_TYPE: Record<string, string> = {
url: 'globe'
}
function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
if (kind === '/') {
return 'terminal'
}
function atIcon(item: Unstable_TriggerItem) {
const meta = item.metadata as { rawText?: string } | undefined
const raw = meta?.rawText || item.label
@@ -42,6 +39,18 @@ function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
}
interface RowMeta {
display?: string
group?: string
meta?: string
}
const ROW_BASE_CLASS = [
'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left',
'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)',
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
].join(' ')
interface ComposerTriggerPopoverProps {
activeIndex: number
items: readonly Unstable_TriggerItem[]
@@ -63,6 +72,9 @@ export function ComposerTriggerPopover({
}: ComposerTriggerPopoverProps) {
const { t } = useI18n()
const copy = t.composer
const isSlash = kind === '/'
let lastGroup: string | undefined
return (
<div
@@ -73,41 +85,94 @@ export function ComposerTriggerPopover({
role="listbox"
>
{items.length === 0 ? (
<CompletionDrawerEmpty title={loading ? copy.lookupLoading : copy.lookupNoMatches}>
{kind === '@' ? (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
</>
)}
</CompletionDrawerEmpty>
loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
<span>{copy.lookupLoading}</span>
</div>
) : (
<CompletionDrawerEmpty title={copy.lookupNoMatches}>
{kind === '@' ? (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
</>
)}
</CompletionDrawerEmpty>
)
) : (
items.map((item, index) => {
const meta = item.metadata as { display?: string; meta?: string } | undefined
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
const meta = item.metadata as RowMeta | undefined
const display = meta?.display ?? (isSlash ? `/${item.label}` : item.label)
const description = meta?.meta || item.description
const group = meta?.group?.trim()
const showHeader = isSlash && Boolean(group) && group !== lastGroup
const isFirstHeader = lastGroup === undefined
lastGroup = group || lastGroup
const active = index === activeIndex
return (
<button
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
data-highlighted={index === activeIndex ? '' : undefined}
key={item.id}
onClick={() => onPick(item)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
</span>
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">{display}</span>
{description && (
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
<Fragment key={item.id}>
{showHeader && (
<div
className={cn(
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)',
isFirstHeader ? 'pt-0.5' : 'pt-2'
)}
>
{group}
</div>
)}
</button>
<button
className={cn(ROW_BASE_CLASS, isSlash ? 'flex-col gap-0' : 'items-center gap-2')}
data-highlighted={active ? '' : undefined}
onClick={() => onPick(item)}
onMouseEnter={() => onHover(index)}
type="button"
>
{isSlash ? (
<>
{/* Active row (keyboard nav or hover) un-truncates inline so
long command names / descriptions stay readable without a
floating tooltip. */}
<span
className={cn(
'text-[0.8125rem] font-medium leading-snug text-foreground',
active ? 'whitespace-normal break-words' : 'truncate'
)}
>
{display}
</span>
{description && (
<span
className={cn(
'text-[0.6875rem] leading-snug text-(--ui-text-tertiary)',
active ? 'whitespace-normal break-words' : 'truncate'
)}
>
{description}
</span>
)}
</>
) : (
<>
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
<Codicon name={atIcon(item)} size="0.875rem" />
</span>
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
{display}
</span>
{description && (
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
)}
</>
)}
</button>
</Fragment>
)
})
)}
+2 -2
View File
@@ -38,7 +38,6 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { normalizeCombo } from '@/lib/keybinds/combo'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
@@ -112,7 +111,8 @@ const NON_SESSION_LOAD_STEP = 10
// Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory.
const NEW_SESSION_KBD: readonly string[] =normalizeCombo('mod+n')
const NEW_SESSION_KBD: readonly string[] =
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
const SIDEBAR_NAV: SidebarNavItem[] = [
{
@@ -10,7 +10,6 @@ import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { modKey } from '@/lib/keybinds/combo'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
@@ -134,11 +133,11 @@ export function SidebarSessionRow({
return
}
// ⌘-click (mac) / Ctrl-click (win/linux) pops the chat into its own
// ⌘-click (mac) / -click (win/linux) pops the chat into its own
// window — the universal "open in a new window" gesture. Archive
// lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed).
if (event[modKey] && canOpenSessionWindow()) {
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
+11 -2
View File
@@ -91,7 +91,6 @@ import { CommandPalette } from './command-palette'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { useKeybinds } from './hooks/use-keybinds'
import { modKey } from '@/lib/keybinds/combo'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
@@ -99,6 +98,7 @@ import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
import { SessionPickerOverlay } from './session-picker-overlay'
import { SessionSwitcher } from './session-switcher'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
@@ -272,7 +272,7 @@ export function DesktopController() {
return
}
if (event[modKey] && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
event.preventDefault()
event.stopPropagation()
closeActiveRightRailTab()
@@ -695,6 +695,7 @@ export function DesktopController() {
handleSkinCommand,
refreshSessions,
requestGateway,
resumeStoredSession: resumeSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
@@ -744,6 +745,13 @@ export function DesktopController() {
}
}, [gatewayState, refreshCronJobs])
useEffect(() => {
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
void refreshCurrentModel()
void refreshHermesConfig()
}
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
useRouteResume({
activeSessionId,
activeSessionIdRef,
@@ -823,6 +831,7 @@ export function DesktopController() {
/>
)}
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<SessionPickerOverlay onResume={resumeSession} />
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
<UpdatesOverlay />
<GatewayConnectingOverlay />
@@ -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()
})
})
@@ -27,7 +27,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`. */
@@ -145,7 +145,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 +211,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" />
) : (
@@ -106,7 +106,7 @@ describe('useProjectTree', () => {
expect(readDir).toHaveBeenCalledTimes(1)
})
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
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 +119,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 () => {
@@ -14,11 +14,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 +46,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 {
@@ -227,7 +239,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))
}))
}
})
@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
variant="secondary"
>
{t.rightSidebar.addToChat}
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel}</span>
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
</Button>
</div>
)}
@@ -1,7 +1,6 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
import { formatCombo, modKey } from '@/lib/keybinds/combo'
import type { DesktopTerminalPalette } from '@/themes/types'
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
@@ -98,10 +97,12 @@ export function resolveSurfaceColor(fallback: string): string {
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
}
export const addSelectionShortcutLabel = formatCombo('mod+l')
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
export function isAddSelectionShortcut(event: KeyboardEvent) {
const mod = event[modKey]
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
}
@@ -0,0 +1,32 @@
import { useStore } from '@nanostores/react'
import { SessionPickerDialog } from '@/components/session-picker'
import { $gatewayState, $selectedStoredSessionId, $sessionPickerOpen, setSessionPickerOpen } from '@/store/session'
interface SessionPickerOverlayProps {
onResume: (storedSessionId: string) => void
}
/**
* Mounts the session picker that `/resume` (and `/sessions`, `/switch`) opens
* the desktop equivalent of the TUI's sessions overlay. Resuming runs through
* the same `resumeSession` path the sidebar uses.
*/
export function SessionPickerOverlay({ onResume }: SessionPickerOverlayProps) {
const open = useStore($sessionPickerOpen)
const gatewayOpen = useStore($gatewayState) === 'open'
const activeStoredSessionId = useStore($selectedStoredSessionId)
if (!gatewayOpen) {
return null
}
return (
<SessionPickerDialog
activeStoredSessionId={activeStoredSessionId}
onOpenChange={setSessionPickerOpen}
onResume={onResume}
open={open}
/>
)
}
@@ -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,13 +689,13 @@ 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: { branch?: string; cwd?: string } = {}
if (modelChanged) {
setCurrentModel(payload!.model || '')
}
@@ -645,20 +706,10 @@ export function useMessageStream({
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 (sessionId && (runtimeInfo.cwd !== undefined || runtimeInfo.branch !== undefined)) {
updateSessionState(sessionId, state => ({
...state,
branch: runtimeInfo.branch ?? state.branch,
cwd: runtimeInfo.cwd ?? state.cwd
}))
}
if (typeof payload?.personality === 'string') {
@@ -680,7 +731,18 @@ export function useMessageStream({
if (typeof payload?.yolo === 'boolean') {
setYoloActive(payload.yolo)
}
}
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)
@@ -0,0 +1,77 @@
import { renderHook } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelInfo } from '@/hermes'
import {
$activeSessionId,
$currentModel,
$currentProvider,
setCurrentModel,
setCurrentProvider
} from '@/store/session'
import { useModelControls } from './use-model-controls'
vi.mock('@/hermes', () => ({
getGlobalModelInfo: vi.fn(),
setGlobalModel: vi.fn()
}))
describe('useModelControls.refreshCurrentModel', () => {
beforeEach(() => {
$activeSessionId.set(null)
setCurrentModel('')
setCurrentProvider('')
})
afterEach(() => {
vi.restoreAllMocks()
$activeSessionId.set(null)
setCurrentModel('')
setCurrentProvider('')
})
it('applies the global model when there is no active runtime session', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({
model: 'openai/gpt-5.5',
provider: 'openai-codex'
})
const { result } = renderHook(() =>
useModelControls({
activeSessionId: null,
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openai/gpt-5.5')
expect($currentProvider.get()).toBe('openai-codex')
})
it('does not clobber the active session footer state with global model info', async () => {
setCurrentModel('deepseek/deepseek-v4-pro')
setCurrentProvider('deepseek')
$activeSessionId.set('runtime-1')
vi.mocked(getGlobalModelInfo).mockResolvedValue({
model: 'openai/gpt-5.5',
provider: 'openai-codex'
})
const { result } = renderHook(() =>
useModelControls({
activeSessionId: 'runtime-1',
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
expect($currentProvider.get()).toBe('deepseek')
})
})
@@ -4,7 +4,13 @@ import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
import {
$activeSessionId,
$currentModel,
$currentProvider,
setCurrentModel,
setCurrentProvider
} from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection {
@@ -39,6 +45,13 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
try {
const result = await getGlobalModelInfo()
// A resumed/live session owns the footer model state. Global config
// refreshes (gateway boot, profile swap, settings save) must not clobber
// the active chat's runtime model/provider in the status bar.
if ($activeSessionId.get()) {
return
}
if (typeof result.model === 'string') {
setCurrentModel(result.model)
}
@@ -1,6 +1,6 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
@@ -42,6 +42,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
}
interface HarnessHandle {
cancelRun: () => Promise<void>
steerPrompt: (text: string) => Promise<boolean>
submitText: (
text: string,
@@ -55,6 +56,7 @@ function Harness({
onSeedState,
refreshSessions,
requestGateway,
resumeStoredSession,
storedSessionId
}: {
busyRef?: MutableRefObject<boolean>
@@ -62,6 +64,7 @@ function Harness({
onSeedState?: (state: Record<string, unknown>) => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
storedSessionId?: null | string
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
@@ -69,6 +72,12 @@ function Harness({
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
}
const localBusyRef = busyRef ?? { current: false }
const stateRef = useRef({
messages: [],
busy: false,
awaitingResponse: false,
interrupted: true
} as never)
const actions = usePromptActions({
activeSessionId: RUNTIME_SESSION_ID,
@@ -79,17 +88,14 @@ function Harness({
handleSkinCommand: () => '',
refreshSessions,
requestGateway,
resumeStoredSession: resumeStoredSession ?? (() => undefined),
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
sttEnabled: false,
updateSessionState: (_sessionId, updater) => {
// Seed with interrupted:true so we can prove a fresh submit clears it.
const next = updater({
messages: [],
busy: false,
awaitingResponse: false,
interrupted: true
} as never) as unknown as Record<string, unknown>
const next = updater(stateRef.current) as unknown as Record<string, unknown>
stateRef.current = next as never
onSeedState?.(next)
return next as never
@@ -97,8 +103,12 @@ function Harness({
})
useEffect(() => {
onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText })
}, [actions.steerPrompt, actions.submitText, onReady])
onReady({
cancelRun: actions.cancelRun,
steerPrompt: actions.steerPrompt,
submitText: actions.submitText
})
}, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
return null
}
@@ -190,6 +200,68 @@ describe('usePromptActions /title', () => {
})
})
describe('usePromptActions desktop slash pickers', () => {
beforeEach(() => {
setSessions(() => [sessionInfo({ id: '20260610_120000_abcdef', title: 'Loaded session' })])
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('resumes an exact session id even when it is not in the loaded sidebar cache', async () => {
const resumeStoredSession = vi.fn(async () => undefined)
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
resumeStoredSession={resumeStoredSession}
/>
)
await handle!.submitText('/resume 20260610_130000_123abc')
expect(resumeStoredSession).toHaveBeenCalledWith('20260610_130000_123abc')
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('marks a timed-out handoff as failed so the next attempt can retry', async () => {
vi.useFakeTimers()
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'handoff.state') {
return { state: 'pending' } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const result = handle!.submitText('/handoff telegram')
await vi.advanceTimersByTimeAsync(61_000)
await result
expect(calls.some(call => call.method === 'handoff.request')).toBe(true)
expect(calls).toContainEqual({
method: 'handoff.fail',
params: {
error: expect.stringContaining('Timed out'),
session_id: RUNTIME_SESSION_ID
}
})
})
})
describe('usePromptActions submit / queue drain semantics', () => {
afterEach(() => {
cleanup()
@@ -562,6 +634,43 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})
it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let interruptAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'session.interrupt') {
interruptAttempts += 1
if (interruptAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
await waitFor(() => expect(handle).not.toBeNull())
await handle!.cancelRun()
expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
})
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
const calls: string[] = []
const states: Record<string, unknown>[] = []
@@ -751,4 +860,3 @@ describe('uploadComposerAttachment remote read failures', () => {
).rejects.toThrow('ENOENT: no such file')
})
})
@@ -4,20 +4,24 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
import { translateNow, type Translations, useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
optimisticAttachmentRef,
parseCommandDispatch,
parseSlashCommand,
pathLabel,
sessionTitle,
SLASH_COMMAND_RE
} from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
type DesktopActionId,
type DesktopPickerId,
desktopSlashUnavailableMessage,
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
isModelPickerCommand
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
@@ -38,11 +42,13 @@ import {
$busy,
$connection,
$messages,
$sessions,
$yoloActive,
setAwaitingResponse,
setBusy,
setMessages,
setModelPickerOpen,
setSessionPickerOpen,
setSessions,
setYoloActive
} from '@/store/session'
@@ -50,12 +56,30 @@ import {
import type {
ClientSessionState,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
HandoffStateResponse,
ImageAttachResponse,
SessionSteerResponse,
SessionTitleResponse,
SlashExecResponse
} from '../../types'
interface HandoffResult {
ok: boolean
error?: string
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function isSessionIdCandidate(value: string): boolean {
const trimmed = value.trim()
return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed)
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
@@ -84,6 +108,12 @@ function inlineErrorMessage(error: unknown, fallback: string): string {
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
}
function isSessionNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /session not found/i.test(message)
}
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
@@ -245,6 +275,7 @@ interface PromptActionsOptions {
handleSkinCommand: (arg: string) => string
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
selectedStoredSessionIdRef: MutableRefObject<string | null>
startFreshSessionDraft: () => void
sttEnabled: boolean
@@ -260,6 +291,15 @@ interface SubmitTextOptions {
fromQueue?: boolean
}
/** Everything a slash handler needs about the invocation it's serving. */
interface SlashActionCtx {
arg: string
command: string
name: string
recordInput: boolean
sessionHint?: string
}
function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
@@ -310,6 +350,7 @@ export function usePromptActions({
handleSkinCommand,
refreshSessions,
requestGateway,
resumeStoredSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
@@ -320,7 +361,11 @@ export function usePromptActions({
const appendSessionTextMessage = useCallback(
(sessionId: string, role: ChatMessage['role'], text: string) => {
const body = text.trim()
// Strip ANSI: slash-command output from the backend worker carries SGR
// color codes (e.g. "Unknown command" in red). The ESC byte is invisible
// in the chat panel, so without this the `[1;31m…[0m` payload leaks as
// literal text.
const body = stripAnsi(text).trim()
if (!body) {
return
@@ -622,9 +667,7 @@ export function usePromptActions({
try {
await requestGateway('prompt.submit', { session_id: sessionId, text })
} catch (firstErr) {
const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr)
if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) {
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
// Re-register the session in the gateway and get a fresh live ID.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
@@ -696,230 +739,124 @@ export function usePromptActions({
]
)
// Queue a handoff of this session to a messaging platform and watch it to
// a terminal state. We only write the request through the gateway; the
// separate `hermes gateway` process performs the actual transfer, so we
// poll `handoff.state` (mirror of the CLI's block-poll) for the result.
const handoffSession = useCallback(
async (
platform: string,
options?: { onProgress?: (state: string) => void; sessionId?: string }
): Promise<HandoffResult> => {
const sid = options?.sessionId || activeSessionIdRef.current
if (!sid) {
return { error: copy.sessionUnavailable, ok: false }
}
const target = platform.trim().toLowerCase()
if (!target) {
return { error: copy.handoff.failed(''), ok: false }
}
try {
options?.onProgress?.('pending')
await requestGateway<HandoffRequestResponse>('handoff.request', {
platform: target,
session_id: sid
})
} catch (err) {
return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false }
}
const deadline = Date.now() + 60_000
let lastState = 'pending'
while (Date.now() < deadline) {
await delay(800)
let record: HandoffStateResponse
try {
record = await requestGateway<HandoffStateResponse>('handoff.state', { session_id: sid })
} catch {
continue
}
const state = record.state || 'pending'
if (state !== lastState) {
options?.onProgress?.(state)
lastState = state
}
if (state === 'completed') {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
}
if (state === 'failed') {
return { error: record.error || copy.handoff.failed(target), ok: false }
}
}
const cleanup = await requestGateway<HandoffFailResponse>('handoff.fail', {
error: copy.handoff.timedOut,
session_id: sid
}).catch(() => null)
if (cleanup?.state === 'completed') {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
}
return { error: copy.handoff.timedOut, ok: false }
},
[activeSessionIdRef, appendSessionTextMessage, copy, requestGateway]
)
const executeSlashCommand = useCallback(
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise<void> => {
const command = commandText.trim()
const { name, arg } = parseSlashCommand(command)
const normalizedName = name.toLowerCase()
const ensureSessionId = async (sessionHint?: string) =>
sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (!name) {
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (sessionId) {
appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
}
return
}
if (normalizedName === 'new' || normalizedName === 'reset') {
startFreshSessionDraft()
return
}
if (normalizedName === 'branch' || normalizedName === 'fork') {
await branchCurrentSession()
return
}
// /yolo maps to the status-bar YOLO control — a per-session approval
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
// it locally; the session-create path applies it on the first message.
if (normalizedName === 'yolo') {
const sid = sessionHint || activeSessionIdRef.current
const next = !$yoloActive.get()
if (!sid) {
setYoloActive(next)
notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff })
return
}
try {
const active = await setSessionYolo(requestGateway, sid, next)
appendSessionTextMessage(sid, 'system', copy.yoloSystem(active))
} catch {
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
}
return
}
// /model opens the desktop model picker overlay — the same full
// provider+model picker reachable from the status-bar model button —
// instead of the headless prompt_toolkit modal the slash worker can't
// render. With explicit args (`/model <name> [--provider ...]`) run the
// switch directly through slash.exec so power users can still type it.
if (isModelPickerCommand(`/${normalizedName}`)) {
if (!arg.trim()) {
setModelPickerOpen(true)
return
}
const sid = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (!sid) {
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
return
}
try {
const result = await requestGateway<SlashExecResponse>('slash.exec', {
session_id: sid,
command: command.replace(/^\/+/, '')
})
const body = result?.output || `/${name}: model switched`
appendSessionTextMessage(
sid,
'system',
recordInput ? slashStatusText(command, body) : body
)
} catch (err) {
appendSessionTextMessage(
sid,
'system',
`error: ${err instanceof Error ? err.message : String(err)}`
)
}
return
}
if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
notify({ kind: 'success', message: handleSkinCommand(arg) })
return
}
// /profile selects which profile new chats open in — no app relaunch.
// A profile is per-session now, so an existing thread can't change its
// profile mid-stream; `/profile <name>` instead points the next new chat
// (and the current empty draft) at that profile's backend.
if (normalizedName === 'profile') {
const target = arg.trim()
const current = normalizeProfileKey($activeGatewayProfile.get())
if (!target) {
notify({
kind: 'success',
message: copy.profileStatus(current)
})
return
}
try {
const { profiles } = await getProfiles()
const match = profiles.find(profile => profile.name === target)
if (!match) {
notify({
kind: 'error',
title: copy.unknownProfile,
message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', '))
})
return
}
const key = normalizeProfileKey(match.name)
$newChatProfile.set(key)
// Swap the live gateway now so an empty draft sends into this
// profile immediately; an existing thread keeps its own profile.
await ensureGatewayProfile(key)
notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
} catch (err) {
notifyError(err, copy.setProfileFailed)
}
return
}
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
// Resolve the target session plus a writer for inline slash output, or
// notify + return null when none can be created. Folds the ensure / bail /
// build-renderSlashOutput boilerplate every exec-style handler repeats.
const withSlashOutput = async (
ctx: SlashActionCtx
): Promise<{ render: (text: string) => void; sessionId: string } | null> => {
const sessionId = await ensureSessionId(ctx.sessionHint)
if (!sessionId) {
notify({
kind: 'error',
title: copy.sessionUnavailable,
message: copy.createSessionFailed
})
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return null
}
const render = (text: string) =>
appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text)
return { render, sessionId }
}
// `exec` commands (and unknown skill / quick commands the backend owns)
// run on the gateway and render their text output inline. This is the only
// path that talks to slash.exec / command.dispatch.
async function runExec(ctx: SlashActionCtx): Promise<void> {
const { arg, command, name } = ctx
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const renderSlashOutput = (text: string) =>
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
// /title <name> renames the session. Route through the gateway's
// `session.title` RPC — the same path the TUI uses — NOT the REST
// renameSession endpoint and NOT the slash worker.
//
// Why not the slash worker: it's a separate HermesCLI subprocess whose
// SQLite write to the shared state.db can silently fail (notably on
// Windows), and it never refreshes the sidebar.
//
// Why not REST renameSession: `sessionId` here is the *runtime* session
// id returned by session.create — it is NOT the stored DB `sessions.id`,
// and session.create deliberately does not persist a DB row until the
// first turn. The REST PATCH endpoint resolves against the sessions
// table, so a runtime id (or a brand-new, not-yet-persisted session)
// 404s with "Session not found" on every platform. See #38508 / #38576.
//
// session.title maps the runtime id to the in-memory session, writes
// through the gateway's own DB connection, and QUEUES the title
// (`pending: true`) when the row isn't persisted yet — so it works for a
// fresh chat too. refreshSessions() then pulls the authoritative title
// back into the sidebar. A bare `/title` (no arg) still falls through to
// the worker to display the current title.
if (normalizedName === 'title' && arg) {
try {
const result = await requestGateway<SessionTitleResponse>('session.title', {
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
await refreshSessions().catch(() => undefined)
renderSlashOutput(
finalTitle
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
: 'Session title cleared.'
)
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
return
}
if (normalizedName === 'skin') {
renderSlashOutput(handleSkinCommand(arg))
return
}
if (name === 'help' || name === 'commands') {
try {
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
renderSlashOutput(renderCommandsCatalog(catalog, copy))
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
return
}
const { render: renderSlashOutput, sessionId } = resolved
if (!isDesktopSlashCommand(name)) {
renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
@@ -943,11 +880,7 @@ export function usePromptActions({
try {
const dispatch = parseCommandDispatch(
await requestGateway<unknown>('command.dispatch', {
session_id: sessionId,
name,
arg
})
await requestGateway<unknown>('command.dispatch', { session_id: sessionId, name, arg })
)
if (!dispatch) {
@@ -994,6 +927,261 @@ export function usePromptActions({
}
}
// One handler per `action` command. Adding a desktop-native command is a
// registry row in desktop-slash-commands.ts plus an entry here — never a
// new branch in a dispatch ladder.
const actionHandlers: Record<DesktopActionId, (ctx: SlashActionCtx) => Promise<void>> = {
new: async () => {
startFreshSessionDraft()
},
branch: async () => {
await branchCurrentSession()
},
// /yolo maps to the status-bar YOLO control — a per-session approval
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
// it locally; the session-create path applies it on the first message.
yolo: async ({ sessionHint }) => {
const sid = sessionHint || activeSessionIdRef.current
const next = !$yoloActive.get()
if (!sid) {
setYoloActive(next)
notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff })
return
}
try {
const active = await setSessionYolo(requestGateway, sid, next)
appendSessionTextMessage(sid, 'system', copy.yoloSystem(active))
} catch {
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
}
},
// /handoff hands this session to a messaging platform. The platform is
// completed inline in the slash popover (backend _handoff_completions),
// so there is no overlay: `/handoff <platform>` runs the desktop's own
// handoff RPC. cli_only on the backend, so it must not reach slash.exec.
handoff: async ({ arg, command, recordInput, sessionHint }) => {
const platform = arg.trim()
if (!platform) {
notify({ kind: 'success', message: copy.handoff.pickPlatform })
return
}
const sid = sessionHint || activeSessionIdRef.current
if (!sid) {
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return
}
const result = await handoffSession(platform, { sessionId: sid })
if (!result.ok && result.error) {
appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, result.error) : result.error)
}
},
// /profile selects which profile new chats open in — no app relaunch.
// A profile is per-session now, so an existing thread can't change its
// profile mid-stream; `/profile <name>` points the next new chat (and
// the current empty draft) at that profile's backend.
profile: async ({ arg }) => {
const target = arg.trim()
const current = normalizeProfileKey($activeGatewayProfile.get())
if (!target) {
notify({ kind: 'success', message: copy.profileStatus(current) })
return
}
try {
const { profiles } = await getProfiles()
const match = profiles.find(profile => profile.name === target)
if (!match) {
notify({
kind: 'error',
title: copy.unknownProfile,
message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', '))
})
return
}
const key = normalizeProfileKey(match.name)
$newChatProfile.set(key)
await ensureGatewayProfile(key)
notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
} catch (err) {
notifyError(err, copy.setProfileFailed)
}
},
skin: async ({ arg, command, recordInput, sessionHint }) => {
const sid = sessionHint || activeSessionIdRef.current
const message = handleSkinCommand(arg)
// No session to print into yet — surface it as a toast instead of
// spinning up a backend session just to change the theme.
if (!sid) {
notify({ kind: 'success', message })
return
}
appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, message) : message)
},
// /title <name> renames via the gateway's session.title RPC — the same
// path the TUI uses, NOT REST renameSession (which 404s on runtime ids)
// nor the slash worker (whose DB write can silently fail). Bare /title
// shows the current title, which the worker owns, so delegate to exec.
title: async ctx => {
if (!ctx.arg) {
await runExec(ctx)
return
}
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
const { arg } = ctx
try {
const result = await requestGateway<SessionTitleResponse>('session.title', {
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
await refreshSessions().catch(() => undefined)
renderSlashOutput(
finalTitle
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
: 'Session title cleared.'
)
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
help: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
try {
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
renderSlashOutput(renderCommandsCatalog(catalog, copy))
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
// Picker commands open a desktop overlay; a typed arg is resolved by that
// picker so the command never dead-ends or falls through to the backend.
const openPicker = async (pickerId: DesktopPickerId, ctx: SlashActionCtx): Promise<void> => {
if (pickerId === 'model') {
if (!ctx.arg.trim()) {
setModelPickerOpen(true)
return
}
// Power users can still type `/model <name>` — run it on the backend.
await runExec(ctx)
return
}
// session picker — /resume, /sessions, /switch
const query = ctx.arg.trim()
if (!query) {
setSessionPickerOpen(true)
return
}
const sessions = $sessions.get()
const lower = query.toLowerCase()
const match =
sessions.find(session => session.id === query) ||
sessions.find(session => sessionTitle(session).toLowerCase().includes(lower)) ||
sessions.find(session => (session.preview ?? '').toLowerCase().includes(lower))
if (!match) {
if (isSessionIdCandidate(query)) {
await resumeStoredSession(query)
return
}
notify({ kind: 'error', message: copy.resumeFailed })
return
}
await resumeStoredSession(match.id)
}
// The whole dispatcher: resolve the command's desktop surface, then act on
// its kind. No per-command ladder — behavior lives in the registry.
async function runSlash(commandText: string, sessionHint?: string, recordInput = true): Promise<void> {
const command = commandText.trim()
const { name, arg } = parseSlashCommand(command)
if (!name) {
const sessionId = await ensureSessionId(sessionHint)
if (sessionId) {
appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
}
return
}
const ctx: SlashActionCtx = { arg, command, name, recordInput, sessionHint }
const surface = resolveDesktopCommand(`/${name}`)?.surface
switch (surface?.kind) {
case 'unavailable': {
const resolved = await withSlashOutput(ctx)
resolved?.render(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
return
}
case 'picker':
return openPicker(surface.picker, ctx)
case 'action':
return actionHandlers[surface.action](ctx)
default:
// exec spec, or an unknown skill / quick command the backend owns.
return runExec(ctx)
}
}
await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true)
},
[
@@ -1004,8 +1192,10 @@ export function usePromptActions({
copy,
createBackendSessionForSend,
handleSkinCommand,
handoffSession,
refreshSessions,
requestGateway,
resumeStoredSession,
startFreshSessionDraft,
submitPromptText
]
@@ -1087,11 +1277,39 @@ export function usePromptActions({
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch (err) {
let stopError = err
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
try {
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await requestGateway('session.interrupt', { session_id: recoveredId })
return
}
} catch (resumeErr) {
stopError = resumeErr
}
}
setMutableRef(busyRef, false)
setBusy(false)
notifyError(err, copy.stopFailed)
notifyError(stopError, copy.stopFailed)
}
}, [activeSessionId, activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, updateSessionState])
}, [
activeSessionId,
activeSessionIdRef,
busyRef,
copy.stopFailed,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
])
// Steer = nudge the live turn without interrupting: the gateway appends the
// text to the next tool result so the model reads it on its next iteration
@@ -1314,6 +1532,7 @@ export function usePromptActions({
cancelRun,
editMessage,
handleThreadMessagesChange,
handoffSession,
reloadFromMessage,
steerPrompt,
submitText,
@@ -8,7 +8,6 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { setSessionYolo } from '@/lib/yolo-session'
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
@@ -19,8 +18,6 @@ import {
$messages,
$sessions,
$yoloActive,
getRememberedWorkspaceCwd,
workspaceCwdForNewSession,
sessionPinId,
setActiveSessionId,
setAwaitingResponse,
@@ -42,10 +39,11 @@ import {
setSessionStartedAt,
setSessionsTotal,
setTurnStartedAt,
setYoloActive
setYoloActive,
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'
@@ -211,14 +209,27 @@ 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'>> | null {
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'>> = {}
const sessionState: SessionRuntimeStatePatch = {}
reportBackendContract(info.desktop_contract)
@@ -226,12 +237,14 @@ function applyRuntimeInfo(
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
}
if (info.cwd) {
@@ -245,23 +258,29 @@ function applyRuntimeInfo(
}
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') {
setCurrentReasoningEffort(info.reasoning_effort)
sessionState.reasoningEffort = info.reasoning_effort
}
if (typeof info.service_tier === 'string') {
setCurrentServiceTier(info.service_tier)
sessionState.serviceTier = info.service_tier
}
if (typeof info.fast === 'boolean') {
setCurrentFastMode(info.fast)
sessionState.fast = info.fast
}
if (typeof info.yolo === 'boolean') {
setYoloActive(info.yolo)
sessionState.yolo = info.yolo
}
if (info.usage) {
@@ -271,6 +290,16 @@ function applyRuntimeInfo(
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,
@@ -314,10 +343,15 @@ export function useSessionActions({
setTurnStartedAt(null)
// New chats start in the configured default project dir when set,
// otherwise the sticky last-used workspace (PR #37586).
setCurrentModel('')
setCurrentProvider('')
setCurrentReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
setYoloActive(false)
setCurrentCwd(workspaceCwdForNewSession())
setCurrentBranch('')
clearComposerDraft()
clearComposerAttachments()
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
setFreshDraftReady(true)
},
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
@@ -339,11 +373,13 @@ export function useSessionActions({
// Pass the owning profile so a new chat under a non-launch profile (global
// remote mode) builds its agent + persists against THAT profile's home/db.
const newChatProfile = $newChatProfile.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {})
})
const stored = created.stored_session_id ?? null
if (
@@ -452,18 +488,29 @@ export function useSessionActions({
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())
clearComposerDraft()
clearComposerAttachments()
try {
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
@@ -503,6 +550,7 @@ export function useSessionActions({
selectedStoredSessionIdRef.current = storedSessionId
setSessionStartedAt(Date.now())
const stored = $sessions.get().find(session => session.id === storedSessionId)
applyStoredSessionPreviewRuntimeInfo(stored)
if (stored) {
setCurrentUsage(current => ({
@@ -593,8 +641,6 @@ export function useSessionActions({
}),
storedSessionId
)
clearComposerDraft()
clearComposerAttachments()
} catch (err) {
if (!isCurrentResume()) {
return
@@ -717,8 +763,6 @@ export function useSessionActions({
selectedStoredSessionIdRef.current = routedSessionId
navigate(sessionRoute(routedSessionId))
clearComposerDraft()
clearComposerAttachments()
const runtimeInfo = applyRuntimeInfo(branched.info)
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
@@ -859,6 +903,12 @@ export function useSessionActions({
try {
await setSessionArchived(storedSessionId, true, archived?.profile)
// A sidebar refresh can race the optimistic removal while the PATCH is
// 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))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
@@ -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)
})
})
@@ -5,7 +5,21 @@ import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'
import {
$busy,
$messages,
noteSessionActivity,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setSessionAttention,
setSessionWorking,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -40,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,
@@ -124,6 +148,7 @@ export function useSessionStateCache({
setMessages(nextMessages)
}
syncRuntimeMetadataToView(pending.state)
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)
@@ -148,6 +173,7 @@ export function useSessionStateCache({
return
}
syncRuntimeMetadataToView(state)
pendingViewStateRef.current = { sessionId, state }
// Terminal / attention transitions (turn finished, error, or the agent is
+2 -2
View File
@@ -14,7 +14,7 @@ import {
type KeybindActionMeta,
type KeybindReadonly
} from '@/lib/keybinds/actions'
import { formatCombo, formatFakeCombo } from '@/lib/keybinds/combo'
import { formatCombo } from '@/lib/keybinds/combo'
import { arraysEqual } from '@/lib/storage'
import {
$bindings,
@@ -210,7 +210,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (
<span className="kbd-cap" key={key}>
{formatFakeCombo(key)}
{formatCombo(key)}
</span>
))}
</div>
@@ -162,8 +162,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
currentFastMode
)
// Grayed text: active row shows live state (Fast + effort);
// others show a fast-capability hint.
// Grayed text is live session state only. Do not label inactive
// rows as "Fast" just because they have a fast-capable sibling:
// that makes an off Fast toggle look like it is already on.
const meta = isCurrent
? [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
@@ -171,9 +172,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
]
.filter(Boolean)
.join(' ')
: caps?.fast || family.fastId
? copy.fast
: ''
: ''
// Every row is a hover-Edit submenu trigger. Activating it
// (pointer or keyboard) switches to the family's base model;
+27
View File
@@ -61,6 +61,26 @@ export interface SessionTitleResponse {
session_key?: string
}
export interface HandoffRequestResponse {
queued?: boolean
session_key?: string
platform?: string
// Human-readable home channel name for the destination platform.
home_name?: string
}
export interface HandoffStateResponse {
// '' | 'pending' | 'running' | 'completed' | 'failed'
state?: string
platform?: string
error?: string
}
export interface HandoffFailResponse {
failed?: boolean
state?: string
}
export interface ExecCommandDispatchResponse {
type: 'exec' | 'plugin'
output?: string
@@ -103,6 +123,13 @@ export interface ClientSessionState {
messages: ChatMessage[]
branch: string
cwd: string
model: string
provider: string
reasoningEffort: string
serviceTier: string
fast: boolean
yolo: boolean
personality: string
busy: boolean
awaitingResponse: boolean
streamId: string | null
@@ -63,7 +63,7 @@ export function directiveIconSvg(type: string) {
return `<svg ${SVG_ATTRS} class="size-3 shrink-0 opacity-80">${inner}</svg>`
}
export function directiveIconElement(type: string) {
function iconElementFromPaths(paths: string[]) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('class', 'size-3 shrink-0 opacity-80')
svg.setAttribute('fill', 'none')
@@ -74,7 +74,7 @@ export function directiveIconElement(type: string) {
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
for (const d of iconPathsFor(type)) {
for (const d of paths) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
svg.append(path)
@@ -83,6 +83,46 @@ export function directiveIconElement(type: string) {
return svg
}
export function directiveIconElement(type: string) {
return iconElementFromPaths(iconPathsFor(type))
}
/** Per-type slash-command pill styling. The composer inserts these chips when a
* command is picked; the kind drives a theme-aware accent so commands, skills,
* and themes read distinctly (Cursor-style). */
export type SlashChipKind = 'command' | 'skill' | 'theme'
const SLASH_ICON_PATHS: Record<SlashChipKind, string[]> = {
command: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
skill: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'],
theme: [
'M3 21v-4a4 4 0 1 1 4 4h-4',
'M21 3a16 16 0 0 0 -12.8 10.2',
'M21 3a16 16 0 0 1 -10.2 12.8',
'M10.6 9a9 9 0 0 1 4.4 4.4'
]
}
const SLASH_CHIP_VARIANT: Record<SlashChipKind, string> = {
command:
'bg-[color-mix(in_srgb,var(--ui-accent)_14%,transparent)] text-[color-mix(in_srgb,var(--ui-accent)_82%,var(--foreground))]',
skill:
'bg-[color-mix(in_srgb,var(--ui-warm)_18%,transparent)] text-[color-mix(in_srgb,var(--ui-warm)_82%,var(--foreground))]',
theme:
'bg-[color-mix(in_srgb,var(--ui-accent-secondary)_16%,transparent)] text-[color-mix(in_srgb,var(--ui-accent-secondary)_82%,var(--foreground))]'
}
export const SLASH_CHIP_BASE_CLASS =
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none'
export function slashChipClass(kind: SlashChipKind): string {
return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}`
}
export function slashIconElement(kind: SlashChipKind) {
return iconElementFromPaths(SLASH_ICON_PATHS[kind])
}
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
<svg
className="size-3 shrink-0 opacity-80"
@@ -929,22 +929,42 @@ const SystemMessage: FC = () => {
const slashStatus = text.match(SLASH_STATUS_RE)
if (slashStatus?.groups) {
const output = slashStatus.groups.output.trim()
// Single-line status (e.g. "model → x") reads best centered inline; padded
// multiline output (catalogs, usage tables) needs left-aligned, wider room
// or the column alignment breaks.
const multiline = output.includes('\n')
return (
<MessagePrimitive.Root
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/60"
className={cn(
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60',
multiline ? 'text-left' : 'text-center'
)}
data-role="system"
data-slot="aui_system-message-root"
>
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
<span className="mx-1.5 text-muted-foreground/35">·</span>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={slashStatus.groups.output.trim()} />
{multiline ? (
<LinkifiedText className="mt-0.5 block whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
) : (
<>
<span className="mx-1.5 text-muted-foreground/35">·</span>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
</>
)}
</MessagePrimitive.Root>
)
}
const multiline = text.includes('\n')
return (
<MessagePrimitive.Root
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/55"
className={cn(
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55',
multiline ? 'text-left' : 'text-center'
)}
data-role="system"
data-slot="aui_system-message-root"
>
@@ -16,7 +16,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
@@ -51,6 +50,8 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
return <ApprovalBar request={request} />
}
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
const { t } = useI18n()
const copy = t.assistant.approval
@@ -126,7 +127,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{formatCombo('mod+enter')}</span>}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
@@ -0,0 +1,108 @@
import { useQuery } from '@tanstack/react-query'
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 { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { Check, MessageCircle } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface SessionPickerDialogProps {
/** Stored id of the session currently open, so it can be flagged in the list. */
activeStoredSessionId?: string | null
onOpenChange: (open: boolean) => void
onResume: (storedSessionId: string) => void
open: boolean
}
/**
* Desktop equivalent of the TUI's sessions overlay (`/resume`, `/sessions`,
* `/switch`): a focused, type-to-filter list of recent sessions that resumes
* the picked one. Mirrors the command palette's cmdk surface but scoped to
* sessions only, so `/resume` feels first-class instead of falling through to
* the headless slash worker (which can't render the picker).
*/
export function SessionPickerDialog({
activeStoredSessionId,
onOpenChange,
onResume,
open
}: SessionPickerDialogProps) {
const { t } = useI18n()
const [search, setSearch] = useState('')
const sessionsQuery = useQuery({
enabled: open,
queryFn: () => listSessions(200, 1, 'exclude'),
queryKey: ['session-picker', 'sessions']
})
useEffect(() => {
if (!open) {
setSearch('')
}
}, [open])
const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data])
return (
<DialogPrimitive.Root onOpenChange={onOpenChange} open={open}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.sections.sessions}</DialogPrimitive.Title>
<Command className="bg-transparent" loop>
<CommandInput
onValueChange={setSearch}
placeholder={t.commandCenter.searchPlaceholder}
value={search}
/>
<CommandList className="max-h-[min(24rem,60vh)]">
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
<CommandGroup
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
heading={t.commandCenter.sections.sessions}
>
{sessions.map(session => {
const title = sessionTitle(session)
const preview = session.preview?.trim()
return (
<CommandItem
className="gap-2.5"
key={session.id}
onSelect={() => {
onResume(session.id)
onOpenChange(false)
}}
value={`${title} ${preview ?? ''} ${session.id}`}
>
<MessageCircle className="size-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-col leading-snug">
<span className="truncate">{title}</span>
{preview ? (
<span className="truncate text-xs text-muted-foreground/70">{preview}</span>
) : null}
</span>
<Check
className={cn(
'ml-auto size-4 shrink-0 text-foreground',
session.id !== activeStoredSessionId && 'invisible'
)}
/>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
+11 -5
View File
@@ -1,5 +1,4 @@
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import { formatCombo } from '@/lib/keybinds/combo'
import type { Translations } from './types'
@@ -519,7 +518,7 @@ export const en: Translations = {
loading: 'Loading archived sessions…',
archivedTitle: 'Archived sessions',
archivedIntro:
`Archived chats are hidden from the sidebar but keep all their messages. ${formatCombo('mod')}-click a chat in the sidebar to archive it.`,
'Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to archive it.',
emptyArchivedTitle: 'Nothing archived',
emptyArchivedDesc: 'Archive a chat to hide it here.',
unarchive: 'Unarchive',
@@ -530,7 +529,7 @@ export const en: Translations = {
defaultDirTitle: 'Default project directory',
defaultDirDesc:
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
defaultDirUpdated: `Default project directory updated — start a new chat (${formatCombo('mod+n')}) for it to take effect`,
defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect',
defaultsTo: label => `Defaults to ${label}.`,
change: 'Change',
choose: 'Choose',
@@ -1678,7 +1677,7 @@ export const en: Translations = {
loadingQuestion: 'Loading question…',
other: 'Other (type your answer)',
placeholder: 'Type your answer…',
shortcut: `${formatCombo('mod+enter')} to send`,
shortcut: '⌘/Ctrl + Enter to send',
back: 'Back',
skip: 'Skip',
send: 'Send'
@@ -1779,7 +1778,14 @@ export const en: Translations = {
clipboard: 'Clipboard',
noClipboardImage: 'No image found in clipboard',
clipboardPasteFailed: 'Clipboard paste failed',
dropFiles: 'Drop files'
dropFiles: 'Drop files',
handoff: {
pickPlatform: 'Choose a destination',
success: platform => `Handed off to ${platform}. Resume here anytime.`,
systemNote: platform => `↻ Handed off to ${platform} — resume here anytime.`,
failed: error => `Handoff failed: ${error}`,
timedOut: 'Timed out waiting for the gateway. Is `hermes gateway` running?'
}
},
errors: {
+10 -4
View File
@@ -1,5 +1,4 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import { defineLocale } from './define-locale'
@@ -643,7 +642,7 @@ export const ja = defineLocale({
loading: 'アーカイブ済みセッションを読み込み中…',
archivedTitle: 'アーカイブ済みセッション',
archivedIntro:
`アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを ${formatCombo('mod')} クリックするとアーカイブできます。`,
'アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを Ctrl/⌘ クリックするとアーカイブできます。',
emptyArchivedTitle: 'アーカイブがありません',
emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。',
unarchive: 'アーカイブを解除',
@@ -1812,7 +1811,7 @@ export const ja = defineLocale({
loadingQuestion: '質問を読み込み中…',
other: 'その他(回答を入力)',
placeholder: '回答を入力…',
shortcut: `${formatCombo('mod+enter')} で送信`,
shortcut: '⌘/Ctrl + Enter で送信',
back: '戻る',
skip: 'スキップ',
send: '送信'
@@ -1915,7 +1914,14 @@ export const ja = defineLocale({
clipboard: 'クリップボード',
noClipboardImage: 'クリップボードに画像が見つかりません',
clipboardPasteFailed: 'クリップボードからの貼り付けに失敗しました',
dropFiles: 'ファイルをドロップ'
dropFiles: 'ファイルをドロップ',
handoff: {
pickPlatform: '送信先を選択',
success: platform => `${platform} に引き継ぎました。いつでもここで再開できます。`,
systemNote: platform => `${platform} に引き継ぎました — いつでもここで再開できます。`,
failed: error => `引き継ぎに失敗しました: ${error}`,
timedOut: 'ゲートウェイの待機がタイムアウトしました。`hermes gateway` は起動していますか?'
}
},
errors: {
+7
View File
@@ -1437,6 +1437,13 @@ export interface Translations {
noClipboardImage: string
clipboardPasteFailed: string
dropFiles: string
handoff: {
pickPlatform: string
success: (platform: string) => string
systemNote: (platform: string) => string
failed: (error: string) => string
timedOut: string
}
}
errors: {
+10 -4
View File
@@ -1,5 +1,4 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import { defineLocale } from './define-locale'
@@ -628,7 +627,7 @@ export const zhHant = defineLocale({
loading: '正在載入已封存工作階段…',
archivedTitle: '已封存工作階段',
archivedIntro:
`已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 ${formatCombo('mod')} 點擊聊天即可封存。`,
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
emptyArchivedTitle: '暫無封存',
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
unarchive: '取消封存',
@@ -1773,7 +1772,7 @@ export const zhHant = defineLocale({
loadingQuestion: '正在載入問題…',
other: '其他(輸入您的答案)',
placeholder: '輸入您的答案…',
shortcut: `${formatCombo('mod+enter')} 傳送`,
shortcut: '⌘/Ctrl + Enter 傳送',
back: '返回',
skip: '略過',
send: '傳送'
@@ -1874,7 +1873,14 @@ export const zhHant = defineLocale({
clipboard: '剪貼簿',
noClipboardImage: '剪貼簿中沒有圖片',
clipboardPasteFailed: '剪貼簿貼上失敗',
dropFiles: '拖曳檔案'
dropFiles: '拖曳檔案',
handoff: {
pickPlatform: '選擇目標平台',
success: platform => `已移交到 ${platform}。隨時可在此處恢復。`,
systemNote: platform => `↻ 已移交到 ${platform} — 隨時可在此處恢復。`,
failed: error => `移交失敗:${error}`,
timedOut: '等待閘道逾時。`hermes gateway` 是否正在執行?'
}
},
errors: {
+10 -4
View File
@@ -1,5 +1,4 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import type { Translations } from './types'
@@ -713,7 +712,7 @@ export const zh: Translations = {
sessions: {
loading: '正在加载已归档会话…',
archivedTitle: '已归档会话',
archivedIntro: `已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 ${formatCombo('mod')} 点击对话即可归档。`,
archivedIntro: '已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 Ctrl/⌘ 点击对话即可归档。',
emptyArchivedTitle: '暂无归档',
emptyArchivedDesc: '归档一个对话后会显示在这里。',
unarchive: '取消归档',
@@ -1857,7 +1856,7 @@ export const zh: Translations = {
loadingQuestion: '正在加载问题…',
other: '其他 (输入你的答案)',
placeholder: '输入你的答案…',
shortcut: `${formatCombo('mod+enter')} 发送`,
shortcut: '⌘/Ctrl + Enter 发送',
back: '返回',
skip: '跳过',
send: '发送'
@@ -1957,7 +1956,14 @@ export const zh: Translations = {
clipboard: '剪贴板',
noClipboardImage: '剪贴板中没有图片',
clipboardPasteFailed: '粘贴剪贴板失败',
dropFiles: '拖放文件'
dropFiles: '拖放文件',
handoff: {
pickPlatform: '选择目标平台',
success: platform => `已移交到 ${platform}。随时可在此处恢复。`,
systemNote: platform => `↻ 已移交到 ${platform} — 随时可在此处恢复。`,
failed: error => `移交失败:${error}`,
timedOut: '等待网关超时。`hermes gateway` 是否正在运行?'
}
},
errors: {
+11
View File
@@ -173,3 +173,14 @@ export function hasAnsiCodes(input: string): boolean {
// eslint-disable-next-line no-control-regex
return /\x1b\[/.test(input)
}
/** Remove all ANSI escape sequences, returning plain text. Use when output is
* rendered as text (e.g. chat system messages) rather than styled segments
* otherwise the ESC byte is invisible and the `[1;31m…` payload leaks through. */
export function stripAnsi(input: string): string {
if (!input) {
return input
}
return input.replace(OTHER_ESCAPE_RE, '').replace(CSI_RE, '')
}
+7
View File
@@ -40,6 +40,13 @@ export function createClientSessionState(
messages,
branch: '',
cwd: '',
model: '',
provider: '',
reasoningEffort: '',
serviceTier: '',
fast: false,
yolo: false,
personality: '',
busy: false,
awaitingResponse: false,
streamId: null,
@@ -7,7 +7,9 @@ import {
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
isDesktopSlashSuggestion,
isModelPickerCommand
isModelPickerCommand,
isPickerCommand,
resolveDesktopCommand
} from './desktop-slash-commands'
describe('desktop slash command curation', () => {
@@ -38,6 +40,18 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
})
it('surfaces /tools, /save, and /personality on the desktop', () => {
expect(isDesktopSlashSuggestion('/tools')).toBe(true)
expect(isDesktopSlashSuggestion('/save')).toBe(true)
expect(isDesktopSlashSuggestion('/personality')).toBe(true)
expect(isDesktopSlashCommand('/tools')).toBe(true)
expect(isDesktopSlashCommand('/save')).toBe(true)
expect(isDesktopSlashCommand('/personality')).toBe(true)
expect(desktopSlashUnavailableMessage('/tools')).toBeNull()
expect(desktopSlashUnavailableMessage('/save')).toBeNull()
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
})
it('allows aliases to execute without cluttering the popover', () => {
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
expect(isDesktopSlashCommand('/reset')).toBe(true)
@@ -74,6 +88,24 @@ describe('desktop slash command curation', () => {
['/new', 'Start a new desktop chat'],
['/ship-it', 'Run release checklist']
])
// skill_count is recomputed from the filtered output (only /ship-it is an
// extension command — /new is a built-in) so the /help footer matches what
// the user actually sees rather than echoing the unfiltered backend total.
expect(filtered.skill_count).toBe(1)
})
it('recomputes skill_count to reflect only extensions surfaced on desktop', () => {
const filtered = filterDesktopCommandsCatalog({
pairs: [
['/new', 'Start a new session'],
['/clear', 'Clear terminal screen'],
['/gif-search', 'Search for a gif'],
['/ship-it', 'Run release checklist']
],
skill_count: 12
})
expect(filtered.pairs?.map(([cmd]) => cmd)).toEqual(['/new', '/gif-search', '/ship-it'])
expect(filtered.skill_count).toBe(2)
})
@@ -123,4 +155,26 @@ describe('desktop slash command curation', () => {
expect(isModelPickerCommand('/new')).toBe(false)
expect(isModelPickerCommand('/skills')).toBe(false)
})
it('gives /resume (and its aliases) a first-class session picker surface', () => {
expect(isPickerCommand('/resume', 'session')).toBe(true)
expect(isPickerCommand('/sessions', 'session')).toBe(true)
expect(isPickerCommand('/switch', 'session')).toBe(true)
// Unlike /model, /resume shows in the popover; its aliases stay hidden.
expect(isDesktopSlashSuggestion('/resume')).toBe(true)
expect(isDesktopSlashSuggestion('/sessions')).toBe(false)
expect(isDesktopSlashCommand('/switch')).toBe(true)
// The session picker is distinct from the model picker.
expect(isModelPickerCommand('/resume')).toBe(false)
})
it('resolves commands and aliases to their declared surface', () => {
expect(resolveDesktopCommand('/new')?.surface).toEqual({ kind: 'action', action: 'new' })
expect(resolveDesktopCommand('/reset')?.surface).toEqual({ kind: 'action', action: 'new' })
expect(resolveDesktopCommand('/resume')?.surface).toEqual({ kind: 'picker', picker: 'session' })
expect(resolveDesktopCommand('/usage')?.surface).toEqual({ kind: 'exec' })
expect(resolveDesktopCommand('/clear')?.surface).toEqual({ kind: 'unavailable', reason: 'terminal' })
// Skill / quick commands aren't in the registry.
expect(resolveDesktopCommand('/gif-search')).toBeNull()
})
})
+239 -145
View File
@@ -22,110 +22,161 @@ export interface DesktopThemeCommandOption {
name: string
}
const DESKTOP_COMMAND_META = [
['/agents', 'Show active desktop sessions and running tasks'],
['/background', 'Run a prompt in the background'],
['/branch', 'Branch the latest message into a new chat'],
['/compress', 'Compress this conversation context'],
['/debug', 'Create a debug report'],
['/goal', 'Manage the standing goal for this session'],
['/help', 'Show desktop slash commands'],
['/new', 'Start a new desktop chat'],
['/profile', 'Switch the active Hermes profile'],
['/queue', 'Queue a prompt for the next turn'],
['/resume', 'Resume a saved session'],
['/retry', 'Retry the last user message'],
['/rollback', 'List or restore filesystem checkpoints'],
['/skin', 'Switch desktop theme or cycle to the next one'],
['/status', 'Show current session status'],
['/steer', 'Steer the current run after the next tool call'],
['/stop', 'Stop running background processes'],
['/title', 'Rename the current session'],
['/undo', 'Remove the last user/assistant exchange'],
['/usage', 'Show token usage for this session'],
['/version', 'Show Hermes Agent version'],
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
] as const
/**
* Local client action a command resolves to. Each id maps to exactly one
* handler in the dispatcher (`use-prompt-actions`), so adding a command never
* means adding a branch to a switch ladder you add a row here + a handler
* keyed by the id.
*/
export type DesktopActionId =
| 'branch'
| 'handoff'
| 'help'
| 'new'
| 'profile'
| 'skin'
| 'title'
| 'yolo'
const DESKTOP_COMMANDS: ReadonlySet<string> = new Set(DESKTOP_COMMAND_META.map(([command]) => command))
/** A command fulfilled by opening a desktop overlay picker. */
export type DesktopPickerId = 'model' | 'session'
const DESKTOP_ALIASES = new Map([
['/bg', '/background'],
['/btw', '/background'],
['/fork', '/branch'],
['/q', '/queue'],
['/reload_mcp', '/reload-mcp'],
['/reload_skills', '/reload-skills'],
['/reset', '/new'],
['/tasks', '/agents']
])
/** Why a known Hermes command has no desktop UI surface. */
export type DesktopUnavailableReason = 'advanced' | 'messaging' | 'settings' | 'terminal'
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
/**
* How the desktop fulfils a command. This is the single discriminator the
* dispatcher, popover, pills, and pickers all read no parallel block-lists.
*
* - `action` handled by a local client handler (new chat, branch, )
* - `picker` opens an overlay (`/model`, `/resume`); a typed arg is
* resolved by that picker instead of falling through
* - `exec` runs on the backend via slash.exec / command.dispatch and
* renders its text output inline
* - `unavailable` a known command with genuinely no desktop UI (terminal-only,
* messaging-only, ); shows a reason instead of executing
*/
export type DesktopCommandSurface =
| { kind: 'action'; action: DesktopActionId }
| { kind: 'picker'; picker: DesktopPickerId }
| { kind: 'exec' }
| { kind: 'unavailable'; reason: DesktopUnavailableReason }
const PICKER_OWNED_COMMANDS = new Set(['/model'])
export interface DesktopCommandSpec {
/** Canonical command, leading slash included (e.g. `/resume`). */
name: string
/** Popover/help label; omitted for unavailable commands (never surfaced). */
description?: string
aliases?: string[]
surface: DesktopCommandSurface
/**
* Hide from the slash popover / completions while still letting it execute.
* Used for picker commands reachable from chrome (the model picker lives on
* the status bar), so the popover doesn't dead-end on inline completion.
*/
hidden?: boolean
/**
* The command has an inline options "screen" (theme / personality / session /
* platform / toolset list). Picking the bare command in the popover expands to
* that argument step instead of committing mirroring typing `/<cmd> ` by hand.
*/
args?: boolean
}
const TERMINAL_ONLY_COMMANDS = new Set([
'/browser',
'/busy',
'/clear',
'/commands',
'/compact',
'/config',
'/copy',
'/cron',
'/details',
'/exit',
'/footer',
'/gateway',
'/gquota',
'/history',
'/image',
'/indicator',
'/logs',
'/mouse',
'/paste',
'/platforms',
'/plugins',
'/quit',
'/redraw',
'/reload',
'/restart',
'/save',
'/sb',
'/set-home',
'/sethome',
'/snap',
'/snapshot',
'/statusbar',
'/toolsets',
'/tools',
'/update',
'/verbose'
])
const exec = (): DesktopCommandSurface => ({ kind: 'exec' })
const action = (id: DesktopActionId): DesktopCommandSurface => ({ kind: 'action', action: id })
const picker = (id: DesktopPickerId): DesktopCommandSurface => ({ kind: 'picker', picker: id })
const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => ({ kind: 'unavailable', reason })
const MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny'])
/**
* THE source of truth for desktop slash commands. Everything below execution
* gating, popover suggestions, catalog filtering, pill grouping, and the
* dispatcher's behavior derives from this one table.
*/
const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
// Local client actions
{ name: '/new', description: 'Start a new desktop chat', aliases: ['/reset'], surface: action('new') },
{ name: '/branch', description: 'Branch the latest message into a new chat', aliases: ['/fork'], surface: action('branch') },
{ name: '/yolo', description: 'Toggle YOLO — auto-approve dangerous commands', surface: action('yolo') },
{ name: '/handoff', description: 'Hand off this session to a messaging platform', surface: action('handoff'), args: true },
{ name: '/profile', description: 'Switch the active Hermes profile', surface: action('profile') },
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
{ name: '/title', description: 'Rename the current session', surface: action('title') },
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
const SETTINGS_OWNED_COMMANDS = new Set(['/skills'])
// Overlay pickers
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
{
name: '/resume',
description: 'Resume a saved session',
aliases: ['/sessions', '/switch'],
surface: picker('session'),
args: true
},
const ADVANCED_COMMANDS = new Set([
'/curator',
'/fast',
'/insights',
'/kanban',
'/personality',
'/reasoning',
'/reload-mcp',
'/reload-skills',
'/voice'
])
// Backend-executed commands that render useful inline output
{ name: '/agents', description: 'Show active desktop sessions and running tasks', aliases: ['/tasks'], surface: exec() },
{ name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() },
{ name: '/compress', description: 'Compress this conversation context', surface: exec() },
{ name: '/debug', description: 'Create a debug report', surface: exec() },
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
{ name: '/save', description: 'Save the current transcript to JSON', surface: exec() },
{ name: '/status', description: 'Show current session status', surface: exec() },
{ name: '/steer', description: 'Steer the current run after the next tool call', surface: exec() },
{ name: '/stop', description: 'Stop running background processes', surface: exec() },
{ name: '/tools', description: 'List or toggle tools available to the agent', surface: exec(), args: true },
{ name: '/undo', description: 'Remove the last user/assistant exchange', surface: exec() },
{ name: '/usage', description: 'Show token usage for this session', surface: exec() },
{ name: '/version', description: 'Show Hermes Agent version', surface: exec() },
const BLOCKED_COMMANDS = new Set([
...PICKER_OWNED_COMMANDS,
...TERMINAL_ONLY_COMMANDS,
...MESSAGING_ONLY_COMMANDS,
...SETTINGS_OWNED_COMMANDS,
...ADVANCED_COMMANDS
])
// No desktop surface, but carry an alias (underscore spelling variants).
{ name: '/reload-mcp', aliases: ['/reload_mcp'], surface: unavailable('advanced') },
{ name: '/reload-skills', aliases: ['/reload_skills'], surface: unavailable('advanced') }
]
// Known commands with no desktop surface (and no alias) — a flat name list
// per reason beats 40 identical object literals.
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
terminal: [
'/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
],
messaging: ['/approve', '/deny'],
settings: ['/skills'],
advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice']
}
const ALL_SPECS: readonly DesktopCommandSpec[] = [
...DESKTOP_COMMAND_SPECS,
...(Object.entries(NO_DESKTOP_SURFACE) as [DesktopUnavailableReason, readonly string[]][]).flatMap(
([reason, names]) => names.map(name => ({ name, surface: unavailable(reason) }))
)
]
const SPEC_BY_NAME = new Map<string, DesktopCommandSpec>(ALL_SPECS.map(spec => [spec.name, spec]))
const ALIAS_TO_CANONICAL = new Map<string, string>(
ALL_SPECS.flatMap(spec => (spec.aliases ?? []).map(alias => [alias, spec.name] as const))
)
const UNAVAILABLE_MESSAGE: Record<DesktopUnavailableReason, (command: string) => string> = {
advanced: command =>
`${command} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`,
messaging: command => `${command} is only used from messaging platforms.`,
settings: command => `${command} is managed from the desktop sidebar.`,
terminal: command => `${command} is only available in the terminal interface.`
}
const PICKER_UNAVAILABLE_MESSAGE: Record<DesktopPickerId, (command: string) => string> = {
model: command => `${command} uses the desktop model picker instead of a slash command.`,
session: command => `${command} uses the desktop session picker instead of a slash command.`
}
function normalizeCommand(command: string): string {
const trimmed = command.trim()
@@ -137,27 +188,25 @@ function normalizeCommand(command: string): string {
export function canonicalDesktopSlashCommand(command: string): string {
const normalized = normalizeCommand(command)
return DESKTOP_ALIASES.get(normalized) || normalized
return ALIAS_TO_CANONICAL.get(normalized) || normalized
}
export function isDesktopSlashCommand(command: string): boolean {
/** Resolve a command (or alias) to its desktop spec, or null for unknown/extension commands. */
export function resolveDesktopCommand(command: string): DesktopCommandSpec | null {
return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command)) ?? null
}
function isKnownHermesSlashCommand(command: string): boolean {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
if (BLOCKED_COMMANDS.has(normalized) || BLOCKED_COMMANDS.has(canonical)) {
return false
}
return DESKTOP_COMMANDS.has(canonical) || !isKnownHermesSlashCommand(normalized)
return SPEC_BY_NAME.has(normalized) || ALIAS_TO_CANONICAL.has(normalized)
}
/**
* An "extension" command is anything the backend surfaces that is NOT one of
* Hermes' built-in slash commands i.e. skill commands (`/gif-search`,
* `/codex`, ) and user-defined quick commands. These are user-activated, so
* they should appear in the desktop slash palette even though they aren't in
* the curated `DESKTOP_COMMANDS` allow-list. This mirrors the predicate in
* `isDesktopSlashCommand` that already lets them EXECUTE when typed.
* they appear in the desktop slash palette and execute when typed.
*/
export function isDesktopSlashExtensionCommand(command: string): boolean {
const normalized = normalizeCommand(command)
@@ -169,63 +218,85 @@ export function isDesktopSlashExtensionCommand(command: string): boolean {
return !isKnownHermesSlashCommand(normalized)
}
export function isDesktopSlashSuggestion(command: string): boolean {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
/** Gates execution: true unless the command is a known no-desktop-surface command. */
export function isDesktopSlashCommand(command: string): boolean {
const spec = resolveDesktopCommand(command)
// Surface skill / quick commands (extensions the backend provides) alongside
// the curated built-ins. Built-in aliases stay hidden so the popover isn't
// cluttered with duplicates.
if (isDesktopSlashExtensionCommand(normalized)) {
return true
if (spec) {
return spec.surface.kind !== 'unavailable'
}
return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized)
return isDesktopSlashExtensionCommand(command)
}
/** Gates discovery in the popover/completions. */
export function isDesktopSlashSuggestion(command: string): boolean {
const normalized = normalizeCommand(command)
// Aliases stay hidden so the popover isn't cluttered with duplicates.
if (ALIAS_TO_CANONICAL.has(normalized)) {
return false
}
const spec = SPEC_BY_NAME.get(normalized)
if (spec) {
return spec.surface.kind !== 'unavailable' && !spec.hidden
}
// Skill / quick commands the backend provides.
return isDesktopSlashExtensionCommand(normalized)
}
/**
* True for commands the desktop fulfils by opening the model picker overlay
* (e.g. `/model`) rather than executing a slash command. The caller opens the
* picker UI instead of printing the "uses the desktop model picker" notice.
* True for commands the desktop fulfils by opening an overlay picker
* (`/model`, `/resume`/`/sessions`/`/switch`). Optionally pin to one picker.
*/
export function isModelPickerCommand(command: string): boolean {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
export function isPickerCommand(command: string, picker?: DesktopPickerId): boolean {
const surface = resolveDesktopCommand(command)?.surface
return PICKER_OWNED_COMMANDS.has(canonical)
if (surface?.kind !== 'picker') {
return false
}
return picker ? surface.picker === picker : true
}
/** Back-compat shim for the model picker check. */
export function isModelPickerCommand(command: string): boolean {
return isPickerCommand(command, 'model')
}
export function desktopSlashUnavailableMessage(command: string): string | null {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
const canonical = canonicalDesktopSlashCommand(command)
const surface = SPEC_BY_NAME.get(canonical)?.surface
if (PICKER_OWNED_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.`
if (!surface) {
return null
}
if (SETTINGS_OWNED_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is managed from the desktop sidebar.`
if (surface.kind === 'unavailable') {
return UNAVAILABLE_MESSAGE[surface.reason](canonical)
}
if (MESSAGING_ONLY_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is only used from messaging platforms.`
}
if (ADVANCED_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`
}
if (TERMINAL_ONLY_COMMANDS.has(normalized) || TERMINAL_ONLY_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is only available in the terminal interface.`
if (surface.kind === 'picker') {
return PICKER_UNAVAILABLE_MESSAGE[surface.picker](canonical)
}
return null
}
export function desktopSlashDescription(command: string, fallback = ''): string {
const canonical = canonicalDesktopSlashCommand(command)
return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command))?.description || fallback
}
return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback
/**
* True when picking the bare command should expand to its inline argument
* options (theme / personality / session / platform / toolset) rather than
* committing immediately. Lets the popover act as a two-step picker.
*/
export function desktopSlashCommandTakesArgs(command: string): boolean {
return resolveDesktopCommand(command)?.args ?? false
}
export function desktopSkinSlashCompletions(
@@ -274,13 +345,36 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm
?.filter(([command]) => isDesktopSlashSuggestion(command))
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
// Recount skill commands from the filtered output so /help's footer reflects
// what the user actually sees. Backend's skill_count includes commands the
// desktop hides (terminal-only, picker-owned, advanced), producing a footer
// like "60 skill commands available" while only ~29 appear in the list.
const filteredCommands = new Set<string>()
for (const section of categories ?? []) {
for (const [command] of section.pairs) {
filteredCommands.add(canonicalDesktopSlashCommand(command))
}
}
for (const [command] of pairs ?? []) {
filteredCommands.add(canonicalDesktopSlashCommand(command))
}
let skillCount = 0
for (const command of filteredCommands) {
if (isDesktopSlashExtensionCommand(command)) {
skillCount += 1
}
}
const hasSkillCount = catalog.skill_count !== undefined || skillCount > 0
return {
...catalog,
...(categories ? { categories } : {}),
...(pairs ? { pairs } : {})
...(pairs ? { pairs } : {}),
...(hasSkillCount ? { skill_count: skillCount } : {})
}
}
function isKnownHermesSlashCommand(command: string): boolean {
return DESKTOP_COMMANDS.has(command) || DESKTOP_ALIASES.has(command) || BLOCKED_COMMANDS.has(command)
}
+11 -17
View File
@@ -5,9 +5,6 @@
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
// add a hotkey, add a row here and a handler there — nothing else.
import type { Combo, FakeCombo } from "./combo";
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
// The self-referential opener — bound + dispatched like any action, but shown in
@@ -30,16 +27,15 @@ export interface KeybindActionMeta {
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
export const PROFILE_SLOT_COUNT = 18
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => {
const slot = i+1
const combo = (slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`) as Combo
function comboForSlot(slot: number): string {
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
}
return ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [combo]
})
})
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [comboForSlot(i + 1)]
}))
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
// `mod` keeps one binding cross-platform; on macOS this shadows the system
@@ -108,12 +104,10 @@ export function keybindAction(id: string): KeybindActionMeta | undefined {
return ACTION_BY_ID.get(id)
}
export type KeybindBindings = Record<string, Combo[]>
export type KeybindBindings = Record<string, string[]>
export function defaultBindings(): KeybindBindings {
return Object.fromEntries<string, Combo[]>(
KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults] as Combo[]])
)
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
}
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
@@ -123,7 +117,7 @@ export function defaultBindings(): KeybindBindings {
export interface KeybindReadonly {
id: string
category: KeybindCategory
keys: readonly FakeCombo[]
keys: readonly string[]
}
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
+56 -97
View File
@@ -10,13 +10,11 @@
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
// folds `ctrl` → `mod`.
const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
export const modKey = IS_MAC ? 'metaKey' as const : 'ctrlKey' as const
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
// event.code → canonical base token. Letters/digits map to their lowercase
// character; everything else uses an explicit name so combos read cleanly.
const CODE_TO_KEY = {
const CODE_TO_KEY: Record<string, string> = {
Backquote: '`',
Backslash: '\\',
BracketLeft: '[',
@@ -37,50 +35,8 @@ const CODE_TO_KEY = {
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right'
} as const satisfies Record<Capitalize<string>, Lowercase<string>>
type SpecialKey = typeof CODE_TO_KEY[keyof typeof CODE_TO_KEY]
type Alpha = 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'
| 'n'|'o'|'p'|'q'|'r'|'s'|'t'|'u'|'v'|'w'|'x'|'y'|'z'
export type Digit = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'
type FKey =
| 'f1' | 'f2' | 'f3' | 'f4' | 'f5' | 'f6'
| 'f7' | 'f8' | 'f9' | 'f10' | 'f11' | 'f12'
| 'f13' | 'f14' | 'f15' | 'f16' | 'f17' | 'f18'
| 'f19' | 'f20' | 'f21' | 'f22' | 'f23' | 'f24'
type BaseKey = Alpha | Digit | FKey | SpecialKey
// subset of https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
type KeyCode = Uppercase<FKey> | `Digit${Digit}` | `Key${Uppercase<Alpha>}` | keyof typeof CODE_TO_KEY
function baseKeyFromCode(code: KeyCode): BaseKey | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase() as Alpha
}
if (code.startsWith('Digit')) {
return code.slice(5) as Digit
}
if (code.startsWith('Numpad')) {
const rest = code.slice(6)
return /^[0-9]$/.test(rest) ? rest as Digit : null
}
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
return code.toLowerCase() as FKey
}
return CODE_TO_KEY[code as keyof typeof CODE_TO_KEY] ?? null
}
const MODIFIER_CODES = new Set([
'AltLeft',
'AltRight',
@@ -92,20 +48,42 @@ const MODIFIER_CODES = new Set([
'ShiftRight'
])
function baseKeyFromCode(code: string): string | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase()
}
if (code.startsWith('Digit')) {
return code.slice(5)
}
if (code.startsWith('Numpad')) {
const rest = code.slice(6)
return /^[0-9]$/.test(rest) ? rest : null
}
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
return code.toLowerCase()
}
return CODE_TO_KEY[code] ?? null
}
// Returns the canonical combo for a keydown, or null while only modifiers are
// held (so capture mode keeps waiting for a real key).
export function comboFromEvent(event: KeyboardEvent): Combo | null {
export function comboFromEvent(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) {
return null
}
const base = baseKeyFromCode(event.code as KeyCode)
const base = baseKeyFromCode(event.code)
if (!base) {
return null
}
const parts: Combo[] = []
const parts: string[] = []
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
// Control IS the accelerator, so it folds into `mod`.
@@ -127,7 +105,7 @@ export function comboFromEvent(event: KeyboardEvent): Combo | null {
parts.push(base)
return parts.join('+') as Combo
return parts.join('+')
}
// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
@@ -137,14 +115,7 @@ export function canonicalizeCombo(combo: string): string {
return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
}
const MOD_LABELS = {
mod: IS_MAC ? '⌘' : 'Ctrl',
ctrl: IS_MAC ? '⌃' : 'Ctrl',
alt: IS_MAC ? '⌥' : 'Alt',
shift: IS_MAC ? '⇧' : 'Shift'
} as const
const FANCY_KEY_LABELS = {
const TOKEN_LABELS: Record<string, string> = {
enter: '↵',
escape: 'Esc',
backspace: '⌫',
@@ -153,47 +124,39 @@ const FANCY_KEY_LABELS = {
up: '↑',
down: '↓',
left: '←',
right: '→',
} as const
const TOKEN_LABELS: Record<string, string> = {
...MOD_LABELS,
...FANCY_KEY_LABELS
right: '→'
}
function labelForToken(token: string): string {
if (TOKEN_LABELS[token]) {
return TOKEN_LABELS[token]
function labelForBase(base: string): string {
if (TOKEN_LABELS[base]) {
return TOKEN_LABELS[base]
}
if (/^f\d{1,2}$/.test(token)) {
return token.toUpperCase()
if (/^f\d{1,2}$/.test(base)) {
return base.toUpperCase()
}
return token.length === 1 ? token.toUpperCase() : token
return base.length === 1 ? base.toUpperCase() : base
}
//
function labelForMod(mod: string): string {
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
type ModKey = keyof typeof MOD_LABELS
if (mod === 'ctrl') {
return IS_MAC ? '⌃' : 'Ctrl'
}
type ModPrefix = `${'mod+'|''}${'alt+'|''}${'shift+'|''}`
if (mod === 'alt') {
return IS_MAC ? '⌥' : 'Alt'
}
type ModPrefixedCombo<Suffix extends string> =
| `${ModPrefix}${Suffix}`
| ModKey
| 'mod+alt' | 'mod+shift' | 'alt+shift' | 'mod+alt+shift'
| 'ctrl+tab' | 'ctrl+shift+tab'
| `ctrl+${Digit}`
if (mod === 'shift') {
return IS_MAC ? '⇧' : 'Shift'
}
export type Combo = ModPrefixedCombo<BaseKey>
export type FakeCombo = ModPrefixedCombo<BaseKey | '@' | '?'>
// Human-readable keys, e.g. "mod+shift+k" returns ["⌘","⇧","K"] on macos, ["Ctrl","Shift","K"] elsewhere.
export function normalizeCombo(combo: Combo): string[] {
const parts = combo.split('+')
return parts.map(p => labelForToken(p.trim()))
return mod
}
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
@@ -202,18 +165,14 @@ export function comboTokens(combo: string): string[] {
const parts = combo.split('+')
const base = parts.pop() ?? ''
return [...parts.map(labelForToken), labelForToken(base)]
return [...parts.map(labelForMod), labelForBase(base)]
}
// Human-readable label, e.g. "mod+shift+k" returns "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: Combo): string {
return normalizeCombo(combo).join(IS_MAC ? '' : '+')
}
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: string): string {
const tokens = comboTokens(combo)
// like `formatCombo` but allows any input like `@`
export function formatFakeCombo(combo: FakeCombo): string {
return normalizeCombo(combo as Combo).join(IS_MAC ? '' : '+')
return IS_MAC ? tokens.join('') : tokens.join('+')
}
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire
@@ -231,6 +190,6 @@ export function isEditableTarget(target: EventTarget | null): boolean {
// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or
// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs.
export function comboAllowedInInput(combo: Combo): boolean {
export function comboAllowedInInput(combo: string): boolean {
return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
}
@@ -5,6 +5,8 @@ import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from '
describe('model-status-label', () => {
it('formats display names consistently', () => {
expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8')
expect(displayModelName('openai/gpt-5.5-fast')).toBe('GPT-5.5')
expect(displayModelName('deepseek/deepseek-v4-pro-thinking')).toBe('Deepseek V4 Pro')
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
})
+63
View File
@@ -3,8 +3,12 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
$composerAttachments,
addComposerAttachment,
clearSessionDraft,
type ComposerAttachment,
removeComposerAttachment,
SESSION_DRAFTS_STORAGE_KEY,
stashSessionDraft,
takeSessionDraft,
updateComposerAttachment
} from './composer'
@@ -41,3 +45,62 @@ describe('updateComposerAttachment', () => {
expect($composerAttachments.get()).toHaveLength(0)
})
})
describe('session drafts', () => {
afterEach(() => {
for (const scope of ['session-a', 'session-b', null]) {
clearSessionDraft(scope)
}
window.localStorage.clear()
})
it('keeps drafts isolated per session scope', () => {
stashSessionDraft('session-a', 'draft a', [])
stashSessionDraft('session-b', 'draft b', [attachment({ id: 'image:b', kind: 'image' })])
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: 'draft a' })
expect(takeSessionDraft('session-b').text).toBe('draft b')
expect(takeSessionDraft('session-b').attachments.map(a => a.id)).toEqual(['image:b'])
})
it('scopes the unsaved new-session draft separately from real sessions', () => {
stashSessionDraft(null, 'new chat draft', [])
stashSessionDraft('session-a', 'session draft', [])
expect(takeSessionDraft(null).text).toBe('new chat draft')
expect(takeSessionDraft(undefined).text).toBe('new chat draft')
expect(takeSessionDraft('session-a').text).toBe('session draft')
})
it('persists draft text (not attachments) to localStorage', () => {
stashSessionDraft('session-a', 'survives reload', [attachment({ id: 'file:a' })])
const persisted = JSON.parse(window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) ?? '{}') as Record<string, string>
expect(persisted['session-a']).toBe('survives reload')
})
it('evicts empty drafts instead of leaving stale entries behind', () => {
stashSessionDraft('session-a', 'saved', [])
stashSessionDraft('session-a', ' ', [])
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })
})
it('clears a stashed draft after an accepted submit', () => {
stashSessionDraft('session-a', 'sent prompt', [attachment({ id: 'file:a' })])
clearSessionDraft('session-a')
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })
})
it('returns clones so callers cannot mutate the stash', () => {
stashSessionDraft('session-a', 'draft', [attachment({ id: 'file:a' })])
const taken = takeSessionDraft('session-a')
taken.attachments[0]!.label = 'mutated'
expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf')
})
})
+78
View File
@@ -21,6 +21,84 @@ export const $composerDraft = atom('')
export const $composerAttachments = atom<ComposerAttachment[]>([])
export const $composerTerminalSelections = atom<Record<string, string>>({})
// Per-thread draft stash for the decoupled composer. Session lifecycle never
// touches this — only ChatBar's scope swap reads/writes it. Text mirrors to
// localStorage; attachments are memory-only (blobs, upload state).
export const SESSION_DRAFTS_STORAGE_KEY = 'hermes:composer-drafts:v3'
const NEW_SESSION_DRAFT_KEY = '__new__'
const MAX_PERSISTED_DRAFTS = 50
const EMPTY_SESSION_DRAFT: SessionDraft = { attachments: [], text: '' }
export interface SessionDraft {
attachments: ComposerAttachment[]
text: string
}
const draftKey = (scope: string | null | undefined) => scope?.trim() || NEW_SESSION_DRAFT_KEY
const cloneDraft = (draft: SessionDraft): SessionDraft => ({
attachments: draft.attachments.map(attachment => ({ ...attachment })),
text: draft.text
})
function loadPersistedDraftTexts(): [string, SessionDraft][] {
try {
const raw = window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY)
if (!raw) {
return []
}
return Object.entries(JSON.parse(raw) as Record<string, string>).map(([key, text]) => [
key,
{ attachments: [], text }
])
} catch {
return []
}
}
const draftsBySession = new Map<string, SessionDraft>(loadPersistedDraftTexts())
function persistDraftTexts() {
try {
const entries = [...draftsBySession]
.filter(([, draft]) => draft.text)
.slice(-MAX_PERSISTED_DRAFTS)
.map(([key, draft]) => [key, draft.text] as const)
if (entries.length === 0) {
window.localStorage.removeItem(SESSION_DRAFTS_STORAGE_KEY)
} else {
window.localStorage.setItem(SESSION_DRAFTS_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))
}
} catch {
// Best-effort only — quota/private-mode must never break typing.
}
}
export function stashSessionDraft(scope: string | null | undefined, text: string, attachments: ComposerAttachment[]) {
const key = draftKey(scope)
// Delete-then-set keeps MRU order for MAX_PERSISTED_DRAFTS eviction.
draftsBySession.delete(key)
if (text.trim() || attachments.length > 0) {
draftsBySession.set(key, cloneDraft({ attachments, text }))
}
persistDraftTexts()
}
export function takeSessionDraft(scope: string | null | undefined): SessionDraft {
const stashed = draftsBySession.get(draftKey(scope))
return stashed ? cloneDraft(stashed) : EMPTY_SESSION_DRAFT
}
export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', [])
export function setComposerDraft(value: string) {
$composerDraft.set(value)
}
+3 -4
View File
@@ -7,7 +7,6 @@ import {
type KeybindBindings
} from '@/lib/keybinds/actions'
import { canonicalizeCombo } from '@/lib/keybinds/combo'
import type { Combo } from '@/lib/keybinds/combo'
import { arraysEqual, persistString, storedString } from '@/lib/storage'
const STORAGE_KEY = 'hermes.desktop.keybinds'
@@ -29,7 +28,7 @@ function loadBindings(): KeybindBindings {
const value = parsed[id]
if (Array.isArray(value)) {
base[id] = value.filter((combo): combo is string => typeof combo === 'string') as Combo[]
base[id] = value.filter((combo): combo is string => typeof combo === 'string')
}
}
} catch {
@@ -79,7 +78,7 @@ export const $comboIndex = computed($bindings, bindings => {
return index
})
export function setBinding(actionId: string, combos: Combo[]): void {
export function setBinding(actionId: string, combos: string[]): void {
if (!keybindAction(actionId)) {
return
}
@@ -102,7 +101,7 @@ export function resetAllBindings(): void {
}
// Other actions that already use `combo` (excluding `actionId` itself).
export function conflictsFor(actionId: string, combo: Combo): string[] {
export function conflictsFor(actionId: string, combo: string): string[] {
const bindings = $bindings.get()
return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo))
+41 -2
View File
@@ -133,13 +133,52 @@ describe('mergeSessionPage', () => {
it('keeps a pinned session matched by its lineage root after compression', () => {
// The pin is stored on the lineage-root id, but the loaded row surfaces
// under its live compression tip. Matching on _lineage_root_id keeps it.
const previous = [session({ id: 'tip', _lineage_root_id: 'root' })]
const incoming = [session({ id: 'other' })]
const previous = [session({ id: 'tip', _lineage_root_id: 'root' })] as SessionInfo[]
const incoming = [session({ id: 'other' })] as SessionInfo[]
const merged = mergeSessionPage(previous, incoming, ['root'])
expect(merged.map(s => s.id)).toEqual(['tip', 'other'])
})
it('evicts an old compression tip when the incoming page has the new tip from the same lineage', () => {
// Repro of #43483: after auto-compression rotates the tip (#4 → #5),
// the sidebar showed both the old tip and the new tip as separate rows.
// The old tip must be evicted because its lineage key matches the incoming
// new tip's lineage key.
const previous = [
session({ id: 'tip-4', _lineage_root_id: 'root' }),
session({ id: 'other' }),
] as SessionInfo[]
const incoming = [
session({ id: 'tip-5', _lineage_root_id: 'root' }),
] as SessionInfo[]
// 'tip-4' is in the keep set (e.g. it was the active/working session),
// but should still be evicted because the incoming page carries the same
// lineage under a new tip id.
const merged = mergeSessionPage(previous, incoming, ['tip-4'])
expect(merged.map(s => s.id)).toEqual(['tip-5'])
// The new tip comes from the server payload.
expect(merged.find(s => s.id === 'tip-5')?._lineage_root_id).toBe('root')
})
it('preserves an unrelated pinned session even when lineage dedup is active', () => {
// Regression guard: lineage dedup must not accidentally evict sessions
// from a different lineage that happen to be in the keep set.
const previous = [
session({ id: 'a-old', _lineage_root_id: 'lineage-a' }),
session({ id: 'b', _lineage_root_id: 'lineage-b' }),
] as SessionInfo[]
const incoming = [
session({ id: 'a-new', _lineage_root_id: 'lineage-a' }),
] as SessionInfo[]
const merged = mergeSessionPage(previous, incoming, ['b'])
expect(merged.map(s => s.id)).toEqual(['b', 'a-new'])
})
})
describe('workspaceCwdForNewSession', () => {
+10
View File
@@ -125,10 +125,18 @@ export function mergeSessionPage(
}
const incomingIds = new Set(incoming.map(session => session.id))
// Deduplicate by compression lineage: when auto-compression rotates the tip
// id (old #4 → new #5), the incoming page carries the new tip but the
// previous list still holds the old one. Without lineage-level dedup both
// rows survive as separate sidebar entries (fixes #43483).
const incomingLineageKeys = new Set(
incoming.map(session => session._lineage_root_id ?? session.id)
)
const survivors = previous.filter(
session =>
!incomingIds.has(session.id) &&
!incomingLineageKeys.has(session._lineage_root_id ?? session.id) &&
(keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id)))
)
@@ -200,6 +208,7 @@ export const $availablePersonalities = atom<string[]>([])
export const $introSeed = atom(0)
export const $contextSuggestions = atom<ContextSuggestion[]>([])
export const $modelPickerOpen = atom(false)
export const $sessionPickerOpen = atom(false)
export const setConnection = (next: Updater<HermesConnection | null>) => updateAtom($connection, next)
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
@@ -249,6 +258,7 @@ export const setAvailablePersonalities = (next: Updater<string[]>) => updateAtom
export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, next)
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
export const setSessionPickerOpen = (next: Updater<boolean>) => updateAtom($sessionPickerOpen, next)
// Watchdog tracking — when does a "working" session count as stuck?
// Long-running tool calls (LLM inference, long shell commands, web fetches)
+1 -1
View File
@@ -8,7 +8,7 @@
},
"types": "./src/index.ts",
"scripts": {
"type-check": "tsc -p tsconfig.json --noEmit"
"typecheck": "tsc -p . --noEmit"
},
"devDependencies": {
"typescript": "^6.0.3"
+94 -3
View File
@@ -3426,6 +3426,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# frozen when the agent thread completes, displayed in the status bar.
self._prompt_start_time: Optional[float] = None # time.time() when turn started
self._prompt_duration: float = 0.0 # frozen duration of last completed turn
self._last_turn_finished_at: Optional[float] = None # time.time() when the last agent loop finished
# Initialize SQLite session store early so /title works before first message
self._session_db = None
try:
@@ -3812,6 +3813,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
emoji = "" if live else ""
return f"{emoji} {time_str}"
@staticmethod
def _format_idle_since(last_finished_at: Optional[float], turn_live: bool) -> str:
"""Format time since the last final agent response for the status bar.
Returns an empty string while a turn is live (the per-prompt elapsed
timer covers that case) or before the first turn has completed.
Compact read-out: `` 42s`` / `` 3m`` / `` 1h 12m``.
"""
if turn_live or last_finished_at is None:
return ""
idle = max(0.0, time.time() - last_finished_at)
return f"{format_duration_compact(idle)}"
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
# Prefer the agent's model name — it updates on fallback.
# self.model reflects the originally configured model and never
@@ -3835,6 +3849,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
getattr(self, "_prompt_duration", 0.0),
live=getattr(self, "_prompt_start_time", None) is not None,
),
"idle_since": self._format_idle_since(
getattr(self, "_last_turn_finished_at", None),
turn_live=getattr(self, "_prompt_start_time", None) is not None,
),
"context_tokens": 0,
"context_length": None,
"context_percent": None,
@@ -4146,6 +4164,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
prompt_elapsed = snapshot.get("prompt_elapsed")
if prompt_elapsed:
parts.append(prompt_elapsed)
idle_since = snapshot.get("idle_since")
if idle_since:
parts.append(idle_since)
if yolo_active:
parts.append("⚠ YOLO")
return self._trim_status_bar_text("".join(parts), width)
@@ -4247,6 +4268,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if prompt_elapsed:
frags.append(("class:status-bar-dim", ""))
frags.append(("class:status-bar-dim", prompt_elapsed))
# Position 8: idle time since the last final agent response
idle_since = snapshot.get("idle_since")
if idle_since:
frags.append(("class:status-bar-dim", ""))
frags.append(("class:status-bar-dim", idle_since))
if yolo_active:
frags.append(("class:status-bar-dim", ""))
frags.append(("class:status-bar-yolo", "⚠ YOLO"))
@@ -5552,6 +5578,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
f"{_escape(desc)} [dim]({skill_count} skills)[/]"
)
quick_commands = self.config.get("quick_commands", {})
if quick_commands:
_cprint(f"\n{_BOLD}Quick Commands{_RST} ({len(quick_commands)} configured):")
for name, qcmd in sorted(quick_commands.items()):
desc = qcmd.get("description", qcmd.get("type", ""))
ChatConsole().print(
f" [bold {_accent_hex()}]{('/' + name):<22}[/] [dim]-[/] {_escape(desc)}"
)
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
@@ -5821,6 +5856,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
except Exception:
pass
def _discard_session_if_empty(self, session_id: Optional[str]) -> bool:
"""Drop a just-ended session row when it never gained content.
Starting the CLI and immediately quitting (or rotating with /new,
/clear) used to leave an empty untitled row behind that clutters
``/resume`` and ``hermes sessions list``. Delegates the
check-and-delete to ``SessionDB.delete_session_if_empty``, which
only removes rows with no messages, no title, and no child
sessions. Ported from google-gemini/gemini-cli#27770.
"""
if not self._session_db or not session_id:
return False
# In-memory transcript is authoritative: if this CLI object holds
# conversation messages (flushed to the DB or not), the session is
# not empty. Protects against pruning a real conversation whose DB
# flush failed or hasn't happened yet.
if getattr(self, "conversation_history", None):
return False
try:
from hermes_constants import get_hermes_home as _ghh
return self._session_db.delete_session_if_empty(
session_id, sessions_dir=_ghh() / "sessions"
)
except Exception:
logger.debug(
"Could not prune empty session %s", session_id, exc_info=True
)
return False
def new_session(self, silent=False, title=None):
"""Start a fresh session with a new session ID and cleared agent state."""
if self.agent and self.conversation_history:
@@ -5837,6 +5901,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._session_db.end_session(old_session_id, "new_session")
except Exception:
pass
# Don't let immediately-rotated empty sessions pile up in
# /resume and `hermes sessions list` (gemini-cli#27770 port).
self._discard_session_if_empty(old_session_id)
self.session_start = datetime.now()
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
@@ -10121,6 +10188,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if self._prompt_start_time is not None:
self._prompt_duration = max(0.0, time.time() - self._prompt_start_time)
self._prompt_start_time = None
# Record when this agent loop finished so the status bar can show
# idle time since the last final response.
self._last_turn_finished_at = time.time()
# Proactively clean up async clients whose event loop is dead.
# The agent thread may have created AsyncOpenAI clients bound
@@ -13074,6 +13144,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._session_db.end_session(self.agent.session_id, "cli_close")
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not close session in DB: %s", e)
# Started-and-immediately-quit sessions never gained content;
# drop the empty row so /resume and `hermes sessions list`
# stay clean (gemini-cli#27770 port). No-op for resumed or
# titled sessions and anything with messages or children.
if not getattr(self, '_delete_session_on_exit', False):
try:
self._discard_session_if_empty(self.agent.session_id)
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not prune empty session: %s", e)
# /exit --delete: also remove the current session's transcripts
# and SQLite history. Ported from google-gemini/gemini-cli#19332.
if getattr(self, '_delete_session_on_exit', False):
@@ -13336,9 +13415,21 @@ def main(
else:
toolsets_list.append(str(t))
else:
# Use the shared resolver so MCP servers are included at runtime
from hermes_cli.tools_config import _get_platform_tools
toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
# Coding posture (base Hermes): with no explicit --toolsets, collapse
# to the coding toolset (+ enabled MCP servers) when sitting in a code
# workspace. See agent/coding_context.py.
_coding = None
try:
from agent.coding_context import coding_selection
_coding = coding_selection(platform="cli", config=CLI_CONFIG)
except Exception:
_coding = None
if _coding is not None:
toolsets_list = _coding
else:
# Use the shared resolver so MCP servers are included at runtime
from hermes_cli.tools_config import _get_platform_tools
toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
parsed_skills = _parse_skills_argument(skills)
-44
View File
@@ -150,9 +150,6 @@ def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]:
state = "scheduled" if normalized.get("enabled", True) else "paused"
normalized["state"] = state
profile = _coerce_job_text(normalized.get("profile")).strip()
normalized["profile"] = profile or None
return normalized
@@ -523,30 +520,6 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:
return str(resolved)
def _normalize_profile(profile: Optional[str]) -> Optional[str]:
"""Normalize and validate an optional cron job profile name.
Empty / None disables per-job profile selection. Otherwise the profile name
is canonicalized with the same rules as ``hermes -p`` and must refer to an
existing profile at create/update time. ``default`` is the built-in root
profile and is always valid.
"""
if profile is None:
return None
raw = str(profile).strip()
if not raw:
return None
from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
normalized = normalize_profile_name(raw)
# resolve_profile_env validates the canonical name and checks that named
# profiles exist. Store only the stable profile id, not the filesystem path,
# so profile directories can move with the Hermes root.
resolve_profile_env(normalized)
return normalized
def create_job(
prompt: Optional[str],
schedule: str,
@@ -563,7 +536,6 @@ def create_job(
context_from: Optional[Union[str, List[str]]] = None,
enabled_toolsets: Optional[List[str]] = None,
workdir: Optional[str] = None,
profile: Optional[str] = None,
no_agent: bool = False,
) -> Dict[str, Any]:
"""
@@ -605,11 +577,6 @@ def create_job(
With ``no_agent=True``, ``workdir`` is still applied as the
script's cwd so relative paths inside the script behave
predictably.
profile: Optional Hermes profile name. When set, the job runs with
that profile's HERMES_HOME so profile-specific config,
credentials, scripts, skills, and memory paths resolve
consistently. ``default`` selects the root profile; empty /
None preserves the scheduler's existing behaviour.
no_agent: When True, skip the agent entirely run ``script`` on schedule
and deliver its stdout directly. Empty stdout = silent (no
delivery). Requires ``script`` to be set. Ideal for classic
@@ -647,7 +614,6 @@ def create_job(
normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None
normalized_toolsets = normalized_toolsets or None
normalized_workdir = _normalize_workdir(workdir)
normalized_profile = _normalize_profile(profile)
normalized_no_agent = bool(no_agent)
# no_agent jobs are meaningless without a script — the script IS the job.
@@ -702,7 +668,6 @@ def create_job(
"origin": origin, # Tracks where job was created for "origin" delivery
"enabled_toolsets": normalized_toolsets,
"workdir": normalized_workdir,
"profile": normalized_profile,
}
jobs = load_jobs()
@@ -792,15 +757,6 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
else:
updates["workdir"] = _normalize_workdir(_wd)
# Validate / normalize profile if present in updates. Empty string or
# None both mean "clear the field" (restore old behaviour).
if "profile" in updates:
_profile = updates["profile"]
if _profile is None or _profile == "" or _profile is False:
updates["profile"] = None
else:
updates["profile"] = _normalize_profile(_profile)
updated = _apply_skill_fields({**job, **updates})
schedule_changed = "schedule" in updates
+14 -109
View File
@@ -19,7 +19,6 @@ import shutil
import subprocess
import sys
import threading
from contextlib import contextmanager
# fcntl is Unix-only; on Windows use msvcrt for file locking
try:
@@ -166,7 +165,7 @@ _parallel_pool_max_workers: Optional[int] = None
_running_job_ids: set = set()
_running_lock = threading.Lock()
# Sequential (env/context-mutating) cron jobs — workdir/profile jobs that touch
# Sequential (env-mutating) cron jobs — workdir jobs that touch
# process-global runtime state — must run one at a time, but must NOT block the
# ticker thread. A persistent single-thread executor preserves ordering across
# ticks while keeping dispatch fire-and-forget, the same as the parallel pool.
@@ -190,10 +189,10 @@ def _get_parallel_pool(max_workers: Optional[int]) -> concurrent.futures.ThreadP
def _get_sequential_pool() -> concurrent.futures.ThreadPoolExecutor:
"""Return (or create) the persistent single-thread sequential pool.
A single worker guarantees env/context-mutating jobs never overlap, even
A single worker guarantees env-mutating jobs never overlap, even
across ticks: a job queued by a newer tick waits for the previous tick's
sequential jobs to finish rather than corrupting their os.environ /
profile state.
sequential jobs to finish rather than corrupting their os.environ
state.
"""
global _sequential_pool
if _sequential_pool is None:
@@ -235,71 +234,6 @@ def _get_lock_paths() -> tuple[Path, Path]:
return lock_dir, lock_dir / ".tick.lock"
@contextmanager
def _job_profile_context(job_id: str, profile: Optional[str]):
"""Temporarily run a job under a specific Hermes profile.
Cron jobs are stored and scheduled by the profile running the scheduler, but
an individual job can opt into a different runtime profile. While active,
the scheduler's test/override hook and a context-local Hermes home override
both point at the resolved profile directory so _get_hermes_home(),
.env/config loading, script resolution, AIAgent construction, and downstream
get_hermes_home() callers agree on the same home.
Some existing provider/config paths still load profile .env values through
os.environ, so profile jobs also snapshot and restore the process
environment on exit. tick() runs profile jobs sequentially to keep that
temporary mutation isolated from other scheduled jobs.
"""
raw_profile = str(profile or "").strip()
if not raw_profile:
yield None
return
global _hermes_home
prior_override = _hermes_home
env_snapshot = os.environ.copy()
from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
normalized_profile = normalize_profile_name(raw_profile)
try:
profile_home = Path(resolve_profile_env(normalized_profile)).resolve()
except (FileNotFoundError, ValueError) as exc:
logger.warning(
"Job '%s': configured profile %r no longer valid (%s) — "
"falling back to scheduler default",
job_id, raw_profile, exc,
)
yield None
return
override_token = None
try:
override_token = set_hermes_home_override(profile_home)
_hermes_home = profile_home
logger.info(
"Job '%s': using Hermes profile '%s' (%s)",
job_id,
normalized_profile,
profile_home,
)
yield normalized_profile
finally:
_hermes_home = prior_override
if override_token is not None:
reset_hermes_home_override(override_token)
# Delta-based restore: remove added keys, restore changed keys.
# Avoids a brief window where other threads see an empty env.
added = set(os.environ.keys()) - set(env_snapshot.keys())
for k in added:
os.environ.pop(k, None)
for k, v in env_snapshot.items():
if os.environ.get(k) != v:
os.environ[k] = v
def _resolve_origin(job: dict) -> Optional[dict]:
"""Extract origin info from a job, preserving any extra routing metadata.
@@ -1032,17 +966,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
else:
argv = [sys.executable, str(path)]
run_env = os.environ.copy()
run_env["HERMES_HOME"] = str(_get_hermes_home())
try:
from hermes_constants import get_subprocess_home
profile_home = get_subprocess_home()
if profile_home:
run_env["HOME"] = profile_home
except Exception:
pass
try:
popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {}
result = subprocess.run(
@@ -1051,7 +974,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
text=True,
timeout=script_timeout,
cwd=str(path.parent),
env=run_env,
**popen_kwargs,
)
stdout = (result.stdout or "").strip()
@@ -1381,13 +1303,6 @@ def _scan_assembled_cron_prompt(
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
"""Execute a single cron job, applying any per-job profile override."""
job_id = job["id"]
with _job_profile_context(job_id, job.get("profile")):
return _run_job_impl(job)
def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
"""
Execute a single cron job.
@@ -1624,9 +1539,8 @@ def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
# .cursorrules from the job's project dir, AND
# - the terminal, file, and code-exec tools run commands from there.
#
# tick() serializes jobs that mutate process-global runtime state (workdir
# and/or profile jobs) outside the parallel pool, so mutating
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
# tick() serializes workdir-jobs outside the parallel pool, so mutating
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
# jobs we leave TERMINAL_CWD untouched — preserves the original behaviour
# (skip_context_files=True, tools use whatever cwd the scheduler has).
_job_workdir = (job.get("workdir") or "").strip() or None
@@ -2173,21 +2087,12 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i
mark_job_run(job["id"], False, str(e))
return False
# Partition due jobs: jobs with a per-job workdir and/or profile touch
# process-global runtime state inside run_job. Workdir jobs temporarily
# set os.environ["TERMINAL_CWD"]; profile jobs use a context-local
# Hermes home override, scheduler _hermes_home hook, and temporary
# profile .env load into os.environ with snapshot/restore. They MUST run
# sequentially to avoid corrupting each other. Jobs without either field
# stay parallel-safe.
sequential_jobs = [
j for j in due_jobs
if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip()
]
parallel_jobs = [
j for j in due_jobs
if not ((j.get("workdir") or "").strip() or (j.get("profile") or "").strip())
]
# Partition due jobs: those with a per-job workdir mutate
# os.environ["TERMINAL_CWD"] inside run_job, which is process-global —
# so they MUST run sequentially to avoid corrupting each other. Jobs
# without a workdir leave env untouched and stay parallel-safe.
sequential_jobs = [j for j in due_jobs if (j.get("workdir") or "").strip()]
parallel_jobs = [j for j in due_jobs if not (j.get("workdir") or "").strip()]
_results: list = []
_all_futures: list = []
@@ -2216,9 +2121,9 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i
return pool.submit(_run_and_release)
# Sequential pass for env/context-mutating (workdir/profile) jobs.
# Sequential pass for env-mutating (workdir) jobs.
# Queued to a persistent single-thread pool so they run one at a time
# WITHOUT blocking the ticker thread — a long workdir/profile job no
# WITHOUT blocking the ticker thread — a long workdir job no
# longer starves the rest of the schedule (same fix as the parallel
# pass, just serialized). The in-flight guard prevents a still-running
# job from being re-queued on the next tick.
+144
View File
@@ -0,0 +1,144 @@
# Profile Builder — Dashboard-Native, Full-Featured Profile Creation
Status: design proposal (not yet implemented)
Author: drafted for Teknium
Supersedes: PR #31781 (prompt_toolkit `hermes profile wizard`)
## Why this, not the CLI wizard
PR #31781 added a keyboard-driven `hermes profile wizard` in the terminal.
The decision is to **not** build the profile-creation experience in the CLI.
The dashboard already owns mature, separate pages for every element a profile
needs, and a profile is just a HERMES_HOME directory — so the dashboard is the
right home for a full-featured builder, and it can reuse everything that
already exists.
A profile = a full `~/.hermes/profiles/<name>/` directory with its own:
- `config.yaml` — holds `model`/`provider`, `mcp_servers`, enabled skills
- `skills/` — physical SKILL.md files (built-in seed + optional + hub installs)
- `.env` — secrets
- `SOUL.md` / `USER.md` — identity
So per-profile scoping of Model, MCPs, and Skills is **native** — no data-model
change needed. The gap is purely UX: creation today is a thin modal
(name + clone + model + description), and you can only compose skills/MCPs
*after* the profile exists, by visiting other pages and remembering to scope
them.
## What already exists (reuse, don't rebuild)
| Element | Existing page | Existing API | Profile-scopable? |
|---|---|---|---|
| Name / Description | ProfilesPage create modal | `POST /api/profiles` (`create_profile`) | yes (args) |
| Model + Provider | ModelsPage | `_write_profile_model(profile_dir, …)` | yes — HERMES_HOME override, already wired into create endpoint |
| MCPs | McpPage | `mcp_config._save_mcp_server` + `/api/mcp/catalog` | yes — wrap with HERMES_HOME override |
| Skills (built-in/optional) | SkillsPage | `GET /api/skills`, `/api/skills/toggle` | yes — config write |
| Skills (hub) | SkillsPage | `/api/skills/hub/search`, `/api/skills/hub/install` | **only via subprocess** — see seam #1 |
## Two architectural seams found while grounding this design
These are load-bearing — they change the implementation, not just the polish.
### Seam #1 — hub-skill install cannot use the HERMES_HOME override
`tools/skills_hub.py` binds `SKILLS_DIR = HERMES_HOME / "skills"` at **module
import time**. The context-local `set_hermes_home_override()` swap (which makes
`_write_profile_model` and the MCP write land in the target profile) does NOT
retroactively rebind that already-imported module global. So a data-layer wrap
of hub install would write into the dashboard's *own* active profile, not the
new one.
The correct mechanism is the existing subprocess path: `_spawn_hermes_action`
runs `python -m hermes_cli.main <subcommand>`, and `_apply_profile_override()`
re-reads `sys.argv` at import in the fresh child. Prepend `-p <profile>`:
```python
_spawn_hermes_action(["-p", profile, "skills", "install", identifier], "skills-install")
```
A fresh subprocess re-imports `skills_hub` with the profile's HERMES_HOME bound
from the start, so `SKILLS_DIR` resolves to `<profile>/skills/`. Correct by
construction.
### Seam #2 — hub installs are async, so create cannot be fully atomic
Built-in/optional skill enabling and MCP writes are **synchronous config ops**
and can be part of the create call. Hub installs are long-running git fetches
spawned detached (`_spawn_hermes_action` returns a PID immediately). So the
create flow is:
1. `create_profile()` — make the dir (synchronous)
2. write model (synchronous, HERMES_HOME override)
3. write selected MCP servers (synchronous, HERMES_HOME override)
4. seed/enable selected built-in + optional skills (synchronous)
5. spawn `hermes -p <profile> skills install <id>` per hub skill (async, returns PIDs)
Steps 14 commit before the response; step 5 returns a list of action PIDs the
UI polls (same pattern as today's SkillsPage hub install). The builder's
"Review → Create" returns `{ok, name, path, hub_installs: [{id, pid}]}` and the
final screen shows live install progress for the hub skills.
## Proposed backend change (small, follows existing patterns)
Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite:
```python
class ProfileCreate(BaseModel):
name: str
clone_from_default: bool = False
clone_all: bool = False
no_skills: bool = False
description: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
# NEW — all optional, all best-effort post-create (profile already exists)
mcp_servers: List[MCPServerCreate] = [] # synchronous, HERMES_HOME override
builtin_skills: List[str] = [] # synchronous enable/seed
hub_skills: List[str] = [] # async spawn, returns PIDs
```
The endpoint already does best-effort post-create steps (`seed_profile_skills`,
`_write_profile_model`). Add two more best-effort blocks (MCP write, hub-skill
spawn) in the same style — a failure in any of them must not 500 the create,
since the profile dir already exists and the user can fix it from the relevant
page afterward. Mirror `_write_profile_model`'s HERMES_HOME-override helper for
the MCP write (`_write_profile_mcp_servers(profile_dir, servers)`).
## Proposed frontend — dedicated builder page `/profiles/new`
A full page (not the cramped modal), stepped, each step reusing the existing
page's component + API, targeted at the new profile:
```
① Identity Name + Description (+ optional clone-from existing profile)
② Model Provider + model picker (reuse ModelsPage picker)
③ Skills Tabs: Built-in · Optional · Hub-search
multi-select; "Start from default bundle" preset button
④ MCPs Tabs: Catalog browse · Manual add (reuse McpPage form)
⑤ Review Blueprint preview → Create
→ progress screen for async hub installs
```
Nothing writes to disk until ⑤.
## Open product decisions (need Teknium)
1. **Skills seeding default.** Fresh profiles auto-seed the default bundle
today. In the builder, should the skill step **replace** the bundle (pick
exactly what you want; offer a "start from default bundle" preset) or
**augment** it? Recommendation: replace + preset button.
2. **Page vs richer modal.** Dedicated `/profiles/new` page (room to grow:
SOUL editing, multi-agent fleets later) vs a bigger create modal on
ProfilesPage. Recommendation: dedicated page — matches "full-featured / way
more options."
## Verification plan (when built)
- Backend E2E with isolated HERMES_HOME: POST a full create body
(name + model + 2 MCPs + 3 builtin skills + 1 hub skill), assert the new
profile dir has the model in config.yaml, both MCP servers in config.yaml,
the builtin skills enabled, and a spawned PID for the hub skill. Negative:
a bad MCP entry must not 500 the create.
- `cd web && npm run build` (no JS test suite in web/).
- Targeted: `pytest tests/<web_server profile tests> -k profile_create`.
@@ -0,0 +1,240 @@
---
title: "fix: Prevent Telegram streamed replies from ending after first overflow chunk"
status: active
date: 2026-06-09
type: fix
target_repo: hermes-agent
origin: user-reported Telegram topic screenshot
---
# fix: Prevent Telegram streamed replies from ending after first overflow chunk
## Summary
Fix a Telegram gateway bug where a long streamed assistant reply can appear to stop mid-answer in a topic after the first overflow chunk. The reported screenshot shows a long Hermes response in the `Nehemiah - Coding` Telegram topic ending at `- The visible tool-call summary`, followed by the user noting that the previous message did not finish streaming to that Telegram topic.
The plan targets the streamed edit overflow path, not general model generation. A completed assistant response must either reach Telegram in full across all continuation messages or leave enough state for the gateway fallback path to deliver the remaining content instead of marking the turn complete after a partial delivery.
---
## Problem Frame
Telegram limits message text to 4096 UTF-16 code units. Hermes streams gateway responses by editing a message and, when a streamed message grows past the limit, splitting the overflow into additional Telegram messages. The adapter already has a split-and-deliver path for oversized edits, but the partial-continuation failure contract is weak: if chunk 1 is edited successfully and a later continuation fails, the adapter can still report success for the operation. The stream consumer may then mark the final response delivered even though the visible topic only contains the first part.
This is especially visible in Telegram forum topics because a long final response can be split below tool-progress bubbles, and a missing continuation looks exactly like the stream stopped mid-answer.
---
## Requirements
- R1. Long streamed Telegram replies must preserve all final content across overflow chunks.
- R2. If any continuation chunk fails after the first overflow edit lands, the gateway must not mark the final response as fully delivered.
- R3. Continuation chunks must remain routed to the same Telegram topic/thread as the original response.
- R4. The fix must avoid duplicate full-answer sends when all overflow chunks were delivered successfully.
- R5. Tests must cover the reported failure shape: a final streamed reply that exceeds Telegram's limit, succeeds on the first edit, fails on a continuation, and must not be treated as complete.
---
## Key Technical Decisions
- Treat overflow delivery as all-or-not-complete. `_edit_overflow_split` should only return a successful final-delivery result when every planned chunk reaches Telegram. Partial delivery is a distinct outcome that downstream code can recover from.
- Carry partial-overflow metadata through `SendResult.raw_response` rather than adding a new public dataclass field unless implementation proves the existing result shape is insufficient. The stream consumer already inspects `SendResult` after adapter edits, so a small raw response contract can keep the change contained.
- Make the stream consumer responsible for final-delivery truth. The adapter knows which chunks landed, but the consumer owns `_final_response_sent`, `_final_content_delivered`, `_fallback_prefix`, and fallback final-send behaviour.
- Keep routing inside Telegram adapter helpers. Continuation sends should continue to use `_thread_kwargs_for_send(...)` with metadata-derived `message_thread_id` and reply anchors so forum topic behaviour stays consistent.
---
## High-Level Technical Design
```mermaid
sequenceDiagram
participant C as GatewayStreamConsumer
participant T as TelegramAdapter.edit_message
participant B as Telegram Bot API
C->>T: finalize/edit long accumulated response
T->>B: edit original message with chunk 1
loop remaining chunks
T->>B: send continuation in same topic/thread
end
alt all chunks delivered
T-->>C: success, last message id, continuation ids
C->>C: mark final response delivered
else any continuation failed
T-->>C: partial overflow failure with delivered prefix metadata
C->>C: do not mark final delivered
C->>B: fallback sends missing tail or full final response safely
end
```
---
## Implementation Units
### U1. Add a partial-overflow contract for Telegram edit splits
**Goal:** Make `TelegramAdapter._edit_overflow_split` distinguish complete overflow delivery from partial delivery.
**Requirements:** R1, R2, R4
**Dependencies:** None
**Files:**
- `gateway/platforms/telegram.py`
- `tests/gateway/test_telegram_send.py` or the existing Telegram adapter test module that already covers `edit_message` overflow behaviour
**Approach:**
- Keep the successful path unchanged when every chunk is delivered: return `SendResult(success=True, message_id=<last chunk>, continuation_message_ids=(...))`.
- When a continuation fails after the first edit, return a result that clearly indicates partial delivery instead of plain success. Prefer `success=False`, `retryable=True`, and `raw_response` metadata such as delivered chunk count, total chunk count, last delivered message id, and the visible delivered prefix.
- Preserve logging, but do not rely on logs as the only signal. The caller must be able to tell partial delivery happened.
- Ensure the first edited chunk and all successful continuation chunks still include the existing Markdown/plain-text fallback behaviour.
**Patterns to follow:**
- Existing overflow handling in `TelegramAdapter.edit_message` and `_edit_overflow_split`.
- Existing `SendResult` semantics in `gateway/platforms/base.py`, especially `retryable`, `raw_response`, and `continuation_message_ids`.
**Test scenarios:**
- Oversized finalized edit where all continuations succeed returns success, the last continuation id, and all continuation ids.
- Oversized finalized edit where the first continuation send fails returns a partial-overflow failure and does not report success.
- Oversized finalized edit where one continuation succeeds and a later continuation fails reports the last delivered continuation id and delivered count in raw metadata.
- A continuation MarkdownV2 formatting failure still retries plain text before being treated as a delivery failure.
**Verification:** Adapter tests prove complete overflow remains successful and partial overflow is observable by the caller.
### U2. Teach the stream consumer to recover from partial overflow
**Goal:** Ensure a partial Telegram overflow does not set `_final_response_sent` or `_final_content_delivered` unless the full response reached the user.
**Requirements:** R1, R2, R4, R5
**Dependencies:** U1
**Files:**
- `gateway/stream_consumer.py`
- `tests/gateway/test_stream_consumer.py` or a focused new `tests/gateway/test_stream_consumer_telegram_overflow.py`
**Approach:**
- In `_send_or_edit`, when `adapter.edit_message(...)` returns a partial-overflow failure, update consumer state to reflect the last visible prefix/message and enter fallback delivery for the missing content.
- Avoid treating `_already_sent` as final delivery. A partial visible message can be true while final delivery is false.
- Use the delivered-prefix metadata if available so `_send_fallback_final(...)` sends only the missing tail. If implementation finds the prefix is unreliable after Markdown formatting, prefer sending the complete final response as a fresh fallback message rather than silently dropping the tail.
- Keep the existing success handling for `continuation_message_ids` when the adapter delivered all chunks.
**Patterns to follow:**
- Existing fallback mode in `GatewayStreamConsumer._send_or_edit` and `_send_fallback_final`.
- Existing comments around `_final_response_sent`, `_final_content_delivered`, and `_fallback_prefix` for prior partial-delivery regressions.
**Test scenarios:**
- A final streamed response that overflows and receives a complete-success edit split sets final-delivery flags and does not invoke fallback.
- A final streamed response whose adapter reports partial overflow does not set final-delivery flags immediately.
- After partial overflow, fallback delivery sends the remaining tail and then marks final content delivered only if the fallback send succeeds.
- If fallback delivery also fails, the consumer leaves final-delivery false so the gateway's non-streaming final-send safety path can still run.
**Verification:** Stream consumer tests reproduce the screenshot shape by simulating first chunk visible and continuation failure, then assert the final answer is not suppressed.
### U3. Preserve Telegram topic/thread routing for overflow and fallback continuations
**Goal:** Ensure overflow recovery messages land in the same Telegram forum topic or DM topic fallback context.
**Requirements:** R3
**Dependencies:** U1, U2
**Files:**
- `gateway/platforms/telegram.py`
- `gateway/stream_consumer.py`
- `tests/gateway/test_stream_consumer_thread_routing.py`
- Relevant Telegram adapter routing tests, if existing coverage is closer there
**Approach:**
- Keep passing `metadata` through every overflow continuation and fallback send.
- Keep reply anchors where valid, but do not let a missing reply anchor drop the `message_thread_id` for normal forum topics.
- For private DM topic fallback metadata, preserve the existing stricter anchor behaviour documented in the adapter comments.
**Patterns to follow:**
- `TelegramAdapter._thread_kwargs_for_send(...)`.
- Existing tests around Telegram topic recovery and stream consumer thread routing.
**Test scenarios:**
- Overflow continuations include `message_thread_id` for a forum topic.
- A continuation retry after `reply message not found` keeps forum topic routing when allowed.
- Partial-overflow fallback sends receive the same metadata passed to the original stream consumer.
**Verification:** Thread-routing assertions inspect fake bot calls and confirm all continuation/fallback messages carry the expected topic metadata.
### U4. Add issue evidence and PR body traceability
**Goal:** Make the upstream issue and PR clearly trace the user-visible bug and verification evidence.
**Requirements:** R5
**Dependencies:** U1, U2, U3
**Files:**
- GitHub issue body created via `gh issue create`
- PR body using `.github/PULL_REQUEST_TEMPLATE.md`
**Approach:**
- Create a GitHub issue with the screenshot evidence: the long message in the `Nehemiah - Coding` Telegram topic stops at `- The visible tool-call summary`, and the user's reply says the previous message did not finish streaming to that Telegram topic.
- Reference affected component as Gateway and platform as Telegram.
- In the PR body, link the issue with `Fixes #...`, describe the split-delivery contract change, and include the screenshot or attach it if GitHub upload is available.
- Follow `CONTRIBUTING.md` and the repository PR template exactly.
**Patterns to follow:**
- `.github/ISSUE_TEMPLATE/bug_report.yml`
- `.github/PULL_REQUEST_TEMPLATE.md`
**Test scenarios:**
- Test expectation: none, this is tracker and PR documentation work.
**Verification:** The GitHub issue exists with screenshot evidence or an explicit screenshot reference, and the PR body links the issue and lists the tests run.
---
## Scope Boundaries
### In Scope
- Telegram streamed response overflow splitting and recovery.
- Stream consumer final-delivery truth for partial overflow delivery.
- Topic/thread metadata preservation for overflow and fallback continuation sends.
- Focused unit tests around adapter and stream consumer behaviour.
### Out of Scope
- Changing model streaming semantics in `run_agent.py`.
- Reworking Telegram draft streaming, which is DM-only and not the forum-topic path in the screenshot.
- Changing general platform message splitting for Discord, Slack, WhatsApp, or Matrix unless a shared helper must be corrected for the Telegram fix.
- Altering tool-progress display settings or terminal progress rendering.
### Deferred to Follow-Up Work
- Broader observability for gateway delivery completeness across all messaging platforms.
- A user-facing resend/recover command for a previous truncated response.
---
## Risks & Mitigations
- Risk: fallback recovery duplicates already-visible first chunks. Mitigation: use delivered-prefix metadata where reliable and add tests for no-duplicate complete-success behaviour.
- Risk: preserving forum topic routing while dropping invalid reply anchors is easy to regress. Mitigation: include fake bot call assertions for `message_thread_id` and reply behaviour.
- Risk: MarkdownV2 formatting can alter visible/raw prefix comparisons. Mitigation: keep fallback conservative; duplicate content is preferable to silently missing content, but tests should keep the common path tail-only.
---
## Sources & Research
- User-provided screenshot at `/root/.hermes/image_cache/img_f664e68f6ddf.jpg`.
- `gateway/stream_consumer.py` streamed edit, overflow, fallback, and final-delivery state handling.
- `gateway/platforms/telegram.py` Telegram send/edit overflow splitting and topic routing helpers.
- `gateway/platforms/base.py` `SendResult` contract and shared message chunking helper.
- `tests/gateway/test_stream_consumer.py`, `tests/gateway/test_stream_consumer_thread_routing.py`, and Telegram adapter tests for focused regression coverage.
---
## Verification Strategy
- Run focused Telegram adapter overflow tests.
- Run focused stream consumer overflow/fallback tests.
- Run topic-routing tests affected by metadata changes.
- Run the gateway test subset around Telegram send/edit, stream consumer, and run progress if touched.
- Before PR creation, ensure `git diff` contains only the plan, implementation, tests, and PR/issue-relevant documentation for this bug.
+26 -7
View File
@@ -1218,17 +1218,30 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(matrix_cfg, dict):
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
allowed_users = matrix_cfg.get("allowed_users")
if allowed_users is not None and not os.getenv("MATRIX_ALLOWED_USERS"):
if isinstance(allowed_users, list):
allowed_users = ",".join(str(v) for v in allowed_users)
os.environ["MATRIX_ALLOWED_USERS"] = str(allowed_users)
allowed_rooms = matrix_cfg.get("allowed_rooms")
if allowed_rooms is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
if isinstance(allowed_rooms, list):
allowed_rooms = ",".join(str(v) for v in allowed_rooms)
os.environ["MATRIX_ALLOWED_ROOMS"] = str(allowed_rooms)
frc = matrix_cfg.get("free_response_rooms")
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
# allowed_rooms: if set, bot ONLY responds in these rooms (whitelist)
ar = matrix_cfg.get("allowed_rooms")
if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
if isinstance(ar, list):
ar = ",".join(str(v) for v in ar)
os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar)
ignore_patterns = matrix_cfg.get("ignore_user_patterns")
if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"):
if isinstance(ignore_patterns, list):
ignore_patterns = ",".join(str(v) for v in ignore_patterns)
os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns)
if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"):
os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower()
if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"):
os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower()
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
@@ -1497,8 +1510,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
matrix_password = os.getenv("MATRIX_PASSWORD", "")
if matrix_password:
matrix_config.extra["password"] = matrix_password
matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"}
matrix_e2ee_mode = os.getenv("MATRIX_E2EE_MODE", "").strip().lower()
matrix_e2ee = (
matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred")
or os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
)
matrix_config.extra["encryption"] = matrix_e2ee
if matrix_e2ee_mode:
matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode
matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "")
if matrix_device_id:
matrix_config.extra["device_id"] = matrix_device_id
+7
View File
@@ -1545,6 +1545,13 @@ class SendResult:
message_id: Optional[str] = None
error: Optional[str] = None
raw_response: Any = None
# Adapter-specific metadata. Cross-layer contracts that affect delivery
# semantics must be documented at the producer and consumer sites. Current
# known contract: Telegram edit overflow partials set
# raw_response["partial_overflow"] with delivered_chunks, total_chunks,
# last_message_id, delivered_prefix, and continuation_message_ids so the
# stream consumer can send the missing tail instead of marking a clipped
# response complete.
retryable: bool = False # True for transient connection errors — base will retry automatically
# When the adapter had to split an oversized payload across multiple
# platform messages (e.g. Telegram edit_message overflow split-and-deliver),
+1404 -286
View File
File diff suppressed because it is too large Load Diff
+80 -8
View File
@@ -2348,10 +2348,15 @@ class TelegramAdapter(BasePlatformAdapter):
)
except Exception as fmt_err:
if "not modified" not in str(fmt_err).lower():
logger.warning(
"[%s] Overflow split: MarkdownV2 first-chunk edit "
"failed, falling back to plain text: %s",
self.name, fmt_err,
)
await self._bot.edit_message_text(
chat_id=int(chat_id),
message_id=int(message_id),
text=first_chunk,
text=_strip_mdv2(first_chunk),
)
else:
await self._bot.edit_message_text(
@@ -2379,6 +2384,7 @@ class TelegramAdapter(BasePlatformAdapter):
# are already correctly sized). Best-effort MarkdownV2 with plain
# fallback, mirroring send().
continuation_ids: list[str] = []
delivered_chunks = [first_chunk]
prev_id = message_id
thread_id = self._metadata_thread_id(metadata)
for chunk in chunks[1:]:
@@ -2392,7 +2398,14 @@ class TelegramAdapter(BasePlatformAdapter):
)
for use_markdown in (True, False) if finalize else (False,):
try:
text = self.format_message(chunk) if use_markdown else chunk
if use_markdown:
text = self.format_message(chunk)
else:
# Plain attempt: on finalize the MarkdownV2 attempt
# failed, so degrade to clean stripped text, never
# the raw chunk (raw ** / ``` markers would render
# literally); streaming previews stay raw.
text = _strip_mdv2(chunk) if finalize else chunk
sent_msg = await self._bot.send_message(
chat_id=int(chat_id),
text=text,
@@ -2418,7 +2431,7 @@ class TelegramAdapter(BasePlatformAdapter):
try:
sent_msg = await self._bot.send_message(
chat_id=int(chat_id),
text=chunk,
text=_strip_mdv2(chunk) if finalize else chunk,
**retry_thread_kwargs,
**self._link_preview_kwargs(),
**self._notification_kwargs(metadata),
@@ -2442,17 +2455,37 @@ class TelegramAdapter(BasePlatformAdapter):
break
if sent_msg is None:
# Continuation failed — the user has chunk 1 + however many
# continuations succeeded. Report success with what we got
# so the stream consumer knows the edit landed; the
# remaining tail is lost on this attempt and the next
# streaming tick may retry.
# continuations succeeded, but NOT the full response. Do not
# report success: the stream consumer treats a successful edit
# as final delivery on got_done, which would suppress fallback
# delivery and leave the Telegram topic clipped after the last
# delivered chunk.
logger.warning(
"[%s] Overflow split: stopped at %d/%d chunks delivered",
self.name, 1 + len(continuation_ids), len(chunks),
)
break
delivered_prefix = "".join(
re.sub(r" \(\d+/\d+\)$", "", delivered)
for delivered in delivered_chunks
)
return SendResult(
success=False,
message_id=prev_id,
error="overflow_continuation_failed",
retryable=True,
raw_response={
"partial_overflow": True,
"delivered_chunks": 1 + len(continuation_ids),
"total_chunks": len(chunks),
"last_message_id": prev_id,
"delivered_prefix": delivered_prefix,
"continuation_message_ids": tuple(continuation_ids),
},
continuation_message_ids=tuple(continuation_ids),
)
new_id = str(getattr(sent_msg, "message_id", "")) or prev_id
continuation_ids.append(new_id)
delivered_chunks.append(chunk)
prev_id = new_id
last_id = continuation_ids[-1] if continuation_ids else message_id
@@ -3804,6 +3837,33 @@ class TelegramAdapter(BasePlatformAdapter):
)
return error
def _telegram_media_too_large_note(self, label: str, file_size: Any, max_bytes: int) -> str:
limit_mb = max(1, max_bytes // (1024 * 1024))
try:
size_mb = int(file_size or 0) / (1024 * 1024)
size_text = f"{size_mb:.1f} MB"
except (TypeError, ValueError):
size_text = "unknown size"
return (
f"[Telegram {label} skipped: file size {size_text} exceeds the "
f"{limit_mb} MB limit. Ask the user to send a shorter voice note "
"or a smaller audio file.]"
)
def _telegram_media_size_allowed(self, source: Any, label: str) -> tuple[bool, Optional[str]]:
"""Validate Telegram media size before downloading into memory."""
max_bytes = int(getattr(self, "_max_doc_bytes", 20 * 1024 * 1024) or 20 * 1024 * 1024)
file_size = getattr(source, "file_size", None)
try:
size = int(file_size or 0)
except (TypeError, ValueError):
size = 0
if size <= 0:
return True, None
if size <= max_bytes:
return True, None
return False, self._telegram_media_too_large_note(label, size, max_bytes)
async def send_voice(
self,
chat_id: str,
@@ -5569,6 +5629,12 @@ class TelegramAdapter(BasePlatformAdapter):
# Download voice/audio messages to cache for STT transcription
if msg.voice:
try:
allowed, note = self._telegram_media_size_allowed(msg.voice, "voice message")
if not allowed:
event.text = self._append_observed_note(event.text, note or "")
logger.info("[Telegram] Skipped oversized user voice (size=%s)", getattr(msg.voice, "file_size", None))
await self.handle_message(event)
return
file_obj = await msg.voice.get_file()
audio_bytes = await file_obj.download_as_bytearray()
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".ogg")
@@ -5579,6 +5645,12 @@ class TelegramAdapter(BasePlatformAdapter):
logger.warning("[Telegram] Failed to cache voice: %s", e, exc_info=True)
elif msg.audio:
try:
allowed, note = self._telegram_media_size_allowed(msg.audio, "audio file")
if not allowed:
event.text = self._append_observed_note(event.text, note or "")
logger.info("[Telegram] Skipped oversized user audio (size=%s)", getattr(msg.audio, "file_size", None))
await self.handle_message(event)
return
file_obj = await msg.audio.get_file()
audio_bytes = await file_obj.download_as_bytearray()
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".mp3")
+69 -8
View File
@@ -191,6 +191,22 @@ from gateway.platforms.base import (
)
def _file_content_hash(path: Path) -> str:
"""Return the first 16 hex chars of the SHA-256 of *path*'s contents.
Used for the bridge staleness handshake: bridge.js reports its own
source hash in ``/health`` (``scriptHash``), and the adapter compares
it against the hash of bridge.js currently on disk. A mismatch means
a long-lived bridge process is serving code from before an update.
Returns ``""`` when the file can't be read.
"""
import hashlib
try:
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
except OSError:
return ""
def check_whatsapp_requirements() -> bool:
"""
Check if WhatsApp dependencies are available.
@@ -587,9 +603,21 @@ class WhatsAppAdapter(BasePlatformAdapter):
logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e)
try:
# Auto-install npm dependencies if node_modules doesn't exist
# Auto-install npm dependencies when node_modules is missing OR
# package.json changed since the last install (e.g. after
# `hermes update` bumps the Baileys pin). The stamp file records
# the package.json hash of the last successful install.
bridge_dir = bridge_path.parent
if not (bridge_dir / "node_modules").exists():
_pkg_json = bridge_dir / "package.json"
_dep_stamp = bridge_dir / "node_modules" / ".hermes-pkg-hash"
_pkg_hash = _file_content_hash(_pkg_json)
_deps_fresh = False
if (bridge_dir / "node_modules").exists():
try:
_deps_fresh = (_dep_stamp.read_text().strip() == _pkg_hash) and bool(_pkg_hash)
except OSError:
_deps_fresh = False
if not _deps_fresh:
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
# Resolve npm path so Windows can execute the .cmd shim.
# shutil.which honours PATHEXT; on POSIX it returns the
@@ -610,6 +638,11 @@ class WhatsAppAdapter(BasePlatformAdapter):
print(f"[{self.name}] npm install failed: {install_result.stderr}")
return False
print(f"[{self.name}] Dependencies installed")
if _pkg_hash:
try:
_dep_stamp.write_text(_pkg_hash)
except OSError:
pass # Stamp is an optimization; install still succeeded
except Exception as e:
print(f"[{self.name}] Failed to install dependencies: {e}")
return False
@@ -629,12 +662,28 @@ class WhatsAppAdapter(BasePlatformAdapter):
data = await resp.json()
bridge_status = data.get("status", "unknown")
if bridge_status == "connected":
print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
self._mark_connected()
self._bridge_process = None # Not managed by us
self._http_session = aiohttp.ClientSession()
self._poll_task = asyncio.create_task(self._poll_messages())
return True
# Staleness handshake: only reuse a running
# bridge if it is serving the same bridge.js
# that is on disk right now. A long-lived
# bridge survives gateway restarts AND
# `hermes update`, so without this check it
# keeps serving pre-update code forever
# (e.g. no inbound media download). Old
# bridges that don't report scriptHash are
# treated as stale by definition.
running_hash = data.get("scriptHash", "")
disk_hash = _file_content_hash(bridge_path)
if running_hash and disk_hash and running_hash == disk_hash:
print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
self._mark_connected()
self._bridge_process = None # Not managed by us
self._http_session = aiohttp.ClientSession()
self._poll_task = asyncio.create_task(self._poll_messages())
return True
print(
f"[{self.name}] Running bridge is stale "
f"(running={running_hash or 'unversioned'}, disk={disk_hash}), restarting"
)
else:
print(f"[{self.name}] Bridge found but not connected (status: {bridge_status}), restarting")
except Exception:
@@ -659,6 +708,18 @@ class WhatsAppAdapter(BasePlatformAdapter):
bridge_env = os.environ.copy()
if self._reply_prefix is not None:
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
# Pass the profile-aware cache directories so the bridge writes
# media where the Python side reads it. Without these the bridge
# hardcodes ~/.hermes/{image,audio,document}_cache, which diverges
# under HERMES_HOME overrides, profiles, and the new cache/ layout.
from gateway.platforms.base import (
get_audio_cache_dir as _get_audio_dir,
get_document_cache_dir as _get_doc_dir,
get_image_cache_dir as _get_img_dir,
)
bridge_env["HERMES_IMAGE_CACHE_DIR"] = str(_get_img_dir())
bridge_env["HERMES_AUDIO_CACHE_DIR"] = str(_get_audio_dir())
bridge_env["HERMES_DOCUMENT_CACHE_DIR"] = str(_get_doc_dir())
self._bridge_process = subprocess.Popen(
[
+98 -2
View File
@@ -32,6 +32,7 @@ import logging
import os
import re
import shlex
import site
import sys
import signal
import tempfile
@@ -135,6 +136,60 @@ _GATEWAY_SECRET_PATTERNS = (
)
def _ensure_windows_gateway_venv_imports() -> None:
"""Make detached Windows gateway runs see the Hermes venv packages.
Some Windows restart paths run the gateway under uv's base ``pythonw.exe``
to avoid the venv launcher respawning a visible console interpreter. That
mode can import the source tree via cwd/PYTHONPATH but still miss optional
packages installed only in ``venv/Lib/site-packages`` (notably the MCP SDK).
Patch the live process before MCP discovery so tool injection does not
depend on every launcher preserving PYTHONPATH perfectly.
"""
if sys.platform != "win32":
return
project_root = Path(__file__).resolve().parent.parent
candidates: list[Path] = []
if os.environ.get("VIRTUAL_ENV"):
candidates.append(Path(os.environ["VIRTUAL_ENV"]))
candidates.append(project_root / "venv")
seen: set[str] = set()
for venv_dir in candidates:
try:
resolved_venv = venv_dir.resolve()
except OSError:
resolved_venv = venv_dir
venv_key = str(resolved_venv).lower()
if venv_key in seen:
continue
seen.add(venv_key)
site_packages = resolved_venv / "Lib" / "site-packages"
if not site_packages.exists():
continue
project_entry = str(project_root)
site_entry = str(site_packages)
if project_entry not in sys.path:
sys.path.insert(0, project_entry)
# addsitepackages() semantics matter here: pywin32, used by the MCP
# SDK on Windows, relies on .pth processing to expose pywintypes.
site.addsitedir(site_entry)
if site_entry in sys.path:
sys.path.remove(site_entry)
insert_at = 1 if sys.path and sys.path[0] == project_entry else 0
sys.path.insert(insert_at, site_entry)
os.environ["VIRTUAL_ENV"] = str(resolved_venv)
pythonpath = [project_entry, site_entry]
if os.environ.get("PYTHONPATH"):
pythonpath.append(os.environ["PYTHONPATH"])
os.environ["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
return
def _gateway_platform_value(platform: Any) -> str:
"""Return a normalized gateway platform value for enums or raw strings."""
return str(getattr(platform, "value", platform) or "").strip().lower()
@@ -4255,10 +4310,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
"""
).strip()
watcher_env = os.environ.copy()
# This watcher is intentionally outside the running gateway. If it
# inherits the gateway marker, `hermes gateway restart` refuses to
# run as a self-restart loop guard and the gateway stays stopped.
watcher_env.pop("_HERMES_GATEWAY", None)
project_root = Path(__file__).resolve().parent.parent
venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv")
site_packages = venv_dir / "Lib" / "site-packages"
if site_packages.exists():
watcher_env["VIRTUAL_ENV"] = str(venv_dir)
pythonpath = [str(project_root), str(site_packages)]
if watcher_env.get("PYTHONPATH"):
pythonpath.append(watcher_env["PYTHONPATH"])
watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
subprocess.Popen(
[sys.executable, "-c", watcher, str(current_pid), *cmd_argv],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=watcher_env,
**windows_detach_popen_kwargs(),
)
return
@@ -4268,12 +4338,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
f"while kill -0 {current_pid} 2>/dev/null; do sleep 0.2; done; "
f"{cmd} gateway restart"
)
# Same marker scrub as the Windows watcher above: this watcher runs
# `hermes gateway restart` from outside the gateway, but it inherits
# _HERMES_GATEWAY=1 from us, and the CLI's self-restart loop guard
# refuses to run when that marker is set — silently (DEVNULL), so the
# gateway stops and never comes back.
watcher_env = os.environ.copy()
watcher_env.pop("_HERMES_GATEWAY", None)
setsid_bin = shutil.which("setsid")
if setsid_bin:
subprocess.Popen(
[setsid_bin, "bash", "-lc", shell_cmd],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=watcher_env,
start_new_session=True,
)
else:
@@ -4281,6 +4359,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
["bash", "-lc", shell_cmd],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=watcher_env,
start_new_session=True,
)
@@ -12932,6 +13011,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
last_tool = [None] # Mutable container for tracking in closure
last_progress_msg = [None] # Track last message for dedup
repeat_count = [0] # How many times the same message repeated
# True when the previously enqueued progress line was a terminal
# fenced code block — consecutive terminal calls then drop the
# repeated "💻 terminal" header and render back-to-back blocks.
last_was_terminal_block = [False]
# ── Discord voice "verbal ack before tool calls" ────────────────
# When the bot is in a voice channel with the continuous mixer
@@ -13088,7 +13171,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
):
from agent.display import get_tool_preview_max_len
_cmd_full = args["command"].rstrip()
_code_block_full = f"{emoji} {tool_name}\n```\n{_cmd_full}\n```"
# Consecutive terminal calls: drop the repeated
# "💻 terminal" header so back-to-back commands render as
# adjacent code blocks under a single header.
_block_header = (
"" if last_was_terminal_block[0] else f"{emoji} {tool_name}\n"
)
_code_block_full = f"{_block_header}```\n{_cmd_full}\n```"
# Single-line, capped preview for non-verbose modes.
_pl = get_tool_preview_max_len()
_cap = _pl if _pl > 0 else 40
@@ -13099,13 +13188,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_cmd_short = _cmd_short[:_cap - 3] + "..."
elif _multiline:
_cmd_short = _cmd_short + " ..."
_code_block_short = f"{emoji} {tool_name}\n```\n{_cmd_short}\n```"
_code_block_short = f"{_block_header}```\n{_cmd_short}\n```"
# Verbose mode: show detailed arguments, respects tool_preview_length
if progress_mode == "verbose":
if _code_block_full is not None:
last_was_terminal_block[0] = True
progress_queue.put(_code_block_full)
return
last_was_terminal_block[0] = False
if args:
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
@@ -13130,6 +13221,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# fenced block (built above) instead of the truncated preview.
if _code_block_short is not None:
msg = _code_block_short
last_was_terminal_block[0] = True
elif preview:
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
@@ -13137,8 +13229,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if len(preview) > _cap:
preview = preview[:_cap - 3] + "..."
msg = f"{emoji} {tool_name}: \"{preview}\""
last_was_terminal_block[0] = False
else:
msg = f"{emoji} {tool_name}..."
last_was_terminal_block[0] = False
# Dedup: collapse consecutive identical progress messages.
# Common with execute_code where models iterate with the same
@@ -15895,6 +15989,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
atexit.register(remove_pid_file)
atexit.register(release_gateway_runtime_lock)
_ensure_windows_gateway_venv_imports()
# MCP tool discovery — run in an executor so the asyncio event loop
# stays responsive even when a configured MCP server is slow or
# unreachable. discover_mcp_tools() uses a blocking 120s wait
+27
View File
@@ -294,6 +294,22 @@ def build_session_context_prompt(
if context.source.chat_topic:
lines.append(f"**Channel Topic:** {context.source.chat_topic}")
if context.source.platform == Platform.MATRIX:
src = context.source
room_name = src.chat_name or src.chat_id
room_id = _hash_chat_id(src.chat_id) if redact_pii else src.chat_id
lines.append("")
lines.append(f"**Matrix Room:** {room_name}")
lines.append(f"**Matrix Room ID:** {room_id}")
if src.thread_id:
thread_id = _hash_chat_id(src.thread_id) if redact_pii else src.thread_id
lines.append(f"**Matrix Thread:** {thread_id}")
lines.append(
"**Matrix room boundary:** Treat this turn as scoped to the current "
"Matrix room/thread only. Do not assume unresolved references are "
"about other Matrix rooms or projects unless the user explicitly says so."
)
# User identity.
# In shared multi-user sessions (shared threads OR shared non-thread groups
# when group_sessions_per_user=False), multiple users contribute to the same
@@ -1264,6 +1280,17 @@ class SessionStore:
entries.sort(key=lambda e: e.updated_at, reverse=True)
return entries
def lookup_by_session_id(self, session_id: str) -> Optional[SessionEntry]:
"""Return the active session entry for a persisted session ID, if any."""
if not session_id:
return None
with self._lock:
self._ensure_loaded_locked()
for entry in self._entries.values():
if entry.session_id == session_id:
return entry
return None
def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None:
"""Append a message to a session's transcript (SQLite).
+97 -2
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import asyncio
import dataclasses
import hashlib
import inspect
import logging
import os
@@ -32,7 +33,7 @@ from agent.account_usage import fetch_account_usage, render_account_usage_lines
from agent.i18n import t
from gateway.config import HomeChannel, Platform, PlatformConfig
from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType
from gateway.session import build_session_key
from gateway.session import SessionSource, build_session_key
from hermes_cli.config import cfg_get
from utils import (
atomic_json_write,
@@ -447,6 +448,22 @@ class GatewaySlashCommandsMixin:
])
if queue_depth:
lines.append(t("gateway.status.queued", count=queue_depth))
if source.platform == Platform.MATRIX:
adapter = self.adapters.get(Platform.MATRIX)
scope = getattr(adapter, "_matrix_session_scope", os.getenv("MATRIX_SESSION_SCOPE", "auto"))
thread = source.thread_id or "none"
lines.extend([
"",
t("gateway.status.matrix_scope_header"),
t("gateway.status.matrix_scope_room", room=source.chat_name or source.chat_id),
t("gateway.status.matrix_scope_room_id", room_id=source.chat_id),
t("gateway.status.matrix_scope_thread", thread_id=thread),
t("gateway.status.matrix_scope_mode", scope=scope),
t(
"gateway.status.matrix_scope_key",
session_key=self._redact_matrix_session_key(session_key),
),
])
lines.extend([
"",
t("gateway.status.platforms", platforms=', '.join(connected_platforms)),
@@ -454,6 +471,37 @@ class GatewaySlashCommandsMixin:
return "\n".join(lines)
@staticmethod
def _redact_matrix_session_key(session_key: str) -> str:
"""Return a stable Matrix session-key fingerprint for shared room status."""
text = str(session_key or "")
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
return f"sha256:{digest}"
def _gateway_session_origin_for_id(self, session_id: str) -> Optional[SessionSource]:
"""Best-effort origin lookup for gateway session IDs."""
lookup = getattr(type(self.session_store), "lookup_by_session_id", None)
if callable(lookup):
entry = lookup(self.session_store, session_id)
return getattr(entry, "origin", None) if entry is not None else None
# Test doubles and older stores may not expose the public lookup helper.
# Keep the Matrix resume guard fail-closed if no origin can be resolved.
entries = getattr(self.session_store, "_entries", {}) or {}
for entry in entries.values():
if getattr(entry, "session_id", None) == session_id:
return getattr(entry, "origin", None)
return None
@staticmethod
def _same_matrix_room(current: SessionSource, origin: Optional[SessionSource]) -> bool:
return (
origin is not None
and origin.platform == Platform.MATRIX
and current.platform == Platform.MATRIX
and origin.chat_id == current.chat_id
)
async def _handle_agents_command(self, event: MessageEvent) -> str:
"""Handle /agents command - list active agents and running tasks."""
from gateway.run import _AGENT_PENDING_SENTINEL
@@ -2652,7 +2700,14 @@ class GatewaySlashCommandsMixin:
source = event.source
session_key = self._session_key_for_source(source)
name = event.get_command_args().strip()
raw_args = event.get_command_args().strip()
try:
parts = shlex.split(raw_args)
except ValueError as exc:
return t("gateway.resume.parse_error", error=exc)
allow_all = "--all" in parts
allow_cross_room = "--cross-room" in parts
name = " ".join(p for p in parts if p not in {"--all", "--cross-room"}).strip()
# Strip common outer brackets/quotes users may type literally from the
# usage hint (e.g. ``/resume <abc123>``). Mirrors the CLI behavior.
@@ -2673,11 +2728,24 @@ class GatewaySlashCommandsMixin:
# List recent titled sessions for this user/platform
try:
titled = _list_titled_sessions()
if source.platform == Platform.MATRIX and not allow_all:
scoped = []
for s in titled:
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
if self._same_matrix_room(source, origin):
scoped.append(s)
titled = scoped
if not titled:
if source.platform == Platform.MATRIX and not allow_all:
return t("gateway.resume.matrix_no_named_sessions")
return t("gateway.resume.no_named_sessions")
lines = [t("gateway.resume.list_header")]
for idx, s in enumerate(titled[:10], start=1):
title = s["title"]
if source.platform == Platform.MATRIX and allow_all:
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
if origin:
title = f"{title}{origin.chat_name or origin.chat_id}"
preview = s.get("preview", "")[:40]
preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else ""
lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part))
@@ -2691,6 +2759,13 @@ class GatewaySlashCommandsMixin:
if name.isdigit():
try:
titled = _list_titled_sessions()
if source.platform == Platform.MATRIX and not allow_all:
scoped = []
for s in titled:
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
if self._same_matrix_room(source, origin):
scoped.append(s)
titled = scoped
except Exception as e:
logger.debug("Failed to list titled sessions for numeric resume: %s", e)
return t("gateway.resume.list_failed", error=e)
@@ -2717,6 +2792,17 @@ class GatewaySlashCommandsMixin:
except Exception as e:
logger.debug("Failed to resolve resume continuation for %s: %s", target_id, e)
if source.platform == Platform.MATRIX:
target_origin = self._gateway_session_origin_for_id(target_id)
if not self._same_matrix_room(source, target_origin) and not allow_cross_room:
if target_origin is None:
return t("gateway.resume.matrix_blocked_no_origin", name=name)
return t(
"gateway.resume.matrix_blocked_other_room",
room=target_origin.chat_name or target_origin.chat_id,
name=name,
)
# Check if already on that session
current_entry = self.session_store.get_or_create_session(source)
if current_entry.session_id == target_id:
@@ -2744,6 +2830,15 @@ class GatewaySlashCommandsMixin:
# Count messages for context
history = self.session_store.load_transcript(target_id)
msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0
msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else ""
if source.platform == Platform.MATRIX and allow_cross_room:
return t(
"gateway.resume.matrix_cross_room_success",
title=title,
room=source.chat_name or source.chat_id,
msg_part=msg_part,
)
if not msg_count:
return t("gateway.resume.resumed_no_count", title=title)
if msg_count == 1:

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