Compare commits

...
Author SHA1 Message Date
teknium1 76fa55240d fix(gateway): use base pythonw for post-update Windows respawn
The post-update respawn watcher (launch_detached_profile_gateway_restart)
respawned the gateway via `_gateway_run_args_for_profile`, which used
`get_python_path()` — the console `python.exe`. On uv-created venvs even
`venv\Scripts\pythonw.exe` re-execs the base interpreter as a console
`python.exe`, and that re-exec is a fresh CreateProcess that does NOT
inherit the watcher's CREATE_NO_WINDOW flag. Result: a blank console
window pops up after every Desktop-GUI `hermes update`, and the respawned
gateway is tied to it.

`hermes gateway start` already avoids this by routing through
`_resolve_detached_python` to get the base `pythonw.exe` plus a
VIRTUAL_ENV / PYTHONPATH overlay (see `_build_gateway_argv`/`_spawn_detached`).
The post-update respawn path was the one launch site that never got the
same treatment — a sibling call path of the same bug class.

Fix:
- `_gateway_run_args_for_profile` now resolves the base `pythonw.exe` on
  Windows via `_resolve_detached_python` (no-op on POSIX — argv keeps
  `get_python_path()` verbatim).
- New `_gateway_respawn_env` overlays VIRTUAL_ENV / PYTHONPATH /
  HERMES_GATEWAY_DETACHED so the base-interpreter respawn can import
  `hermes_cli` without the venv launcher shim. Returns the env unchanged
  on POSIX, preserving pre-fix spawn behaviour bit-for-bit.
- The watcher interpreter itself is resolved the same way and both
  watcher Popen calls pass the overlay env; the respawned gateway
  inherits it transitively from the watcher.

This complements #41028 / #38605, which fix the same uv-pythonw console
trap in the Scheduled-Task `.cmd` wrapper (`_build_gateway_cmd_script`,
the login path). Neither touches the post-update respawn argv this PR
fixes.

Adds tests/hermes_cli/test_gateway_update_respawn_pythonw.py:
base-pythonw resolution for uv venvs, the env overlay, a POSIX-equivalence
guard, and a live-Windows windowless-interpreter check.
2026-06-11 06:32:25 -07: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
258 changed files with 20180 additions and 2676 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
+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):
+40
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)
@@ -1698,6 +1710,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 +1754,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
+700
View File
@@ -0,0 +1,700 @@
"""Coding-context awareness — base Hermes, every interactive surface.
When the user runs Hermes inside a code workspace (CLI, TUI, desktop app, or an
editor over ACP), Hermes shifts into a **coding posture**. This module is the
single place that decides whether we're in that posture and what it implies,
so the rest of the codebase never re-derives "are we coding?" on its own.
Architecture — one seam, many consumers
----------------------------------------
The posture is modelled as a frozen :class:`RuntimeMode` selected from a small
:class:`ContextProfile` registry (today: ``coding`` and ``general``). A profile
is *data* — it declares the toolset to collapse to, the operating brief to
inject, and hints for other domains (model routing, memory, subagents). Every
domain reads the same resolved object instead of probing git/config itself:
* **System prompt** — ``RuntimeMode.system_blocks()`` → the operating brief +
a live git/workspace snapshot (``agent/system_prompt.py``).
* **Toolset** — ``RuntimeMode.toolset_selection()`` → the ``coding`` toolset
plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Only
under the opt-in ``focus`` mode: the default posture is prompt-only and
never touches the user's configured toolsets (toolsets like messaging /
smart-home / music are off-by-default anyway, and someone who explicitly
enabled image-gen or Spotify shouldn't lose it for being in a git repo).
* **Delegation** — subagents inherit the parent's toolset and run through the
same prompt builder, so the coding posture propagates to children for free.
* **Model / memory / compression** — declared on the profile
(``model_hint``, ``memory_policy``) as the extension seam; consumers read
``mode.profile`` rather than re-deciding.
Cache safety
------------
The mode is resolved **once** and is immutable. The workspace snapshot is built
once at prompt-build time and baked into the *stable* system-prompt tier — never
re-probed per turn (that would shatter the prompt cache). Branch and dirty state
drift mid-session, so the brief tells the model to re-check with ``git`` before
acting on the snapshot. A ``/coding`` flip therefore only takes effect next
session (deferred), the same contract as ``/skills install`` vs ``--now``.
Activation (config ``agent.coding_context``):
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
surface sitting in a code workspace (git repo or recognised project root).
Prompt-only; toolsets untouched.
* ``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 (OpenAI/Codex handle
# patch-style diffs best; Anthropic models — and most open-weight coding
# models, whose RL scaffolds use str_replace-style editors — do best with
# string-replacement). Our `patch` tool exposes both: mode="patch" (V4A
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
# its native format. Unknown families get nothing (the brief's neutral wording
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
_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).
``hidden_skill_categories`` — skill categories pruned from the system-prompt
skill index while this posture is active. Discovery-only:
nothing is disabled — ``skills_list`` still returns the
full catalog and ``skill_view`` loads anything. Deny-list
semantics so unknown/custom categories stay visible.
"""
name: str
toolset: Optional[str] = None
guidance: str = ""
model_hint: Optional[str] = None
memory_policy: str = "default"
hidden_skill_categories: tuple[str, ...] = ()
# Skill categories that are clearly not part of a coding workflow. Hidden from
# the prompt's skill index in the coding posture (deny-list — anything not
# listed here, incl. custom user categories, stays visible). Coding-adjacent
# categories (devops, github, mcp, data-science, diagramming, research,
# security, …) are intentionally absent.
_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",
hidden_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 hidden_skill_categories(self) -> frozenset[str]:
"""Skill categories to prune from the prompt's skill index (may be empty)."""
return frozenset(self.profile.hidden_skill_categories)
def 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_hidden_skill_categories(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
) -> frozenset[str]:
"""Skill categories the active posture prunes from the prompt's skill index.
Empty outside the coding posture. Discovery-only: hidden skills remain
loadable via ``skills_list`` / ``skill_view``.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config
).hidden_skill_categories()
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
"""Names of MCP servers the user has enabled — kept in the coding posture.
MCP servers (figma, browser, tophat, …) are explicitly configured and part
of the coding workflow, not noise to strip.
"""
try:
from hermes_cli.config import read_raw_config
from hermes_cli.tools_config import _parse_enabled_flag
servers = read_raw_config().get("mcp_servers") or {}
return [
str(name)
for name, cfg in servers.items()
if isinstance(cfg, dict)
and _parse_enabled_flag(cfg.get("enabled", True), default=True)
]
except Exception:
return []
# ── git/workspace probe ─────────────────────────────────────────────────────
def _git(cwd: Path, *args: str) -> str:
try:
out = subprocess.run(
["git", "-C", str(cwd), *args],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT,
)
except (OSError, subprocess.SubprocessError):
return ""
return out.stdout.strip() if out.returncode == 0 else ""
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
"""Parse ``git status --porcelain=2 --branch`` into branch + counts."""
branch: dict[str, str] = {}
counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicts": 0}
for line in porcelain.splitlines():
if line.startswith("# branch.head"):
branch["head"] = line.split(maxsplit=2)[-1]
elif line.startswith("# branch.upstream"):
branch["upstream"] = line.split(maxsplit=2)[-1]
elif line.startswith("# branch.ab"):
parts = line.split()
branch["ahead"], branch["behind"] = parts[2].lstrip("+"), parts[3].lstrip("-")
elif line.startswith(("1 ", "2 ")):
xy = line.split(maxsplit=2)[1]
if xy[0] != ".":
counts["staged"] += 1
if xy[1] != ".":
counts["modified"] += 1
elif line.startswith("u "):
counts["conflicts"] += 1
elif line.startswith("? "):
counts["untracked"] += 1
return branch, counts
def _read_small(path: Path) -> str:
"""Read a small text file, or ``""`` — never raises, never reads huge files."""
try:
if not path.is_file() or path.stat().st_size > _MAX_FACT_FILE_BYTES:
return ""
return path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
def _project_facts(root: Path) -> list[str]:
"""Detected project facts for the workspace snapshot.
The point is to hand the model its *verify loop* up front — which manifest,
which package manager, and the exact test/lint/build commands — instead of
making it rediscover them every session. Cheap: stat calls plus reads of a
couple of small files; built once at prompt-build time (cache-safe).
"""
facts: list[str] = []
manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
package_managers = [
pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
]
if manifests:
line = f"- Project: {', '.join(manifests[:6])}"
if package_managers:
line += f" ({'/'.join(dict.fromkeys(package_managers))})"
facts.append(line)
verify: list[str] = []
if (root / "scripts" / "run_tests.sh").is_file():
verify.append("scripts/run_tests.sh")
if (root / "package.json").is_file():
try:
scripts = json.loads(_read_small(root / "package.json") or "{}").get("scripts") or {}
except (json.JSONDecodeError, AttributeError):
scripts = {}
js_pm = next((pm for lock, pm in _JS_LOCKFILES if (root / lock).is_file()), "npm")
verify.extend(f"{js_pm} run {name}" for name in _VERIFY_TARGETS if name in scripts)
if (root / "pytest.ini").is_file() or "[tool.pytest" in _read_small(root / "pyproject.toml"):
verify.append("pytest")
makefile = _read_small(root / "Makefile")
if makefile:
verify.extend(
f"make {name}" for name in _VERIFY_TARGETS
if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
)
if verify:
deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
facts.append(f"- Verify: {'; '.join(deduped)}")
context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
if context_files:
facts.append(f"- Context files: {', '.join(context_files)}")
return facts
def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
"""Workspace snapshot for the system prompt (empty outside a workspace).
Git state (branch/status/commits) when the cwd is in a repo, plus detected
project facts (manifest, package manager, verify commands, context files)
— so marker-only (non-git) projects still get a snapshot.
"""
resolved = _resolve_cwd(cwd)
git_root = _git_root(resolved)
root = git_root or _marker_root(resolved)
if root is None:
return ""
lines = ["Workspace (snapshot at session start — re-check with `git` before acting on it):"]
lines.append(f"- Root: {root}")
if git_root is not None:
branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch"))
head = branch.get("head", "")
if head and head != "(detached)":
line = f"- Branch: {head}"
if branch.get("upstream"):
line += f" \u2192 {branch['upstream']}"
ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0")
if ahead != "0" or behind != "0":
line += f" (ahead {ahead}, behind {behind})"
lines.append(line)
elif head == "(detached)":
lines.append("- Branch: (detached HEAD)")
# Linked worktree: the per-worktree git dir differs from the shared common dir.
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,
+30 -1
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,
hidden_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.
``hidden_categories`` (e.g. from the coding posture see
agent/coding_context.py) prunes whole categories from the rendered index.
Discovery-only: the snapshot stores everything, ``skills_list`` /
``skill_view`` still reach every skill, and a footer note tells the model
the full catalog exists.
"""
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(hidden_categories or ())),
)
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
@@ -1272,6 +1280,26 @@ def build_skills_system_prompt(
except Exception as e:
logger.debug("Could not read external skill description %s: %s", desc_file, e)
# Posture-driven category pruning (e.g. non-coding skills while pairing on
# code). Match on the top-level category segment so nested categories
# ("social-media/twitter") are pruned with their parent.
hidden_note = ""
if hidden_categories:
before = sum(len(v) for v in skills_by_category.values())
skills_by_category = {
cat: entries
for cat, entries in skills_by_category.items()
if cat.split("/", 1)[0] not in hidden_categories
}
pruned = before - sum(len(v) for v in skills_by_category.values())
if pruned:
hidden_note = (
f"\n(Note: {pruned} skill(s) in categories unrelated to the "
"current coding context are not listed here. The full catalog "
"is available via skills_list if the user asks for something "
"outside this list.)"
)
if not skills_by_category:
result = ""
else:
@@ -1320,6 +1348,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 ────────────────────────────────────────────
+32
View File
@@ -191,9 +191,21 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
if toolset
}
# Coding posture prunes non-coding skill categories from the index
# (discovery-only — skills_list/skill_view still reach everything).
_hidden_cats = frozenset()
try:
from agent.coding_context import coding_hidden_skill_categories
_hidden_cats = coding_hidden_skill_categories(
platform=agent.platform, cwd=resolve_context_cwd()
)
except Exception:
_hidden_cats = frozenset()
skills_prompt = _r.build_skills_system_prompt(
available_tools=agent.valid_tool_names,
available_toolsets=avail_toolsets,
hidden_categories=_hidden_cats or None,
)
else:
skills_prompt = ""
@@ -221,6 +233,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
```
+46 -1
View File
@@ -31,6 +31,10 @@ 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 {
OFFICIAL_REPO_HTTPS_URL,
isOfficialSshRemote
} = require('./update-remote.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
@@ -1312,6 +1316,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 +1340,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 +1370,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 {
+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/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>
)
})
)}
@@ -98,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'
@@ -694,6 +695,7 @@ export function DesktopController() {
handleSkinCommand,
refreshSessions,
requestGateway,
resumeStoredSession: resumeSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
@@ -743,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,
@@ -822,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,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}
/>
)
}
@@ -633,14 +633,21 @@ export function useMessageStream({
const runningChanged = typeof payload?.running === 'boolean'
if (apply) {
const runtimeInfo: { branch?: string; cwd?: string } = {}
const runtimeInfo: Partial<
Pick<
ClientSessionState,
'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
>
> = {}
if (modelChanged) {
setCurrentModel(payload!.model || '')
runtimeInfo.model = payload!.model || ''
}
if (providerChanged) {
setCurrentProvider(payload!.provider || '')
runtimeInfo.provider = payload!.provider || ''
}
if (typeof payload?.cwd === 'string') {
@@ -653,32 +660,32 @@ export function useMessageStream({
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') {
setCurrentPersonality(normalizePersonalityValue(payload.personality))
}
if (typeof payload?.reasoning_effort === 'string') {
setCurrentReasoningEffort(payload.reasoning_effort)
runtimeInfo.reasoningEffort = payload.reasoning_effort
}
if (typeof payload?.service_tier === 'string') {
setCurrentServiceTier(payload.service_tier)
runtimeInfo.serviceTier = payload.service_tier
}
if (typeof payload?.fast === 'boolean') {
setCurrentFastMode(payload.fast)
runtimeInfo.fast = payload.fast
}
if (typeof payload?.yolo === 'boolean') {
setYoloActive(payload.yolo)
runtimeInfo.yolo = payload.yolo
}
if (sessionId && Object.keys(runtimeInfo).length > 0) {
updateSessionState(sessionId, state => ({ ...state, ...runtimeInfo }))
}
if (runningChanged && sessionId) {
@@ -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,7 +39,8 @@ import {
setSessionStartedAt,
setSessionsTotal,
setTurnStartedAt,
setYoloActive
setYoloActive,
workspaceCwdForNewSession
} from '@/store/session'
import { reportBackendContract } from '@/store/updates'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
@@ -211,14 +209,16 @@ 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 {
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial<
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
> | null {
if (!info) {
return null
}
const sessionState: Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> = {}
const sessionState: Partial<
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
> = {}
reportBackendContract(info.desktop_contract)
@@ -228,10 +228,12 @@ function applyRuntimeInfo(
if (info.model) {
setCurrentModel(info.model)
sessionState.model = info.model
}
if (info.provider) {
setCurrentProvider(info.provider)
sessionState.provider = info.provider
}
if (info.cwd) {
@@ -250,18 +252,22 @@ function applyRuntimeInfo(
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) {
@@ -314,10 +320,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 +350,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 (
@@ -462,8 +475,6 @@ export function useSessionActions({
setCurrentCwd(cachedState.cwd)
setCurrentBranch(cachedState.branch)
setSessionStartedAt(Date.now())
clearComposerDraft()
clearComposerAttachments()
try {
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
@@ -593,8 +604,6 @@ export function useSessionActions({
}),
storedSessionId
)
clearComposerDraft()
clearComposerAttachments()
} catch (err) {
if (!isCurrentResume()) {
return
@@ -717,8 +726,6 @@ export function useSessionActions({
selectedStoredSessionIdRef.current = routedSessionId
navigate(sessionRoute(routedSessionId))
clearComposerDraft()
clearComposerAttachments()
const runtimeInfo = applyRuntimeInfo(branched.info)
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
@@ -859,6 +866,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) {
@@ -5,7 +5,20 @@ 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,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setSessionAttention,
setSessionWorking,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -124,6 +137,12 @@ export function useSessionStateCache({
setMessages(nextMessages)
}
setCurrentModel(pending.state.model)
setCurrentProvider(pending.state.provider)
setCurrentReasoningEffort(pending.state.reasoningEffort)
setCurrentServiceTier(pending.state.serviceTier)
setCurrentFastMode(pending.state.fast)
setYoloActive(pending.state.yolo)
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)
@@ -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;
+26
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,12 @@ export interface ClientSessionState {
messages: ChatMessage[]
branch: string
cwd: string
model: string
provider: string
reasoningEffort: string
serviceTier: string
fast: boolean
yolo: boolean
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"
>
@@ -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>
)
}
+8 -1
View File
@@ -1778,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: {
+8 -1
View File
@@ -1914,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: {
+8 -1
View File
@@ -1873,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: {
+8 -1
View File
@@ -1956,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, '')
}
+6
View File
@@ -40,6 +40,12 @@ export function createClientSessionState(
messages,
branch: '',
cwd: '',
model: '',
provider: '',
reasoningEffort: '',
serviceTier: '',
fast: false,
yolo: false,
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)
}
@@ -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)
}
+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
+41 -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
+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:
+75 -9
View File
@@ -147,8 +147,15 @@ class GatewayStreamConsumer:
self._edit_supported = True # Disabled when progressive edits are no longer usable
self._last_edit_time = 0.0
self._last_sent_text = "" # Track last-sent text to skip redundant edits
# True when the most recent _send_or_edit split-and-delivered across
# continuation messages (the adapter adopted a new message id).
self._last_edit_overflowed = False
self._fallback_final_send = False
self._fallback_prefix = ""
# True when fallback is sending only the missing tail after a partial
# Telegram overflow delivery. In that case the already-visible prefix
# is intentional content, not a stale preview to delete.
self._fallback_preserve_partial_messages = False
self._flood_strikes = 0 # Consecutive flood-control edit failures
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
self._final_response_sent = False
@@ -261,6 +268,7 @@ class GatewayStreamConsumer:
self._last_sent_text = ""
self._fallback_final_send = False
self._fallback_prefix = ""
self._fallback_preserve_partial_messages = False
# #29346: a tool/segment boundary means what we delivered was an interim
# preamble, not the final answer — clear the flags so a premature setter
# can't fool the gateway. Safe: got_done returns before any reset, and
@@ -581,14 +589,20 @@ class GatewayStreamConsumer:
if self._accumulated:
if self._fallback_final_send:
await self._send_fallback_final(self._accumulated)
elif (
current_update_visible
and not self._adapter_requires_finalize
elif current_update_visible and (
not self._adapter_requires_finalize
or self._last_edit_overflowed
):
# Mid-stream edit above already delivered the
# final accumulated content. Skip the redundant
# final edit — but only for adapters that don't
# need an explicit finalize signal.
# final edit for adapters that don't need an
# explicit finalize signal, and for any adapter
# when that edit split-and-delivered across
# continuations: the split edit carried
# finalize=True itself, and re-finalizing with
# the full text would overflow-split again into
# the adopted continuation, duplicating chunks
# on screen.
self._final_response_sent = True
self._final_content_delivered = True
elif self._message_id:
@@ -647,11 +661,21 @@ class GatewayStreamConsumer:
await asyncio.sleep(0.05) # Small yield to not busy-loop
except asyncio.CancelledError:
# Best-effort final edit on cancellation
# Best-effort final edit on cancellation. finalize=True so
# REQUIRES_EDIT_FINALIZE platforms (Telegram) apply final
# formatting — a plain edit here would leave the entire reply
# rendered as a raw streaming preview while the success flags
# below suppress the gateway's formatted re-send.
# is_turn_final=False keeps _try_fresh_final from setting
# _final_response_sent itself; this handler owns the flags.
_best_effort_ok = False
if self._accumulated and self._message_id:
try:
_best_effort_ok = bool(await self._send_or_edit(self._accumulated))
_best_effort_ok = bool(
await self._send_or_edit(
self._accumulated, finalize=True, is_turn_final=False,
)
)
except Exception:
pass
# Only confirm final delivery if the best-effort send above
@@ -867,11 +891,21 @@ class GatewayStreamConsumer:
self._notify_new_message()
# Remove the frozen partial message so the user only sees the
# complete fallback response. Best-effort — if the platform doesn't
# complete fallback response. ONLY safe when the fallback re-sent
# the FULL final text (continuation == final_text). When the
# prefix-based dedup above sent only the missing TAIL, the partial
# message IS the head of the answer — deleting it leaves the user
# with only the last part of the response (the "Gemini sent only
# the second half" symptom). Best-effort — if the platform doesn't
# implement ``delete_message``, the delete fails (flood control still
# active, bot lacks permission, message too old to delete), the
# partial remains but at least the full answer was delivered.
if stale_message_id and stale_message_id != last_message_id:
if (
stale_message_id
and stale_message_id != last_message_id
and not self._fallback_preserve_partial_messages
and continuation == final_text
):
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
try:
@@ -888,6 +922,7 @@ class GatewayStreamConsumer:
self._final_content_delivered = True
self._last_sent_text = chunks[-1]
self._fallback_prefix = ""
self._fallback_preserve_partial_messages = False
def _is_flood_error(self, result) -> bool:
"""Check if a SendResult failure is due to flood control / rate limiting."""
@@ -1208,6 +1243,7 @@ class GatewayStreamConsumer:
return True
# Failure already disabled drafts for this run; fall through to
# the regular edit/send path below.
self._last_edit_overflowed = False
try:
if self._message_id is not None:
if self._edit_supported:
@@ -1264,6 +1300,7 @@ class GatewayStreamConsumer:
and result.message_id
and result.message_id != self._message_id
):
self._last_edit_overflowed = True
self._message_id = str(result.message_id)
self._message_created_ts = time.monotonic()
self._last_sent_text = ""
@@ -1274,6 +1311,35 @@ class GatewayStreamConsumer:
self._flood_strikes = 0
return True
else:
raw_response = getattr(result, "raw_response", None)
if isinstance(raw_response, dict) and raw_response.get("partial_overflow"):
# Telegram edited/sent one or more overflow chunks,
# but not the complete response. Preserve the
# visible prefix so the got_done fallback sends the
# missing tail instead of marking a clipped topic
# reply as final delivery.
self._message_id = str(
raw_response.get("last_message_id")
or result.message_id
or self._message_id
)
delivered_prefix = raw_response.get("delivered_prefix")
if isinstance(delivered_prefix, str) and delivered_prefix:
self._last_sent_text = delivered_prefix
self._fallback_prefix = delivered_prefix
self._fallback_preserve_partial_messages = text.startswith(
delivered_prefix
)
else:
self._fallback_prefix = self._visible_prefix()
self._fallback_preserve_partial_messages = False
self._fallback_final_send = True
self._edit_supported = False
self._already_sent = True
if getattr(result, "continuation_message_ids", ()):
self._notify_new_message()
return False
# Edit failed. If this looks like flood control / rate
# limiting, use adaptive backoff: double the edit interval
# and retry on the next cycle. Only permanently disable
+29 -6
View File
@@ -31,6 +31,9 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Directory names to skip entirely (matched against each path component)
# ``hermes-agent`` is special-cased to root level only in ``_should_exclude``
# so that skill directories like ``skills/autonomous-ai-agents/hermes-agent/``
# are not accidentally excluded.
_EXCLUDED_DIRS = {
"hermes-agent", # the codebase repo — re-clone instead
"__pycache__", # bytecode caches — regenerated on import
@@ -69,10 +72,15 @@ def _should_exclude(rel_path: Path) -> bool:
"""Return True if *rel_path* (relative to hermes root) should be skipped."""
parts = rel_path.parts
# Any path component matches an excluded dir name
for part in parts:
if part in _EXCLUDED_DIRS:
return True
if part not in _EXCLUDED_DIRS:
continue
# ``hermes-agent`` only matches at the root level (first component).
# Nested directories with the same name — e.g.
# ``skills/autonomous-ai-agents/hermes-agent/`` — must be preserved.
if part == "hermes-agent" and part != parts[0]:
continue
return True
name = rel_path.name
@@ -177,10 +185,13 @@ def run_backup(args) -> None:
rel_dir = dp.relative_to(hermes_root)
# Prune excluded directories in-place so os.walk doesn't descend
# ``hermes-agent`` is only pruned at the root level; nested dirs
# with the same name (e.g. in skills/) must be preserved.
is_root = rel_dir == Path(".")
orig_dirnames = dirnames[:]
dirnames[:] = [
d for d in dirnames
if d not in _EXCLUDED_DIRS
if d not in _EXCLUDED_DIRS or (d == "hermes-agent" and not is_root)
]
for removed in set(orig_dirnames) - set(dirnames):
skipped_dirs.add(str(rel_dir / removed))
@@ -211,7 +222,13 @@ def run_backup(args) -> None:
try:
# Safe copy for SQLite databases (handles WAL mode)
if abs_path.suffix == ".db":
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
# Stage the snapshot alongside the output zip so that the
# temp file lives on the same filesystem. The system
# default (/tmp) may be a small tmpfs that cannot hold
# large databases, causing silent backup incompleteness.
with tempfile.NamedTemporaryFile(
suffix=".db", delete=False, dir=str(out_path.parent)
) as tmp:
tmp_db = Path(tmp.name)
if _safe_copy_db(abs_path, tmp_db):
zf.write(tmp_db, arcname=str(rel_path))
@@ -853,7 +870,13 @@ def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]:
for abs_path, rel_path in files_to_add:
try:
if abs_path.suffix == ".db":
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
# Stage the snapshot alongside the output zip so that the
# temp file lives on the same filesystem. The system
# default (/tmp) may be a small tmpfs that cannot hold
# large databases, causing silent backup incompleteness.
with tempfile.NamedTemporaryFile(
suffix=".db", delete=False, dir=str(out_path.parent)
) as tmp:
tmp_db = Path(tmp.name)
try:
if _safe_copy_db(abs_path, tmp_db):
+53
View File
@@ -11,6 +11,7 @@ import subprocess
import threading
import time
from pathlib import Path
from urllib.parse import urlparse
from hermes_constants import get_hermes_home
from typing import TYPE_CHECKING, Dict, List, Optional
@@ -121,6 +122,53 @@ _UPDATE_CHECK_CACHE_SECONDS = 6 * 3600
UPDATE_AVAILABLE_NO_COUNT = -1
_UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
_OFFICIAL_REPO_CANONICAL = "github.com/nousresearch/hermes-agent"
def _canonical_github_remote(url: str | None) -> str:
"""Return ``host/owner/repo`` for common GitHub remote URL forms."""
if not url:
return ""
value = url.strip()
if value.startswith("git@github.com:"):
value = "github.com/" + value[len("git@github.com:"):]
elif value.startswith("ssh://git@github.com/"):
value = "github.com/" + value[len("ssh://git@github.com/"):]
else:
parsed = urlparse(value)
if parsed.netloc and parsed.path:
value = f"{parsed.netloc}{parsed.path}"
value = value.strip().rstrip("/")
if value.endswith(".git"):
value = value[:-4]
return value.lower()
def _is_ssh_remote(url: str | None) -> bool:
if not url:
return False
value = url.strip().lower()
return value.startswith("git@") or value.startswith("ssh://")
def _is_official_ssh_remote(url: str | None) -> bool:
return _is_ssh_remote(url) and _canonical_github_remote(url) == _OFFICIAL_REPO_CANONICAL
def _git_stdout(args: list[str], *, cwd: Path, timeout: int = 5) -> Optional[str]:
try:
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
timeout=timeout,
cwd=str(cwd),
)
except Exception:
return None
if result.returncode != 0:
return None
return (result.stdout or "").strip()
def _check_via_rev(local_rev: str) -> Optional[int]:
@@ -146,6 +194,11 @@ def _check_via_rev(local_rev: str) -> Optional[int]:
def _check_via_local_git(repo_dir: Path) -> Optional[int]:
"""Count commits behind origin/main in a local checkout."""
origin_url = _git_stdout(["remote", "get-url", "origin"], cwd=repo_dir)
if _is_official_ssh_remote(origin_url):
head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir)
return _check_via_rev(head_rev) if head_rev else None
try:
subprocess.run(
["git", "fetch", "origin", "--quiet"],
+141 -2
View File
@@ -1544,12 +1544,140 @@ class SlashCommandCompleter(Completer):
except Exception:
pass
@staticmethod
def _tools_completions(sub_text: str, sub_lower: str):
"""Yield completions for /tools — subcommand + toolset/MCP-server name.
Handles both ``/tools <tab>`` (suggesting ``list|disable|enable``) and
``/tools enable <tab>`` / ``/tools disable <tab>`` (suggesting toolset
keys and MCP server prefixes, filtered by current enable state so the
user only sees actionable options).
"""
SUBS = ("list", "disable", "enable")
parts = sub_text.split()
trailing_space = sub_text.endswith(" ")
# Subcommand stage: zero words typed, or completing the first word.
if len(parts) == 0 or (len(parts) == 1 and not trailing_space):
partial = sub_text if not trailing_space else ""
for sub in SUBS:
if sub.startswith(partial.lower()) and sub != partial.lower():
yield Completion(sub, start_position=-len(partial), display=sub)
return
subcommand = parts[0].lower()
if subcommand not in ("enable", "disable"):
return
partial = "" if trailing_space else parts[-1]
partial_lower = partial.lower()
already = set(parts[1:] if trailing_space else parts[1:-1])
try:
from hermes_cli.config import load_config
from hermes_cli.tools_config import (
CONFIGURABLE_TOOLSETS,
_get_platform_tools,
_get_plugin_toolset_keys,
)
config = load_config()
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
for ts_key, label, _desc in CONFIGURABLE_TOOLSETS:
if ts_key in already or not ts_key.startswith(partial_lower):
continue
is_on = ts_key in enabled
if subcommand == "enable" and is_on:
continue
if subcommand == "disable" and not is_on:
continue
yield Completion(
ts_key,
start_position=-len(partial),
display=ts_key,
display_meta=label,
)
for ts_key in sorted(_get_plugin_toolset_keys()):
if ts_key in already or not ts_key.startswith(partial_lower):
continue
is_on = ts_key in enabled
if subcommand == "enable" and is_on:
continue
if subcommand == "disable" and not is_on:
continue
yield Completion(
ts_key,
start_position=-len(partial),
display=ts_key,
display_meta="plugin toolset",
)
mcp_servers = config.get("mcp_servers") or {}
if isinstance(mcp_servers, dict):
for server in sorted(mcp_servers):
prefix = f"{server}:"
if prefix in already or not prefix.startswith(partial_lower):
continue
yield Completion(
prefix,
start_position=-len(partial),
display=prefix,
display_meta=f"MCP server '{server}'",
)
except Exception:
return
@staticmethod
def _handoff_completions(sub_text: str, sub_lower: str):
"""Yield platform completions for /handoff.
Offers connected (enabled + configured) gateway platforms. A recorded
home channel is NOT required to list a platform it's often learned at
runtime so the meta hints whether one is set yet. Completes only the
first arg (the platform); once one is chosen, stop.
"""
parts = sub_text.split()
trailing_space = sub_text.endswith(" ")
if len(parts) > 1 or (len(parts) == 1 and trailing_space):
return
partial = "" if (not parts or trailing_space) else parts[-1]
partial_lower = partial.lower()
try:
from gateway.config import load_gateway_config
gw = load_gateway_config()
platforms = gw.get_connected_platforms()
except Exception:
return
for platform in platforms:
name = platform.value
if not name.startswith(partial_lower):
continue
try:
home = gw.get_home_channel(platform)
except Exception:
home = None
meta = f"{home.name}" if home and getattr(home, "name", None) else "send this session here"
yield Completion(
name,
start_position=-len(partial),
display=name,
display_meta=meta,
)
@staticmethod
def _personality_completions(sub_text: str, sub_lower: str):
"""Yield completions for /personality from configured personalities."""
try:
from hermes_cli.config import load_config
personalities = load_config().get("agent", {}).get("personalities", {})
# Resolve from the same source the runtime applies personalities —
# agent.personalities via the CLI config (which ships the built-ins).
# load_config()'s schema has no agent.personalities, so the completer
# used to come back empty even with personalities available.
from cli import load_cli_config
personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) or {}
if "none".startswith(sub_lower) and "none" != sub_lower:
yield Completion(
"none",
@@ -1602,6 +1730,17 @@ class SlashCommandCompleter(Completer):
yield from self._personality_completions(sub_text, sub_lower)
return
# /tools needs multi-word completion (subcommand + toolset name)
# so it handles both stages itself, bypassing the single-word
# SUBCOMMANDS branch below.
if base_cmd == "/tools":
yield from self._tools_completions(sub_text, sub_lower)
return
if base_cmd == "/handoff":
yield from self._handoff_completions(sub_text, sub_lower)
return
# Static subcommand completions
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
for sub in SUBCOMMANDS[base_cmd]:
+13
View File
@@ -863,6 +863,19 @@ DEFAULT_CONFIG = {
# identity slot (SOUL.md). Empty by default. The HERMES_ENVIRONMENT_HINT
# env var overrides this (build-time/container mechanism).
"environment_hint": "",
# Coding posture — on interactive coding surfaces (CLI, TUI, desktop
# app, ACP) in a code workspace, Hermes adds a coding operating brief
# + a live git/workspace snapshot to the system prompt. See
# agent/coding_context.py.
# "auto" (default) — prompt-only posture when the surface is
# interactive AND cwd is a code workspace.
# Toolsets are never touched; messaging platforms
# unaffected.
# "focus" — auto + collapse the toolset to the lean coding
# set (+ enabled MCP servers). Explicit opt-in.
# "on" — force the prompt posture everywhere.
# "off" — disable entirely.
"coding_context": "auto",
# Staged inactivity warning: send a warning to the user at this
# threshold before escalating to a full timeout. The warning fires
# once per run and does not interrupt the agent. 0 = disable warning.
-9
View File
@@ -120,9 +120,6 @@ def cron_list(show_all: bool = False):
workdir = job.get("workdir")
if workdir:
print(f" Workdir: {workdir}")
profile = job.get("profile")
if profile:
print(f" Profile: {profile}")
# Execution history
last_status = job.get("last_status")
@@ -221,7 +218,6 @@ def cron_create(args):
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
script=getattr(args, "script", None),
workdir=getattr(args, "workdir", None),
profile=getattr(args, "profile", None),
no_agent=getattr(args, "no_agent", False) or None,
)
if not result.get("success"):
@@ -239,8 +235,6 @@ def cron_create(args):
print(" Mode: no-agent (script stdout delivered directly)")
if job_data.get("workdir"):
print(f" Workdir: {job_data['workdir']}")
if job_data.get("profile"):
print(f" Profile: {job_data['profile']}")
print(f" Next run: {result['next_run_at']}")
return 0
@@ -286,7 +280,6 @@ def cron_edit(args):
skills=final_skills,
script=getattr(args, "script", None),
workdir=getattr(args, "workdir", None),
profile=getattr(args, "profile", None),
no_agent=getattr(args, "no_agent", None),
)
if not result.get("success"):
@@ -307,8 +300,6 @@ def cron_edit(args):
print(" Mode: no-agent (script stdout delivered directly)")
if updated.get("workdir"):
print(f" Workdir: {updated['workdir']}")
if updated.get("profile"):
print(f" Profile: {updated['profile']}")
return 0
+216 -13
View File
@@ -612,13 +612,54 @@ def find_profile_gateway_processes(
def _gateway_run_args_for_profile(profile: str) -> list[str]:
args = [get_python_path(), "-m", "hermes_cli.main"]
python_exe = get_python_path()
if is_windows():
# uv-created venv launchers are a trap here: ``venv\Scripts\pythonw.exe``
# starts hidden but then re-execs the *base* interpreter as a console
# ``python.exe`` — and that re-exec is a fresh CreateProcess that does
# NOT inherit our CREATE_NO_WINDOW flag, so a blank console window pops
# up. That's exactly what users hit when the gateway is respawned after
# a Desktop-GUI ``hermes update``. Resolve the base ``pythonw.exe``
# directly — the same path ``_spawn_detached`` / ``_build_gateway_argv``
# take for ``hermes gateway start`` — so the post-update respawn is
# windowless. The matching VIRTUAL_ENV / PYTHONPATH overlay is applied
# to the spawn env in ``launch_detached_profile_gateway_restart`` so
# imports still resolve without the venv launcher shim.
from hermes_cli.gateway_windows import _resolve_detached_python
python_exe, _venv_dir, _extra_pythonpath = _resolve_detached_python(python_exe)
args = [python_exe, "-m", "hermes_cli.main"]
if profile != "default":
args.extend(["--profile", profile])
args.extend(["gateway", "run", "--replace"])
return args
def _gateway_respawn_env(spawn_env: dict[str, str]) -> dict[str, str]:
"""Overlay VIRTUAL_ENV / PYTHONPATH so a base-``pythonw.exe`` respawn can
import ``hermes_cli`` without the venv launcher shim.
Returns ``spawn_env`` unchanged on non-Windows so the POSIX spawn path is
byte-for-byte identical to the pre-fix behaviour (it inherits ``os.environ``
exactly as before). On Windows it mirrors what ``_build_gateway_argv`` does
for ``hermes gateway start``: point VIRTUAL_ENV at the venv and prepend the
repo root plus base-interpreter site-packages to PYTHONPATH.
"""
if not is_windows():
return spawn_env
from hermes_cli.gateway_windows import (
_prepend_pythonpath,
_resolve_detached_python,
)
_python, venv_dir, extra_pythonpath = _resolve_detached_python(get_python_path())
spawn_env["VIRTUAL_ENV"] = str(venv_dir)
spawn_env["PYTHONIOENCODING"] = "utf-8"
spawn_env["HERMES_GATEWAY_DETACHED"] = "1"
_prepend_pythonpath(spawn_env, [str(PROJECT_ROOT), *extra_pythonpath])
return spawn_env
def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
"""Relaunch a manually-run profile gateway after its current PID exits."""
if old_pid <= 0:
@@ -703,14 +744,33 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
"""
).strip()
# Resolve the watcher interpreter to the windowless base ``pythonw.exe``
# on Windows for the same reason as the respawned gateway (see
# ``_gateway_run_args_for_profile``): ``sys.executable`` during a
# GUI-driven ``hermes update`` is a console ``python.exe`` whose uv venv
# launcher re-execs a visible console. ``_resolve_detached_python`` is a
# no-op shape on non-Windows callers because we only consult it under the
# ``is_windows()`` guard below.
watcher_python = sys.executable
if is_windows():
from hermes_cli.gateway_windows import _resolve_detached_python
watcher_python, _wv, _wpp = _resolve_detached_python(sys.executable)
watcher_argv = [
sys.executable,
watcher_python,
"-c",
watcher,
str(old_pid),
*_gateway_run_args_for_profile(profile),
]
# The watcher inherits this env and the respawned gateway inherits it from
# the watcher, so the base-``pythonw.exe`` legs can import ``hermes_cli``
# without the venv launcher shim. No-op on POSIX (returns os.environ copy
# unchanged), preserving the pre-fix spawn behaviour bit-for-bit there.
spawn_env = _gateway_respawn_env(dict(os.environ))
# Same platform-aware detach for the watcher process itself — so
# closing the user's terminal doesn't kill the watcher.
try:
@@ -718,6 +778,7 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
watcher_argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=spawn_env,
**windows_detach_popen_kwargs(),
)
except OSError:
@@ -736,6 +797,7 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
watcher_argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=spawn_env,
**fallback_kwargs,
)
except OSError:
@@ -2531,6 +2593,65 @@ def systemd_unit_is_current(system: bool = False) -> bool:
return norm_installed == norm_expected
def _temp_home_in_service_definition(definition: str) -> str | None:
"""Return the temp-dir HERMES_HOME baked into a service definition, or None.
A generated systemd unit / launchd plist carries the resolved HERMES_HOME
in its environment block. If that path lives under the system temp dir,
the definition was almost certainly generated by a test/E2E harness that
exported a throwaway ``HERMES_HOME=/tmp/...`` writing it to the real
service file silently breaks the user's gateway on the next (re)start:
the gateway comes back "active (running)" but pointed at an empty temp
home ("No messaging platforms enabled"), deaf to every platform.
Seen live 2026-06-11: an E2E guard probe ran ``hermes gateway restart``
with ``HERMES_HOME=/tmp/hermes-e2e-<pr>`` exported; the restart path's
unit refresh baked the temp path into the production unit and the
post-update restart produced a zombie gateway for 7+ hours.
Matches both systemd ``Environment="HERMES_HOME=..."`` lines and launchd
``<key>HERMES_HOME</key><string>...</string>`` pairs.
"""
import re
import tempfile
candidates = re.findall(r'HERMES_HOME=([^"\n]+)', definition)
candidates += re.findall(
r"<key>HERMES_HOME</key>\s*<string>(.*?)</string>", definition, flags=re.S
)
temp_roots = {
Path(tempfile.gettempdir()).resolve(),
Path("/tmp"),
Path("/var/tmp"),
Path("/private/tmp"),
Path("/private/var/tmp"),
}
for raw in candidates:
try:
resolved = Path(raw.strip().strip('"')).resolve()
except (OSError, ValueError):
continue
for root in temp_roots:
if resolved == root or root in resolved.parents:
return raw.strip()
return None
def _refuse_temp_home_service_write(definition: str, kind: str) -> bool:
"""Refuse (with guidance) when a service definition carries a temp HERMES_HOME."""
temp_home = _temp_home_in_service_definition(definition)
if temp_home is None:
return False
print(
f"✗ Refusing to write the gateway {kind}: HERMES_HOME resolves to a "
f"temporary directory ({temp_home})."
)
print(
" This usually means a test/E2E environment exported HERMES_HOME. "
"Unset it (or run from a clean shell) and retry."
)
return True
def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
"""Rewrite the installed systemd unit when the generated definition has changed."""
unit_path = get_systemd_unit_path(system=system)
@@ -2561,6 +2682,12 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
):
return False
# Structural variant of the same belt: refuse to bake ANY temp-dir
# HERMES_HOME into the unit (manual E2E homes like /tmp/hermes-e2e-NNN
# don't carry the pytest markers above but poison the unit identically).
if _refuse_temp_home_service_write(new_unit, "systemd unit"):
return False
unit_path.write_text(new_unit, encoding="utf-8")
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
print(
@@ -2729,10 +2856,11 @@ def systemd_install(
return
unit_path.parent.mkdir(parents=True, exist_ok=True)
new_unit = generate_systemd_unit(system=system, run_as_user=run_as_user)
if _refuse_temp_home_service_write(new_unit, "systemd unit"):
return
print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}")
unit_path.write_text(
generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8"
)
unit_path.write_text(new_unit, encoding="utf-8")
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
if enable_on_startup:
@@ -3067,12 +3195,77 @@ def get_launchd_label() -> str:
return f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
# Cached launchd domain result — probing is cheap but should only run once per
# process invocation (each ``hermes gateway start/stop/status`` call).
_resolved_launchd_domain: str | None = None
def _launchd_domain() -> str:
# The `user/<uid>` domain (vs the older `gui/<uid>`) is reachable from
# non-Aqua/background sessions (SSH, headless, login items) and is the only
# one that supports service management on macOS 26+. `gui/<uid>` returns
# error 125 ("Domain does not support specified action") there. See #23387.
return f"user/{os.getuid()}" # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows
"""Return the launchd domain that actually manages the gateway service.
Probes ``gui/<uid>`` first (Aqua sessions), then ``user/<uid>``
(Background/SSH sessions). When neither domain contains a loaded
service, falls back to ``launchctl managername`` as a heuristic.
The result is cached for the lifetime of the process so that repeated
calls (``start``, ``stop``, ``restart``) use a consistent domain.
See #40831, #23387.
"""
global _resolved_launchd_domain
if _resolved_launchd_domain is not None:
return _resolved_launchd_domain
uid = os.getuid() # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows
label = get_launchd_label()
gui_domain = f"gui/{uid}"
user_domain = f"user/{uid}"
# 1. Probe gui/<uid> first — in Aqua sessions the service is loaded here.
try:
subprocess.run(
["launchctl", "print", f"{gui_domain}/{label}"],
check=True,
timeout=5,
capture_output=True,
)
_resolved_launchd_domain = gui_domain
return gui_domain
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
pass
# 2. Probe user/<uid> — in Background/SSH sessions this is the working domain.
try:
subprocess.run(
["launchctl", "print", f"{user_domain}/{label}"],
check=True,
timeout=5,
capture_output=True,
)
_resolved_launchd_domain = user_domain
return user_domain
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
pass
# 3. Neither domain has the service loaded — use managername as heuristic.
# Aqua → gui/<uid>, anything else (Background, loginwindow) → user/<uid>.
try:
result = subprocess.run(
["launchctl", "managername"],
capture_output=True,
text=True,
timeout=5,
)
if "Aqua" in (result.stdout or ""):
_resolved_launchd_domain = gui_domain
return gui_domain
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
pass
# 4. Default to user/<uid> (matches the pre-probing behavior for
# Background/SSH sessions and is the recommended domain on macOS 26+).
_resolved_launchd_domain = user_domain
return user_domain
# On macOS, exit code 125 ("Domain does not support specified action") and
@@ -3297,7 +3490,11 @@ def refresh_launchd_plist_if_needed() -> bool:
if not plist_path.exists() or launchd_plist_is_current():
return False
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
new_plist = generate_launchd_plist()
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
return False
plist_path.write_text(new_plist, encoding="utf-8")
label = get_launchd_label()
# Bootout/bootstrap so launchd picks up the new definition
subprocess.run(
@@ -3330,8 +3527,11 @@ def launchd_install(force: bool = False):
return
plist_path.parent.mkdir(parents=True, exist_ok=True)
new_plist = generate_launchd_plist()
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
return
print(f"Installing launchd service to: {plist_path}")
plist_path.write_text(generate_launchd_plist())
plist_path.write_text(new_plist)
try:
subprocess.run(
@@ -3377,9 +3577,12 @@ def launchd_start():
# Self-heal if the plist is missing entirely (e.g., manual cleanup, failed upgrade)
if not plist_path.exists():
new_plist = generate_launchd_plist()
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
sys.exit(1)
print("↻ launchd plist missing; regenerating service definition")
plist_path.parent.mkdir(parents=True, exist_ok=True)
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
plist_path.write_text(new_plist, encoding="utf-8")
try:
subprocess.run(
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
+83 -2
View File
@@ -1623,7 +1623,11 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
npm_cwd = _workspace_root(tui_dir)
# --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty).
# See #38772.
npm_workspace_args: tuple[str, ...] = ("--workspace", "ui-tui")
# When ui-tui/ has its own package-lock.json (e.g. curl install),
# _workspace_root() returns tui_dir itself. Passing --workspace in
# that case fails because npm cannot find a workspace named "ui-tui"
# inside ui-tui/. See #42973.
npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else ("--workspace", "ui-tui")
if termux_startup:
npm_cwd, npm_workspace_args = _termux_workspace_install_context(
tui_dir,
@@ -4642,7 +4646,9 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
# graph (including apps/desktop with its Electron + node-pty deps) is never
# resolved here. Without --workspace the root package.json's apps/* glob
# would pull in desktop on every web build. See #38772.
npm_workspace_args: tuple[str, ...] = ("--workspace", "web")
# When web/ has its own package-lock.json, _workspace_root() returns
# web_dir itself and --workspace would fail. See #42973.
npm_workspace_args: tuple[str, ...] = () if npm_cwd == web_dir else ("--workspace", "web")
if _is_termux_startup_environment():
npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
r1 = _run_npm_install_deterministic(
@@ -10214,6 +10220,21 @@ def _report_dashboard_status() -> int:
return len(pids)
def _dashboard_listening(host: str, port: int) -> bool:
"""True when something is accepting TCP connections at host:port.
Any listener counts even a 401 response proves a dashboard is up.
Used by the unified profile-launch routing to decide attach-vs-start.
"""
import socket
try:
with socket.create_connection((host or "127.0.0.1", port), timeout=1.5):
return True
except OSError:
return False
def cmd_dashboard(args):
"""Start the web UI server, or (with --stop/--status) manage running ones."""
# --status: report running dashboards and exit, no deps needed.
@@ -10234,6 +10255,65 @@ def cmd_dashboard(args):
remaining = _find_stale_dashboard_pids()
sys.exit(1 if remaining else 0)
# ── Unified profile launch routing ────────────────────────────────
# The dashboard is a MACHINE management surface: it can read/write any
# profile via the per-request ?profile= scoping. Running one dashboard
# per profile just fragments that (port collisions, N processes, and a
# "which dashboard am I on?" guessing game). So when a NAMED profile
# launches the dashboard (`worker dashboard` → HERMES_HOME points into
# profiles/), default to the machine dashboard:
# - already running → open the browser at ?profile=<name> and exit
# - not running → re-exec as the machine dashboard (pinned to the
# default profile so _apply_profile_override can't re-route through
# the sticky active_profile file) with the launching profile
# preselected in the UI's switcher.
# `--isolated` opts out and preserves the old per-profile behavior.
try:
from hermes_cli.profiles import get_active_profile_name
_launch_profile = get_active_profile_name()
except Exception:
_launch_profile = "default"
if (
_launch_profile not in ("default", "custom")
and not getattr(args, "isolated", False)
and not getattr(args, "open_profile", "")
):
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
if _dashboard_listening(args.host, args.port):
print(f"Machine dashboard already running on port {args.port}.")
print(f" Managing profile '{_launch_profile}': {url}")
if not args.no_open:
try:
import webbrowser
webbrowser.open(url)
except Exception:
pass
sys.exit(0)
print(
f"Routing to the machine dashboard (profile '{_launch_profile}' "
f"preselected). Use --isolated for a dedicated per-profile server."
)
reexec_argv = [
sys.executable, "-m", "hermes_cli.main",
"-p", "default",
"dashboard",
"--port", str(args.port),
"--host", args.host,
"--open-profile", _launch_profile,
]
if args.no_open:
reexec_argv.append("--no-open")
if getattr(args, "insecure", False):
reexec_argv.append("--insecure")
if getattr(args, "skip_build", False):
reexec_argv.append("--skip-build")
env = os.environ.copy()
# Drop the profile HERMES_HOME so the child binds the machine root.
env.pop("HERMES_HOME", None)
os.execvpe(sys.executable, reexec_argv, env)
# Attach gui.log early so dashboard startup/build failures are captured in
# the same logs directory as every other Hermes surface.
try:
@@ -10307,6 +10387,7 @@ def cmd_dashboard(args):
port=args.port,
open_browser=not args.no_open,
allow_public=getattr(args, "insecure", False),
initial_profile=getattr(args, "open_profile", "") or "",
)
+29 -3
View File
@@ -491,15 +491,27 @@ def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> No
def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
requested_norm = _normalize_custom_provider_name(requested_provider or "")
if not requested_norm or requested_norm == "custom":
if not requested_norm:
return None
# Bare "custom" is normally an incomplete spec — the canonical form is
# "custom:<name>" — and is otherwise owned by the model.base_url "bare
# custom" trust path. BUT a user may literally name a ``providers:`` (or
# legacy ``custom_providers:``) entry "custom" (e.g. ``providers.custom``
# pointing at cliproxy). We used to return None here *before* scanning
# config, so such an entry was never matched and resolution fell through to
# the global default (Codex) — the cause of cron jobs with
# ``provider: "custom"`` failing with ``auth_unavailable: providers=codex``.
# Fall through to the config scan instead; if no entry is literally named
# "custom" it still returns None at the end, preserving the trust path.
# Raw names should only map to custom providers when they are not already
# valid built-in providers or aliases. Explicit menu keys like
# ``custom:local`` always target the saved custom provider.
# ``custom:local`` always target the saved custom provider. Bare "custom"
# is exempt from the shadow check — it is not a built-in to defer to.
if requested_norm == "auto":
return None
if not requested_norm.startswith("custom:"):
if requested_norm != "custom" and not requested_norm.startswith("custom:"):
try:
canonical = auth_mod.resolve_provider(requested_norm)
except AuthError:
@@ -634,6 +646,20 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
return None
def has_named_custom_provider(requested_provider: str) -> bool:
"""Return True when config defines a custom provider matching the request.
Thin public wrapper around :func:`_get_named_custom_provider` so other
modules (e.g. the cronjob tool) can decide whether a provider name will
actually resolve to a configured ``providers:`` / ``custom_providers:``
entry without reaching into a private helper or duplicating the scan.
"""
try:
return _get_named_custom_provider(requested_provider) is not None
except Exception:
return False
def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]:
extra_body = custom_provider.get("extra_body")
if not isinstance(extra_body, dict) or not extra_body:
-8
View File
@@ -70,10 +70,6 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
"--workdir",
help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).",
)
cron_create.add_argument(
"--profile",
help="Hermes profile name to run the job under. Use 'default' for the root profile. Named profiles must already exist. Omit to preserve the scheduler's existing profile.",
)
# cron edit
cron_edit = cron_subparsers.add_parser(
@@ -138,10 +134,6 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
"--workdir",
help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.",
)
cron_edit.add_argument(
"--profile",
help="Hermes profile name to run the job under. Use 'default' for the root profile. Pass empty string to clear.",
)
# lifecycle actions
cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")
+20
View File
@@ -45,6 +45,26 @@ def build_dashboard_parser(
"where npm may not be available. Pre-build with: cd web && npm run build"
),
)
dashboard_parser.add_argument(
"--isolated",
action="store_true",
help=(
"When launched from a named profile (e.g. `worker dashboard`), run "
"a dedicated dashboard server scoped to that profile instead of "
"routing to the machine dashboard. Default behavior is unified: "
"profile launches attach to (or start) ONE machine-level dashboard "
"and preselect the profile in the UI's profile switcher."
),
)
# Internal flag set by the unified-launch re-exec (cmd_dashboard) to
# preselect the launching profile in the SPA switcher. Hidden from
# --help: users get this behavior automatically via `<profile> dashboard`.
dashboard_parser.add_argument(
"--open-profile",
dest="open_profile",
default="",
help=argparse.SUPPRESS,
)
# Lifecycle flags — mutually exclusive with each other and with the
# start-a-server flags above (if both are passed, --stop / --status win
# because they exit before the server is started). The dashboard has
+11 -2
View File
@@ -1437,6 +1437,10 @@ def _get_platform_tools(
continue
if ts_def.get("includes"):
continue
# Posture toolsets (e.g. ``coding``) are session-level selections made
# by agent/coding_context.py — not per-platform capabilities to recover.
if ts_def.get("posture"):
continue
ts_tools = set(resolve_toolset(ts_key))
if not ts_tools or not ts_tools.issubset(platform_tool_universe):
continue
@@ -2178,8 +2182,13 @@ def _toolset_needs_configuration_prompt(
tts_cfg = config.get("tts", {})
return not isinstance(tts_cfg, dict) or "provider" not in tts_cfg
if ts_key == "web":
web_cfg = config.get("web", {})
return not isinstance(web_cfg, dict) or "backend" not in web_cfg
# Web works out of the box via Parallel's free Search MCP (no key), so
# don't force setup just because ``web.backend`` is unset — only prompt
# when web isn't actually usable (e.g. an explicit backend configured
# without its credentials). Lazy import: web_tools is heavy and most
# tools_config callers don't need it.
from tools.web_tools import check_web_api_key
return not check_web_api_key()
if ts_key == "browser":
browser_cfg = config.get("browser", {})
return not isinstance(browser_cfg, dict) or "cloud_provider" not in browser_cfg
+1290 -303
View File
File diff suppressed because it is too large Load Diff
+83 -5
View File
@@ -1715,15 +1715,51 @@ class SessionDB:
"""Archive or unarchive a session.
Archived sessions are hidden from the default session list but keep all
their messages this is a soft hide, not a delete. Returns True when a
row was updated.
their messages this is a soft hide, not a delete. For compression
chains, archive the whole logical conversation. Desktop lists compression
roots projected forward to their latest continuation; updating only the
displayed tip lets the still-unarchived root resurrect it on refresh.
Returns True when at least one row was updated.
"""
def _do(conn):
cursor = conn.execute(
"UPDATE sessions SET archived = ? WHERE id = ?",
(1 if archived else 0, session_id),
"""
WITH RECURSIVE
ancestors(id) AS (
SELECT ?
UNION
SELECT parent.id
FROM ancestors a
JOIN sessions child ON child.id = a.id
JOIN sessions parent ON parent.id = child.parent_session_id
WHERE parent.end_reason = 'compression'
AND child.started_at >= parent.ended_at
),
descendants(id) AS (
SELECT ?
UNION
SELECT child.id
FROM descendants d
JOIN sessions parent ON parent.id = d.id
JOIN sessions child ON child.parent_session_id = parent.id
WHERE parent.end_reason = 'compression'
AND child.started_at >= parent.ended_at
),
lineage(id) AS (
SELECT id FROM ancestors
UNION
SELECT id FROM descendants
)
UPDATE sessions
SET archived = ?
WHERE id IN (SELECT id FROM lineage)
""",
(session_id, session_id, 1 if archived else 0),
)
return cursor.rowcount
rowcount = cursor.rowcount
if rowcount is None or rowcount < 0:
rowcount = conn.execute("SELECT changes()").fetchone()[0]
return rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
@@ -3658,6 +3694,48 @@ class SessionDB:
self._remove_session_files(sessions_dir, session_id)
return deleted
def delete_session_if_empty(
self,
session_id: str,
sessions_dir: Optional[Path] = None,
) -> bool:
"""Delete *session_id* only when it never gained resumable content.
A session is considered empty when it has no messages and no
user-assigned title. Used by CLI exit / session-rotation paths so
immediately-started-and-quit sessions don't pile up in ``/resume``
and ``hermes sessions list`` output. (Pattern ported from
google-gemini/gemini-cli#27770.)
The emptiness check and delete run in one transaction, so a message
flushed concurrently by another writer can't be lost. Sessions with
children (delegate subagent runs) are preserved a parent that
spawned work is not "empty" even if its own transcript never
flushed. Returns True if the session was deleted.
"""
def _do(conn):
cursor = conn.execute(
"""
DELETE FROM sessions
WHERE id = ?
AND title IS NULL
AND NOT EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = sessions.id
)
AND NOT EXISTS (
SELECT 1 FROM sessions child
WHERE child.parent_session_id = sessions.id
)
""",
(session_id,),
)
return cursor.rowcount > 0
deleted = self._execute_write(_do)
if deleted:
self._remove_session_files(sessions_dir, session_id)
return bool(deleted)
def delete_sessions(
self,
session_ids: List[str],
+14
View File
@@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Sessie-databasis is nie beskikbaar nie."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Geen benoemde sessies gevind nie.\nGebruik `/title My Sessie` om jou huidige sessie 'n naam te gee, en dan `/resume My Sessie` om later daarheen terug te keer."
list_header: "📋 **Benoemde Sessies**\n"
list_item: "• **{title}**{preview_part}"
@@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes Gateway Status**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**Sessie-ID:** `{session_id}`"
title: "**Titel:** {title}"
created: "**Geskep:** {timestamp}"
+14
View File
@@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Sitzungsdatenbank nicht verfügbar."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Keine benannten Sitzungen gefunden.\nVerwenden Sie `/title Meine Sitzung`, um die aktuelle Sitzung zu benennen, dann `/resume Meine Sitzung`, um später dorthin zurückzukehren."
list_header: "📋 **Benannte Sitzungen**\n"
list_item: "• **{title}**{preview_part}"
@@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes-Gateway-Status**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**Sitzungs-ID:** `{session_id}`"
title: "**Titel:** {title}"
created: "**Erstellt:** {timestamp}"
+11
View File
@@ -234,6 +234,11 @@ gateway:
resume:
db_unavailable: "Session database not available."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.\nUse quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.\nUse `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.\nFuture messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "No named sessions found.\nUse `/title My Session` to name your current session, then `/resume My Session` to return to it later."
list_header: "📋 **Named Sessions**\n"
list_item: "• **{title}**{preview_part}"
@@ -266,6 +271,12 @@ gateway:
status:
header: "📊 **Hermes Gateway Status**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**Session ID:** `{session_id}`"
title: "**Title:** {title}"
created: "**Created:** {timestamp}"
+14
View File
@@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Base de datos de sesiones no disponible."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "No se encontraron sesiones con nombre.\nUsa `/title Mi sesión` para nombrar la sesión actual y luego `/resume Mi sesión` para volver a ella."
list_header: "📋 **Sesiones con nombre**\n"
list_item: "• **{title}**{preview_part}"
@@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Estado de Hermes Gateway**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**ID de sesión:** `{session_id}`"
title: "**Título:** {title}"
created: "**Creado:** {timestamp}"
+14
View File
@@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Base de données des sessions indisponible."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Aucune session nommée trouvée.\nUtilisez `/title Ma session` pour nommer la session actuelle, puis `/resume Ma session` pour y revenir plus tard."
list_header: "📋 **Sessions nommées**\n"
list_item: "• **{title}**{preview_part}"
@@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **État de Hermes Gateway**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**ID de session :** `{session_id}`"
title: "**Titre :** {title}"
created: "**Créé :** {timestamp}"
+14
View File
@@ -223,6 +223,14 @@ gateway:
resume:
db_unavailable: "Níl bunachar sonraí na seisiún ar fáil."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Níor aimsíodh aon seisiún ainmnithe.\nÚsáid `/title M'Ainm Seisiúin` chun do sheisiún reatha a ainmniú, ansin `/resume M'Ainm Seisiúin` chun filleadh air níos déanaí."
list_header: "📋 **Seisiúin Ainmnithe**\n"
list_item: "• **{title}**{preview_part}"
@@ -255,6 +263,12 @@ gateway:
status:
header: "📊 **Stádas Hermes Gateway**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**ID Seisiúin:** `{session_id}`"
title: "**Teideal:** {title}"
created: "**Cruthaithe:** {timestamp}"
+14
View File
@@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "A munkamenet-adatbázis nem érhető el."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Nem található elnevezett munkamenet.\nHasználd a `/title Saját munkamenet` parancsot a jelenlegi munkamenet elnevezéséhez, majd a `/resume Saját munkamenet` paranccsal térhetsz vissza hozzá."
list_header: "📋 **Elnevezett munkamenetek**\n"
list_item: "• **{title}**{preview_part}"
@@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes Gateway állapot**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**Munkamenet-azonosító:** `{session_id}`"
title: "**Cím:** {title}"
created: "**Létrehozva:** {timestamp}"
+14
View File
@@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Database delle sessioni non disponibile."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Nessuna sessione con nome trovata.\nUsa `/title My Session` per dare un nome alla sessione attuale, poi `/resume My Session` per tornare a essa in seguito."
list_header: "📋 **Sessioni con nome**\n"
list_item: "• **{title}**{preview_part}"
@@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Stato del Gateway Hermes**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**ID sessione:** `{session_id}`"
title: "**Titolo:** {title}"
created: "**Creata:** {timestamp}"
+14
View File
@@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "セッションデータベースは利用できません。"
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "名前付きセッションが見つかりません。\n`/title セッション名` で現在のセッションに名前を付けると、後で `/resume セッション名` で戻れます。"
list_header: "📋 **名前付きセッション**\n"
list_item: "• **{title}**{preview_part}"
@@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes ゲートウェイ状態**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**セッション ID:** `{session_id}`"
title: "**タイトル:** {title}"
created: "**作成日時:** {timestamp}"

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